mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-23 02:36:05 +00:00
feat(projectFolders): add data validation and repair
This commit is contained in:
parent
f7d5b0610b
commit
beb29db05f
5 changed files with 386 additions and 2 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
191
src/app/pfapi/repair/repair-menu-tree.spec.ts
Normal file
191
src/app/pfapi/repair/repair-menu-tree.spec.ts
Normal 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' }]);
|
||||
});
|
||||
});
|
||||
74
src/app/pfapi/repair/repair-menu-tree.ts
Normal file
74
src/app/pfapi/repair/repair-menu-tree.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue