feat(projectFolders): add data validation and repair

This commit is contained in:
Johannes Millan 2025-10-07 14:03:00 +02:00
parent f7d5b0610b
commit beb29db05f
5 changed files with 386 additions and 2 deletions

View file

@ -141,7 +141,7 @@ export class MagicNavConfigService {
this._projectNavTree().length > 0
? this._projectNavTree()
: this._visibleProjects().map((project) => ({
kind: 'project',
kind: MenuTreeKind.PROJECT,
project,
})),
action: () => this._toggleProjectsExpanded(),
@ -178,7 +178,7 @@ export class MagicNavConfigService {
this._tagNavTree().length > 0
? this._tagNavTree()
: this._tags().map((tag) => ({
kind: 'tag',
kind: MenuTreeKind.TAG,
tag,
})),
action: () => this._toggleTagsExpanded(),

View file

@ -16,6 +16,7 @@ import { INBOX_PROJECT } from '../../features/project/project.const';
import { autoFixTypiaErrors } from './auto-fix-typia-errors';
import { IValidation } from 'typia';
import { PFLog } from '../../core/log';
import { repairMenuTree } from './repair-menu-tree';
// TODO improve later
const ENTITY_STATE_KEYS: (keyof AppDataCompleteLegacy)[] = ALL_ENTITY_MODEL_KEYS;
@ -73,6 +74,7 @@ export const dataRepair = (
dataOut = _removeNonExistentProjectIdsFromTasks(dataOut);
dataOut = _removeNonExistentTagsFromTasks(dataOut);
dataOut = _addInboxProjectIdIfNecessary(dataOut);
dataOut = _repairMenuTree(dataOut);
dataOut = autoFixTypiaErrors(dataOut, errors);
// console.timeEnd('dataRepair');
@ -805,3 +807,16 @@ const _cleanupOrphanedSubTasks = (data: AppDataCompleteNew): AppDataCompleteNew
return data;
};
const _repairMenuTree = (data: AppDataCompleteNew): AppDataCompleteNew => {
if (!data.menuTree) {
return data;
}
const validProjectIds = new Set<string>(data.project.ids as string[]);
const validTagIds = new Set<string>(data.tag.ids as string[]);
data.menuTree = repairMenuTree(data.menuTree, validProjectIds, validTagIds);
return data;
};

View file

@ -0,0 +1,191 @@
import { repairMenuTree } from './repair-menu-tree';
import {
MenuTreeKind,
MenuTreeState,
} from '../../features/menu-tree/store/menu-tree.model';
describe('repairMenuTree', () => {
it('should remove orphaned project references from projectTree', () => {
const validProjectIds = new Set(['project1', 'project2']);
const validTagIds = new Set<string>();
const menuTree: MenuTreeState = {
projectTree: [
{ kind: MenuTreeKind.PROJECT, id: 'project1' },
{ kind: MenuTreeKind.PROJECT, id: 'orphaned-project' },
{ kind: MenuTreeKind.PROJECT, id: 'project2' },
],
tagTree: [],
};
const result = repairMenuTree(menuTree, validProjectIds, validTagIds);
expect(result.projectTree.length).toBe(2);
expect(result.projectTree).toEqual([
{ kind: MenuTreeKind.PROJECT, id: 'project1' },
{ kind: MenuTreeKind.PROJECT, id: 'project2' },
]);
});
it('should remove orphaned tag references from tagTree', () => {
const validProjectIds = new Set<string>();
const validTagIds = new Set(['tag1', 'tag2']);
const menuTree: MenuTreeState = {
projectTree: [],
tagTree: [
{ kind: MenuTreeKind.TAG, id: 'tag1' },
{ kind: MenuTreeKind.TAG, id: 'orphaned-tag' },
{ kind: MenuTreeKind.TAG, id: 'tag2' },
],
};
const result = repairMenuTree(menuTree, validProjectIds, validTagIds);
expect(result.tagTree.length).toBe(2);
expect(result.tagTree).toEqual([
{ kind: MenuTreeKind.TAG, id: 'tag1' },
{ kind: MenuTreeKind.TAG, id: 'tag2' },
]);
});
it('should keep folders even if they end up empty', () => {
const validProjectIds = new Set(['project1']);
const validTagIds = new Set<string>();
const menuTree: MenuTreeState = {
projectTree: [
{
kind: 'folder',
id: 'folder1',
name: 'Folder 1',
isExpanded: true,
children: [
{ kind: MenuTreeKind.PROJECT, id: 'project1' },
{ kind: MenuTreeKind.PROJECT, id: 'orphaned-project' },
],
},
],
tagTree: [],
};
const result = repairMenuTree(menuTree, validProjectIds, validTagIds);
expect(result.projectTree.length).toBe(1);
expect(result.projectTree[0].kind).toBe('folder');
if (result.projectTree[0].kind === 'folder') {
expect(result.projectTree[0].id).toBe('folder1');
expect(result.projectTree[0].children.length).toBe(1);
expect(result.projectTree[0].children[0]).toEqual({
kind: MenuTreeKind.PROJECT,
id: 'project1',
});
}
});
it('should handle nested folders correctly', () => {
const validProjectIds = new Set(['project1', 'project2']);
const validTagIds = new Set<string>();
const menuTree: MenuTreeState = {
projectTree: [
{
kind: 'folder',
id: 'parent-folder',
name: 'Parent',
isExpanded: true,
children: [
{ kind: MenuTreeKind.PROJECT, id: 'project1' },
{
kind: 'folder',
id: 'nested-folder',
name: 'Nested',
isExpanded: false,
children: [
{ kind: MenuTreeKind.PROJECT, id: 'project2' },
{ kind: MenuTreeKind.PROJECT, id: 'orphaned-nested-project' },
],
},
],
},
],
tagTree: [],
};
const result = repairMenuTree(menuTree, validProjectIds, validTagIds);
expect(result.projectTree.length).toBe(1);
if (result.projectTree[0].kind === 'folder') {
expect(result.projectTree[0].children.length).toBe(2);
const nestedFolder = result.projectTree[0].children[1];
if (nestedFolder.kind === 'folder') {
expect(nestedFolder.children.length).toBe(1);
expect(nestedFolder.children[0]).toEqual({
kind: MenuTreeKind.PROJECT,
id: 'project2',
});
}
}
});
it('should return empty arrays for invalid tree structures', () => {
const validProjectIds = new Set(['project1']);
const validTagIds = new Set<string>();
const menuTree: MenuTreeState = {
projectTree: null as any,
tagTree: undefined as any,
};
const result = repairMenuTree(menuTree, validProjectIds, validTagIds);
expect(result.projectTree).toEqual([]);
expect(result.tagTree).toEqual([]);
});
it('should preserve valid folder structure', () => {
const validProjectIds = new Set(['project1', 'project2']);
const validTagIds = new Set<string>();
const menuTree: MenuTreeState = {
projectTree: [
{
kind: 'folder',
id: 'folder1',
name: 'Work Projects',
isExpanded: true,
children: [
{ kind: MenuTreeKind.PROJECT, id: 'project1' },
{ kind: MenuTreeKind.PROJECT, id: 'project2' },
],
},
],
tagTree: [],
};
const result = repairMenuTree(menuTree, validProjectIds, validTagIds);
expect(result.projectTree).toEqual(menuTree.projectTree);
});
it('should remove mismatched node kinds', () => {
const validProjectIds = new Set(['project1']);
const validTagIds = new Set(['tag1']);
const menuTree: MenuTreeState = {
projectTree: [
{ kind: MenuTreeKind.PROJECT, id: 'project1' },
{ kind: MenuTreeKind.TAG, id: 'tag1' } as any,
],
tagTree: [
{ kind: MenuTreeKind.TAG, id: 'tag1' },
{ kind: MenuTreeKind.PROJECT, id: 'project1' } as any,
],
};
const result = repairMenuTree(menuTree, validProjectIds, validTagIds);
expect(result.projectTree).toEqual([{ kind: MenuTreeKind.PROJECT, id: 'project1' }]);
expect(result.tagTree).toEqual([{ kind: MenuTreeKind.TAG, id: 'tag1' }]);
});
});

View file

@ -0,0 +1,74 @@
import {
MenuTreeKind,
MenuTreeState,
MenuTreeTreeNode,
} from '../../features/menu-tree/store/menu-tree.model';
import { PFLog } from '../../core/log';
/**
* Repairs menuTree by removing orphaned project/tag references
* @param menuTree The menuTree state to repair
* @param validProjectIds Set of valid project IDs
* @param validTagIds Set of valid tag IDs
* @returns Repaired menuTree state
*/
export const repairMenuTree = (
menuTree: MenuTreeState,
validProjectIds: Set<string>,
validTagIds: Set<string>,
): MenuTreeState => {
PFLog.log('Repairing menuTree - removing orphaned references');
/**
* Recursively filters tree nodes, removing orphaned project/tag references
* and empty folders
*/
const filterTreeNodes = (
nodes: MenuTreeTreeNode[],
treeType: 'projectTree' | 'tagTree',
): MenuTreeTreeNode[] => {
const filtered: MenuTreeTreeNode[] = [];
for (const node of nodes) {
if (node.kind === 'folder') {
const filteredChildren = filterTreeNodes(node.children, treeType);
filtered.push({
...node,
children: filteredChildren,
});
} else if (treeType === 'projectTree' && node.kind === MenuTreeKind.PROJECT) {
// Keep project only if it exists
if (validProjectIds.has(node.id)) {
filtered.push(node);
} else {
PFLog.log(`Removing orphaned project reference ${node.id} from ${treeType}`);
}
} else if (treeType === 'tagTree' && node.kind === MenuTreeKind.TAG) {
// Keep tag only if it exists
if (validTagIds.has(node.id)) {
filtered.push(node);
} else {
PFLog.log(`Removing orphaned tag reference ${node.id} from ${treeType}`);
}
} else {
// kind mismatch or unknown
PFLog.warn(`Removing invalid node from ${treeType}:`, node);
}
}
return filtered;
};
const repairedProjectTree = Array.isArray(menuTree.projectTree)
? filterTreeNodes(menuTree.projectTree, 'projectTree')
: [];
const repairedTagTree = Array.isArray(menuTree.tagTree)
? filterTreeNodes(menuTree.tagTree, 'tagTree')
: [];
return {
projectTree: repairedProjectTree,
tagTree: repairedTagTree,
};
};

View file

@ -44,6 +44,11 @@ export const isRelatedModelDataValid = (d: AppDataCompleteNew): boolean => {
return false;
}
// Validate menuTree
if (!validateMenuTree(d, projectIds, tagIds)) {
return false;
}
return true;
};
@ -357,3 +362,102 @@ const validateReminders = (d: AppDataCompleteNew): boolean => {
return true;
};
const validateMenuTree = (
d: AppDataCompleteNew,
projectIds: Set<string>,
tagIds: Set<string>,
): boolean => {
// Recursive function to validate tree nodes
const validateTreeNodes = (
nodes: any[],
treeType: 'projectTree' | 'tagTree',
): boolean => {
for (const node of nodes) {
if (!node || typeof node !== 'object') {
_validityError(`Invalid menuTree node in ${treeType}`, { node, d });
return false;
}
if (node.kind === 'folder') {
// Validate folder structure
if (!node.id || !node.name) {
_validityError(`Invalid folder node in ${treeType} - missing id or name`, {
node,
d,
});
return false;
}
if (!Array.isArray(node.children)) {
_validityError(`Invalid folder node in ${treeType} - children not array`, {
node,
d,
});
return false;
}
// Recursively validate children
if (!validateTreeNodes(node.children, treeType)) {
return false;
}
} else if (treeType === 'projectTree' && node.kind === 'project') {
// Validate project reference
if (!node.id) {
_validityError(`Project node in menuTree missing id`, { node, d });
return false;
}
if (!projectIds.has(node.id)) {
_validityError(
`Orphaned project reference in menuTree - project ${node.id} doesn't exist`,
{ node, treeType, d },
);
return false;
}
} else if (treeType === 'tagTree' && node.kind === 'tag') {
// Validate tag reference
if (!node.id) {
_validityError(`Tag node in menuTree missing id`, { node, d });
return false;
}
if (!tagIds.has(node.id)) {
_validityError(
`Orphaned tag reference in menuTree - tag ${node.id} doesn't exist`,
{ node, treeType, d },
);
return false;
}
} else {
_validityError(`Invalid node kind in ${treeType}: ${node.kind}`, { node, d });
return false;
}
}
return true;
};
// Validate projectTree
if (d.menuTree?.projectTree) {
if (!Array.isArray(d.menuTree.projectTree)) {
_validityError('menuTree.projectTree is not an array', { d });
return false;
}
if (!validateTreeNodes(d.menuTree.projectTree, 'projectTree')) {
return false;
}
}
// Validate tagTree
if (d.menuTree?.tagTree) {
if (!Array.isArray(d.menuTree.tagTree)) {
_validityError('menuTree.tagTree is not an array', { d });
return false;
}
if (!validateTreeNodes(d.menuTree.tagTree, 'tagTree')) {
return false;
}
}
return true;
};