@dinoxx/dinox-cli 1.0.19 → 1.0.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +756 -472
- package/dist/auth/userInfo.d.ts +10 -1
- package/dist/auth/userInfo.js +66 -17
- package/dist/cli.js +0 -2
- package/dist/commands/auth/index.js +29 -14
- package/dist/commands/boxes/index.js +272 -52
- package/dist/commands/boxes/model.d.ts +34 -0
- package/dist/commands/boxes/model.js +78 -0
- package/dist/commands/boxes/operations.d.ts +86 -0
- package/dist/commands/boxes/operations.js +415 -0
- package/dist/commands/boxes/repo.d.ts +13 -6
- package/dist/commands/boxes/repo.js +272 -149
- package/dist/commands/boxes/store.d.ts +22 -0
- package/dist/commands/boxes/store.js +143 -0
- package/dist/commands/graph/index.js +2 -36
- package/dist/commands/mcp/noteContentTools.d.ts +222 -0
- package/dist/commands/mcp/noteContentTools.js +390 -0
- package/dist/commands/mcp/server.d.ts +3 -0
- package/dist/commands/mcp/server.js +45 -16
- package/dist/commands/mcp/tools.js +3 -1
- package/dist/commands/mcp/types.d.ts +6 -2
- package/dist/commands/meta/index.js +6 -22
- package/dist/commands/meta/repo.d.ts +0 -3
- package/dist/commands/meta/repo.js +1 -4
- package/dist/commands/notes/actions.d.ts +2 -0
- package/dist/commands/notes/actions.js +149 -55
- package/dist/commands/notes/bulk.d.ts +34 -0
- package/dist/commands/notes/bulk.js +204 -0
- package/dist/commands/notes/contentEngine.d.ts +95 -0
- package/dist/commands/notes/contentEngine.js +652 -0
- package/dist/commands/notes/contentPatch.d.ts +84 -0
- package/dist/commands/notes/contentPatch.js +337 -0
- package/dist/commands/notes/contentPatchCommand.d.ts +3 -0
- package/dist/commands/notes/contentPatchCommand.js +126 -0
- package/dist/commands/notes/descriptors.js +192 -40
- package/dist/commands/notes/export.d.ts +21 -0
- package/dist/commands/notes/export.js +224 -0
- package/dist/commands/notes/metadataMutation.d.ts +28 -0
- package/dist/commands/notes/metadataMutation.js +127 -0
- package/dist/commands/notes/repo.d.ts +20 -0
- package/dist/commands/notes/repo.js +135 -18
- package/dist/commands/notes/runtime.d.ts +3 -12
- package/dist/commands/notes/runtime.js +4 -30
- package/dist/commands/notes/searchSql.js +1 -1
- package/dist/commands/prompt/index.js +15 -45
- package/dist/commands/prompt/repo.js +2 -2
- package/dist/commands/runtime.d.ts +26 -0
- package/dist/commands/runtime.js +76 -0
- package/dist/commands/schema/renderSkills.js +20 -7
- package/dist/commands/schema/spec.d.ts +1 -2
- package/dist/commands/schema/spec.js +2 -3
- package/dist/commands/schema/types.d.ts +0 -13
- package/dist/commands/storage/index.js +30 -60
- package/dist/commands/storage/repo.d.ts +1 -1
- package/dist/commands/storage/repo.js +20 -1
- package/dist/commands/sync.js +4 -2
- package/dist/commands/tags/content.d.ts +32 -0
- package/dist/commands/tags/content.js +243 -0
- package/dist/commands/tags/index.js +285 -49
- package/dist/commands/tags/model.d.ts +38 -0
- package/dist/commands/tags/model.js +100 -0
- package/dist/commands/tags/operations.d.ts +105 -0
- package/dist/commands/tags/operations.js +438 -0
- package/dist/commands/tags/repo.d.ts +5 -1
- package/dist/commands/tags/repo.js +142 -218
- package/dist/commands/tags/store.d.ts +28 -0
- package/dist/commands/tags/store.js +166 -0
- package/dist/commands/todo/index.js +30 -61
- package/dist/commands/writeDryRun.d.ts +25 -0
- package/dist/commands/writeDryRun.js +31 -1
- package/dist/config/paths.d.ts +2 -1
- package/dist/config/paths.js +11 -1
- package/dist/config/resolve.js +9 -7
- package/dist/daemon/index.d.ts +2 -0
- package/dist/daemon/index.js +176 -58
- package/dist/powersync/runtime.d.ts +2 -1
- package/dist/powersync/runtime.js +15 -3
- package/dist/powersync/schema/content.d.ts +2 -0
- package/dist/powersync/schema/content.js +2 -0
- package/dist/powersync/schema/index.d.ts +2 -0
- package/dist/skills/check.d.ts +1 -1
- package/dist/skills/check.js +27 -2
- package/dist/utils/authToken.d.ts +2 -0
- package/dist/utils/authToken.js +7 -2
- package/dist/utils/noteContentEngine.d.ts +333 -0
- package/dist/utils/noteContentEngine.js +740 -0
- package/dist/utils/tiptapMarkdownSchema.js +31 -0
- package/package.json +4 -8
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export declare const MAX_BOX_NAME_LENGTH = 500;
|
|
2
|
+
export declare const MAX_BOX_PATH_LENGTH = 2000;
|
|
3
|
+
export declare const MAX_BOX_DESCRIPTION_LENGTH = 200000;
|
|
4
|
+
export declare const MAX_BOX_COLOR_LENGTH = 200;
|
|
5
|
+
export type BoxListItem = {
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
path: string;
|
|
9
|
+
parentId: string | null;
|
|
10
|
+
description: string;
|
|
11
|
+
color: string;
|
|
12
|
+
};
|
|
13
|
+
export type MutableBoxNode = {
|
|
14
|
+
id: string;
|
|
15
|
+
name: string;
|
|
16
|
+
path: string;
|
|
17
|
+
parentId: string | null;
|
|
18
|
+
description: string;
|
|
19
|
+
color: string;
|
|
20
|
+
isDel: number | null;
|
|
21
|
+
userNo: string;
|
|
22
|
+
};
|
|
23
|
+
export type TableInfoRow = {
|
|
24
|
+
name: string | null;
|
|
25
|
+
};
|
|
26
|
+
export declare const REQUIRED_ZETTEL_BOX_COLUMNS: string[];
|
|
27
|
+
export declare const REQUIRED_NOTE_BOX_COLUMNS: string[];
|
|
28
|
+
export declare function canonicalizeBoxPath(value: string): string;
|
|
29
|
+
export declare function normalizeBoxLookupKey(value: string): string;
|
|
30
|
+
export declare function trimString(value: string | null | undefined): string;
|
|
31
|
+
export declare function toNullableId(value: string | null | undefined): string | null;
|
|
32
|
+
export declare function getLeafName(path: string): string;
|
|
33
|
+
export declare function assertMaxLength(label: string, value: string, maxLength: number): void;
|
|
34
|
+
export declare function parseStoredBoxIds(raw: string | null): string[];
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export const MAX_BOX_NAME_LENGTH = 500;
|
|
2
|
+
export const MAX_BOX_PATH_LENGTH = 2000;
|
|
3
|
+
export const MAX_BOX_DESCRIPTION_LENGTH = 200_000;
|
|
4
|
+
export const MAX_BOX_COLOR_LENGTH = 200;
|
|
5
|
+
export const REQUIRED_ZETTEL_BOX_COLUMNS = [
|
|
6
|
+
'id',
|
|
7
|
+
'name',
|
|
8
|
+
'path',
|
|
9
|
+
'parent_id',
|
|
10
|
+
'description',
|
|
11
|
+
'color',
|
|
12
|
+
'priority',
|
|
13
|
+
'is_show',
|
|
14
|
+
'cover_url',
|
|
15
|
+
'created_at',
|
|
16
|
+
'updated_at',
|
|
17
|
+
'extra_data',
|
|
18
|
+
'user_no',
|
|
19
|
+
'is_del',
|
|
20
|
+
];
|
|
21
|
+
export const REQUIRED_NOTE_BOX_COLUMNS = [
|
|
22
|
+
'id',
|
|
23
|
+
'zettel_boxes',
|
|
24
|
+
'updated_at',
|
|
25
|
+
'version',
|
|
26
|
+
'is_del',
|
|
27
|
+
];
|
|
28
|
+
export function canonicalizeBoxPath(value) {
|
|
29
|
+
return value
|
|
30
|
+
.split(/[\\/]+/g)
|
|
31
|
+
.map((segment) => segment.trim())
|
|
32
|
+
.filter(Boolean)
|
|
33
|
+
.join('/');
|
|
34
|
+
}
|
|
35
|
+
export function normalizeBoxLookupKey(value) {
|
|
36
|
+
return canonicalizeBoxPath(value).toLowerCase();
|
|
37
|
+
}
|
|
38
|
+
export function trimString(value) {
|
|
39
|
+
return value?.trim() ?? '';
|
|
40
|
+
}
|
|
41
|
+
export function toNullableId(value) {
|
|
42
|
+
const trimmed = trimString(value);
|
|
43
|
+
return trimmed ? trimmed : null;
|
|
44
|
+
}
|
|
45
|
+
export function getLeafName(path) {
|
|
46
|
+
return path.split('/').at(-1) ?? path;
|
|
47
|
+
}
|
|
48
|
+
export function assertMaxLength(label, value, maxLength) {
|
|
49
|
+
if (value.length > maxLength) {
|
|
50
|
+
throw new DinoxError(`${label} must be at most ${maxLength} characters`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export function parseStoredBoxIds(raw) {
|
|
54
|
+
if (!raw) {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const parsed = JSON.parse(raw);
|
|
59
|
+
if (!Array.isArray(parsed)) {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
const result = [];
|
|
63
|
+
const seen = new Set();
|
|
64
|
+
for (const value of parsed) {
|
|
65
|
+
const normalized = String(value).trim();
|
|
66
|
+
if (!normalized || seen.has(normalized)) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
seen.add(normalized);
|
|
70
|
+
result.push(normalized);
|
|
71
|
+
}
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
import { DinoxError } from '../../utils/errors.js';
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { AbstractPowerSyncDatabase } from '@powersync/common';
|
|
2
|
+
import type { BoxListItem } from './model.js';
|
|
3
|
+
export type BoxTreeNode = BoxListItem & {
|
|
4
|
+
children: BoxTreeNode[];
|
|
5
|
+
};
|
|
6
|
+
export type BoxStatsItem = BoxListItem & {
|
|
7
|
+
directNoteCount: number;
|
|
8
|
+
descendantNoteCount: number;
|
|
9
|
+
latestNoteUpdatedAt: string | null;
|
|
10
|
+
isEmpty: boolean;
|
|
11
|
+
};
|
|
12
|
+
export type BoxStatsResult = {
|
|
13
|
+
totalBoxes: number;
|
|
14
|
+
emptyBoxes: number;
|
|
15
|
+
boxes: BoxStatsItem[];
|
|
16
|
+
};
|
|
17
|
+
export type BoxCleanupIssue = {
|
|
18
|
+
type: 'empty_box' | 'duplicate_path' | 'orphan_parent' | 'deleted_with_active_children';
|
|
19
|
+
id: string;
|
|
20
|
+
path: string;
|
|
21
|
+
name: string;
|
|
22
|
+
parentId: string | null;
|
|
23
|
+
detail: string;
|
|
24
|
+
duplicateIds?: string[];
|
|
25
|
+
childIds?: string[];
|
|
26
|
+
};
|
|
27
|
+
export type BoxCleanupResult = {
|
|
28
|
+
issues: BoxCleanupIssue[];
|
|
29
|
+
emptyBoxes: number;
|
|
30
|
+
duplicatePaths: number;
|
|
31
|
+
orphanParents: number;
|
|
32
|
+
deletedWithActiveChildren: number;
|
|
33
|
+
};
|
|
34
|
+
export type BoxRenameInput = {
|
|
35
|
+
path: string;
|
|
36
|
+
newName: string;
|
|
37
|
+
userId: string;
|
|
38
|
+
};
|
|
39
|
+
export type BoxMoveInput = {
|
|
40
|
+
path: string;
|
|
41
|
+
to: string;
|
|
42
|
+
userId: string;
|
|
43
|
+
};
|
|
44
|
+
export type BoxMergeInput = {
|
|
45
|
+
from: string;
|
|
46
|
+
to: string;
|
|
47
|
+
userId: string;
|
|
48
|
+
};
|
|
49
|
+
export type BoxReadInput = {
|
|
50
|
+
userId?: string;
|
|
51
|
+
};
|
|
52
|
+
export type BoxSubtreeChange = {
|
|
53
|
+
id: string;
|
|
54
|
+
fromPath: string;
|
|
55
|
+
toPath: string;
|
|
56
|
+
};
|
|
57
|
+
export type BoxRenameMoveResult = {
|
|
58
|
+
id: string;
|
|
59
|
+
fromPath: string;
|
|
60
|
+
toPath: string;
|
|
61
|
+
updatedBoxes: BoxSubtreeChange[];
|
|
62
|
+
};
|
|
63
|
+
export type BoxMergeResult = {
|
|
64
|
+
fromId: string;
|
|
65
|
+
toId: string;
|
|
66
|
+
fromPath: string;
|
|
67
|
+
toPath: string;
|
|
68
|
+
softDeletedBoxIds: string[];
|
|
69
|
+
updatedNotes: Array<{
|
|
70
|
+
id: string;
|
|
71
|
+
before: string[];
|
|
72
|
+
after: string[];
|
|
73
|
+
}>;
|
|
74
|
+
};
|
|
75
|
+
export declare function getBoxTree(db: AbstractPowerSyncDatabase, input?: BoxReadInput): Promise<BoxTreeNode[]>;
|
|
76
|
+
export declare function getBoxStats(db: AbstractPowerSyncDatabase, input?: BoxReadInput): Promise<BoxStatsResult>;
|
|
77
|
+
export declare function inspectBoxCleanup(db: AbstractPowerSyncDatabase, input?: BoxReadInput): Promise<BoxCleanupResult>;
|
|
78
|
+
export declare function renameBox(db: AbstractPowerSyncDatabase, input: BoxRenameInput, options?: {
|
|
79
|
+
dryRun?: boolean;
|
|
80
|
+
}): Promise<BoxRenameMoveResult>;
|
|
81
|
+
export declare function moveBox(db: AbstractPowerSyncDatabase, input: BoxMoveInput, options?: {
|
|
82
|
+
dryRun?: boolean;
|
|
83
|
+
}): Promise<BoxRenameMoveResult>;
|
|
84
|
+
export declare function mergeBox(db: AbstractPowerSyncDatabase, input: BoxMergeInput, options?: {
|
|
85
|
+
dryRun?: boolean;
|
|
86
|
+
}): Promise<BoxMergeResult>;
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import { withPowerSyncWriteTransaction } from '../../powersync/runtime.js';
|
|
2
|
+
import { DinoxError } from '../../utils/errors.js';
|
|
3
|
+
import { nowIsoUtc } from '../../utils/time.js';
|
|
4
|
+
import { MAX_BOX_NAME_LENGTH, MAX_BOX_PATH_LENGTH, REQUIRED_NOTE_BOX_COLUMNS, assertMaxLength, canonicalizeBoxPath, getLeafName, normalizeBoxLookupKey, parseStoredBoxIds, trimString, } from './model.js';
|
|
5
|
+
import { activeBoxNodes, activeNodeByPath, assertCanWriteBox, assertRequiredColumns, assertWritableBoxNodes, descendantsOf, ensureNoPathConflict, getTableColumns, isActiveBoxNode, isDescendantBoxPath, loadAllBoxNodes, requireActivePath, toBoxListItem, } from './store.js';
|
|
6
|
+
function buildSubtreeChanges(args) {
|
|
7
|
+
const newRootPath = canonicalizeBoxPath(args.newRootPath);
|
|
8
|
+
if (!newRootPath) {
|
|
9
|
+
throw new DinoxError('Target box path is required');
|
|
10
|
+
}
|
|
11
|
+
assertMaxLength('Box path', newRootPath, MAX_BOX_PATH_LENGTH);
|
|
12
|
+
const rootPrefix = `${args.root.path}/`;
|
|
13
|
+
return descendantsOf(args.nodes, args.root).map((node) => {
|
|
14
|
+
const suffix = node.id === args.root.id ? '' : node.path.slice(rootPrefix.length);
|
|
15
|
+
const toPath = suffix ? `${newRootPath}/${suffix}` : newRootPath;
|
|
16
|
+
return {
|
|
17
|
+
id: node.id,
|
|
18
|
+
fromPath: node.path,
|
|
19
|
+
toPath,
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
function parentPathOf(path) {
|
|
24
|
+
const segments = path.split('/');
|
|
25
|
+
if (segments.length <= 1) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
return segments.slice(0, -1).join('/');
|
|
29
|
+
}
|
|
30
|
+
function parentIdForTargetPath(args) {
|
|
31
|
+
const parentPath = parentPathOf(args.change.toPath);
|
|
32
|
+
if (!parentPath) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const key = normalizeBoxLookupKey(parentPath);
|
|
36
|
+
const changedParent = args.changesByPath.get(key);
|
|
37
|
+
if (changedParent) {
|
|
38
|
+
return changedParent.id;
|
|
39
|
+
}
|
|
40
|
+
return args.activeNodesByPath.get(key)?.id ?? args.fallbackParentId;
|
|
41
|
+
}
|
|
42
|
+
async function applySubtreePathChanges(args) {
|
|
43
|
+
const changesByPath = new Map(args.changes.map((change) => [normalizeBoxLookupKey(change.toPath), change]));
|
|
44
|
+
const activeNodesByPath = activeNodeByPath(args.nodes);
|
|
45
|
+
for (const change of args.changes) {
|
|
46
|
+
const parentId = parentIdForTargetPath({
|
|
47
|
+
change,
|
|
48
|
+
changesByPath,
|
|
49
|
+
activeNodesByPath,
|
|
50
|
+
fallbackParentId: args.newRootParentId,
|
|
51
|
+
});
|
|
52
|
+
await args.db.execute(`
|
|
53
|
+
UPDATE c_zettel_box
|
|
54
|
+
SET name = ?, path = ?, parent_id = ?, updated_at = ?
|
|
55
|
+
WHERE id = ?
|
|
56
|
+
`, [getLeafName(change.toPath), change.toPath, parentId, args.timestamp, change.id]);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function parseRenameInput(input) {
|
|
60
|
+
const path = canonicalizeBoxPath(input.path);
|
|
61
|
+
const newName = canonicalizeBoxPath(input.newName);
|
|
62
|
+
const userId = trimString(input.userId);
|
|
63
|
+
if (!path) {
|
|
64
|
+
throw new DinoxError('Box path is required');
|
|
65
|
+
}
|
|
66
|
+
if (!newName || newName.includes('/')) {
|
|
67
|
+
throw new DinoxError('New box name must be a single path segment');
|
|
68
|
+
}
|
|
69
|
+
assertMaxLength('Box name', newName, MAX_BOX_NAME_LENGTH);
|
|
70
|
+
return { path, newName, userId };
|
|
71
|
+
}
|
|
72
|
+
function parseMoveInput(input) {
|
|
73
|
+
const path = canonicalizeBoxPath(input.path);
|
|
74
|
+
const to = canonicalizeBoxPath(input.to);
|
|
75
|
+
const userId = trimString(input.userId);
|
|
76
|
+
if (!path) {
|
|
77
|
+
throw new DinoxError('Box path is required');
|
|
78
|
+
}
|
|
79
|
+
if (!to) {
|
|
80
|
+
throw new DinoxError('Target parent path is required');
|
|
81
|
+
}
|
|
82
|
+
return { path, to, userId };
|
|
83
|
+
}
|
|
84
|
+
function parseMergeInput(input) {
|
|
85
|
+
const from = canonicalizeBoxPath(input.from);
|
|
86
|
+
const to = canonicalizeBoxPath(input.to);
|
|
87
|
+
const userId = trimString(input.userId);
|
|
88
|
+
if (!from) {
|
|
89
|
+
throw new DinoxError('Source box path is required');
|
|
90
|
+
}
|
|
91
|
+
if (!to) {
|
|
92
|
+
throw new DinoxError('Target box path is required');
|
|
93
|
+
}
|
|
94
|
+
return { from, to, userId };
|
|
95
|
+
}
|
|
96
|
+
export async function getBoxTree(db, input = {}) {
|
|
97
|
+
const nodes = activeBoxNodes(await loadAllBoxNodes(db, { userId: input.userId }));
|
|
98
|
+
const byId = new Map(nodes.map((node) => [node.id, { ...toBoxListItem(node), children: [] }]));
|
|
99
|
+
const roots = [];
|
|
100
|
+
for (const node of nodes) {
|
|
101
|
+
const treeNode = byId.get(node.id);
|
|
102
|
+
const parent = node.parentId ? byId.get(node.parentId) : null;
|
|
103
|
+
if (parent) {
|
|
104
|
+
parent.children.push(treeNode);
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
roots.push(treeNode);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const sortTree = (items) => {
|
|
111
|
+
items.sort((left, right) => left.path.localeCompare(right.path));
|
|
112
|
+
for (const item of items) {
|
|
113
|
+
sortTree(item.children);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
sortTree(roots);
|
|
117
|
+
return roots;
|
|
118
|
+
}
|
|
119
|
+
async function loadNoteBoxRows(db, userId) {
|
|
120
|
+
return db.getAll(`
|
|
121
|
+
SELECT
|
|
122
|
+
TRIM(COALESCE(id, '')) AS id,
|
|
123
|
+
title,
|
|
124
|
+
zettel_boxes,
|
|
125
|
+
is_del,
|
|
126
|
+
TRIM(COALESCE(user_no, '')) AS user_no
|
|
127
|
+
FROM c_note
|
|
128
|
+
WHERE TRIM(COALESCE(id, '')) <> ''
|
|
129
|
+
AND (is_del IS NULL OR is_del = 0)
|
|
130
|
+
AND (TRIM(COALESCE(user_no, '')) = ? OR TRIM(COALESCE(user_no, '')) = '')
|
|
131
|
+
`, [userId]);
|
|
132
|
+
}
|
|
133
|
+
async function loadNoteBoxStats(db, userId) {
|
|
134
|
+
const normalizedUserId = trimString(userId);
|
|
135
|
+
const userFilter = normalizedUserId
|
|
136
|
+
? `AND (TRIM(COALESCE(n.user_no, '')) = ? OR TRIM(COALESCE(n.user_no, '')) = '')`
|
|
137
|
+
: '';
|
|
138
|
+
const rows = await db.getAll(`
|
|
139
|
+
SELECT
|
|
140
|
+
TRIM(CAST(box_item.value AS TEXT)) AS box_id,
|
|
141
|
+
COUNT(DISTINCT n.id) AS note_count,
|
|
142
|
+
MAX(COALESCE(n.updated_at, n.created_at)) AS latest_note_updated_at
|
|
143
|
+
FROM c_note n,
|
|
144
|
+
json_each(
|
|
145
|
+
CASE
|
|
146
|
+
WHEN json_valid(COALESCE(n.zettel_boxes, '')) THEN n.zettel_boxes
|
|
147
|
+
ELSE '[]'
|
|
148
|
+
END
|
|
149
|
+
) box_item
|
|
150
|
+
WHERE (n.is_del IS NULL OR n.is_del = 0)
|
|
151
|
+
${userFilter}
|
|
152
|
+
AND TRIM(CAST(box_item.value AS TEXT)) <> ''
|
|
153
|
+
GROUP BY TRIM(CAST(box_item.value AS TEXT))
|
|
154
|
+
`, normalizedUserId ? [normalizedUserId] : []);
|
|
155
|
+
const stats = new Map();
|
|
156
|
+
for (const row of rows) {
|
|
157
|
+
const id = trimString(row.box_id);
|
|
158
|
+
if (!id) {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
stats.set(id, {
|
|
162
|
+
directNoteCount: Number(row.note_count ?? 0),
|
|
163
|
+
latestNoteUpdatedAt: row.latest_note_updated_at ?? null,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
return stats;
|
|
167
|
+
}
|
|
168
|
+
export async function getBoxStats(db, input = {}) {
|
|
169
|
+
const nodes = activeBoxNodes(await loadAllBoxNodes(db, { userId: input.userId }));
|
|
170
|
+
const statsById = await loadNoteBoxStats(db, input.userId);
|
|
171
|
+
const descendantsById = new Map();
|
|
172
|
+
for (const node of nodes) {
|
|
173
|
+
descendantsById.set(node.id, descendantsOf(nodes, node));
|
|
174
|
+
}
|
|
175
|
+
const boxes = nodes.map((node) => {
|
|
176
|
+
const direct = statsById.get(node.id);
|
|
177
|
+
const descendantNodes = descendantsById.get(node.id) ?? [node];
|
|
178
|
+
let descendantNoteCount = 0;
|
|
179
|
+
let latestNoteUpdatedAt = direct?.latestNoteUpdatedAt ?? null;
|
|
180
|
+
for (const descendant of descendantNodes) {
|
|
181
|
+
const stats = statsById.get(descendant.id);
|
|
182
|
+
descendantNoteCount += stats?.directNoteCount ?? 0;
|
|
183
|
+
if (stats?.latestNoteUpdatedAt && (!latestNoteUpdatedAt || stats.latestNoteUpdatedAt > latestNoteUpdatedAt)) {
|
|
184
|
+
latestNoteUpdatedAt = stats.latestNoteUpdatedAt;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
...toBoxListItem(node),
|
|
189
|
+
directNoteCount: direct?.directNoteCount ?? 0,
|
|
190
|
+
descendantNoteCount,
|
|
191
|
+
latestNoteUpdatedAt,
|
|
192
|
+
isEmpty: descendantNoteCount === 0,
|
|
193
|
+
};
|
|
194
|
+
});
|
|
195
|
+
boxes.sort((left, right) => left.path.localeCompare(right.path));
|
|
196
|
+
return {
|
|
197
|
+
totalBoxes: boxes.length,
|
|
198
|
+
emptyBoxes: boxes.filter((box) => box.isEmpty).length,
|
|
199
|
+
boxes,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
export async function inspectBoxCleanup(db, input = {}) {
|
|
203
|
+
const nodes = await loadAllBoxNodes(db, { userId: input.userId });
|
|
204
|
+
const stats = await getBoxStats(db, input);
|
|
205
|
+
const statsById = new Map(stats.boxes.map((box) => [box.id, box]));
|
|
206
|
+
const active = activeBoxNodes(nodes);
|
|
207
|
+
const activeIds = new Set(active.map((node) => node.id));
|
|
208
|
+
const issues = [];
|
|
209
|
+
for (const node of active) {
|
|
210
|
+
const boxStats = statsById.get(node.id);
|
|
211
|
+
if (boxStats?.isEmpty) {
|
|
212
|
+
issues.push({
|
|
213
|
+
type: 'empty_box',
|
|
214
|
+
id: node.id,
|
|
215
|
+
path: node.path,
|
|
216
|
+
name: node.name,
|
|
217
|
+
parentId: node.parentId,
|
|
218
|
+
detail: 'Box has no notes in itself or descendants.',
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
if (node.parentId && !activeIds.has(node.parentId)) {
|
|
222
|
+
issues.push({
|
|
223
|
+
type: 'orphan_parent',
|
|
224
|
+
id: node.id,
|
|
225
|
+
path: node.path,
|
|
226
|
+
name: node.name,
|
|
227
|
+
parentId: node.parentId,
|
|
228
|
+
detail: `Parent box is missing or deleted: ${node.parentId}`,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const byPath = new Map();
|
|
233
|
+
for (const node of active) {
|
|
234
|
+
const key = normalizeBoxLookupKey(node.path);
|
|
235
|
+
byPath.set(key, [...(byPath.get(key) ?? []), node]);
|
|
236
|
+
}
|
|
237
|
+
for (const duplicates of byPath.values()) {
|
|
238
|
+
if (duplicates.length <= 1) {
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
for (const node of duplicates) {
|
|
242
|
+
issues.push({
|
|
243
|
+
type: 'duplicate_path',
|
|
244
|
+
id: node.id,
|
|
245
|
+
path: node.path,
|
|
246
|
+
name: node.name,
|
|
247
|
+
parentId: node.parentId,
|
|
248
|
+
duplicateIds: duplicates.map((item) => item.id),
|
|
249
|
+
detail: `Active duplicate path appears ${duplicates.length} times.`,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const activeChildrenByDeletedParent = new Map();
|
|
254
|
+
for (const child of active) {
|
|
255
|
+
if (!child.parentId) {
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
const parent = nodes.find((node) => node.id === child.parentId);
|
|
259
|
+
if (parent && !isActiveBoxNode(parent)) {
|
|
260
|
+
activeChildrenByDeletedParent.set(parent.id, [...(activeChildrenByDeletedParent.get(parent.id) ?? []), child]);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
for (const [parentId, children] of activeChildrenByDeletedParent.entries()) {
|
|
264
|
+
const parent = nodes.find((node) => node.id === parentId);
|
|
265
|
+
issues.push({
|
|
266
|
+
type: 'deleted_with_active_children',
|
|
267
|
+
id: parent.id,
|
|
268
|
+
path: parent.path,
|
|
269
|
+
name: parent.name,
|
|
270
|
+
parentId: parent.parentId,
|
|
271
|
+
childIds: children.map((child) => child.id),
|
|
272
|
+
detail: 'Deleted box still has active children.',
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
issues.sort((left, right) => `${left.type}:${left.path}:${left.id}`.localeCompare(`${right.type}:${right.path}:${right.id}`));
|
|
276
|
+
return {
|
|
277
|
+
issues,
|
|
278
|
+
emptyBoxes: issues.filter((issue) => issue.type === 'empty_box').length,
|
|
279
|
+
duplicatePaths: issues.filter((issue) => issue.type === 'duplicate_path').length,
|
|
280
|
+
orphanParents: issues.filter((issue) => issue.type === 'orphan_parent').length,
|
|
281
|
+
deletedWithActiveChildren: issues.filter((issue) => issue.type === 'deleted_with_active_children').length,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
export async function renameBox(db, input, options = {}) {
|
|
285
|
+
const parsed = parseRenameInput(input);
|
|
286
|
+
const columns = await getTableColumns(db, 'c_zettel_box');
|
|
287
|
+
assertCanWriteBox(columns, parsed.userId, 'update');
|
|
288
|
+
const nodes = await loadAllBoxNodes(db, { userId: parsed.userId });
|
|
289
|
+
const root = requireActivePath(nodes, parsed.path, 'Box path');
|
|
290
|
+
const parentPath = root.path.includes('/') ? root.path.split('/').slice(0, -1).join('/') : '';
|
|
291
|
+
const toPath = parentPath ? `${parentPath}/${parsed.newName}` : parsed.newName;
|
|
292
|
+
const changes = buildSubtreeChanges({ nodes, root, newRootPath: toPath });
|
|
293
|
+
ensureNoPathConflict(nodes, changes);
|
|
294
|
+
assertWritableBoxNodes(nodes, new Set(changes.map((change) => change.id)), parsed.userId);
|
|
295
|
+
if (!options.dryRun) {
|
|
296
|
+
await withPowerSyncWriteTransaction(db, async (tx) => {
|
|
297
|
+
await applySubtreePathChanges({
|
|
298
|
+
db: tx,
|
|
299
|
+
nodes,
|
|
300
|
+
changes,
|
|
301
|
+
newRootParentId: root.parentId,
|
|
302
|
+
timestamp: nowIsoUtc(),
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
return {
|
|
307
|
+
id: root.id,
|
|
308
|
+
fromPath: root.path,
|
|
309
|
+
toPath,
|
|
310
|
+
updatedBoxes: changes,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
export async function moveBox(db, input, options = {}) {
|
|
314
|
+
const parsed = parseMoveInput(input);
|
|
315
|
+
const columns = await getTableColumns(db, 'c_zettel_box');
|
|
316
|
+
assertCanWriteBox(columns, parsed.userId, 'update');
|
|
317
|
+
const nodes = await loadAllBoxNodes(db, { userId: parsed.userId });
|
|
318
|
+
const root = requireActivePath(nodes, parsed.path, 'Box path');
|
|
319
|
+
const targetParent = requireActivePath(nodes, parsed.to, 'Target parent path');
|
|
320
|
+
if (root.id === targetParent.id || isDescendantBoxPath(targetParent.path, root.path)) {
|
|
321
|
+
throw new DinoxError('Cannot move a zettel box under itself or one of its descendants');
|
|
322
|
+
}
|
|
323
|
+
const toPath = `${targetParent.path}/${root.name}`;
|
|
324
|
+
const changes = buildSubtreeChanges({ nodes, root, newRootPath: toPath });
|
|
325
|
+
ensureNoPathConflict(nodes, changes);
|
|
326
|
+
assertWritableBoxNodes(nodes, new Set(changes.map((change) => change.id)), parsed.userId);
|
|
327
|
+
if (!options.dryRun) {
|
|
328
|
+
await withPowerSyncWriteTransaction(db, async (tx) => {
|
|
329
|
+
await applySubtreePathChanges({
|
|
330
|
+
db: tx,
|
|
331
|
+
nodes,
|
|
332
|
+
changes,
|
|
333
|
+
newRootParentId: targetParent.id,
|
|
334
|
+
timestamp: nowIsoUtc(),
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
return {
|
|
339
|
+
id: root.id,
|
|
340
|
+
fromPath: root.path,
|
|
341
|
+
toPath,
|
|
342
|
+
updatedBoxes: changes,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
function remapNoteBoxIds(current, sourceIds, targetId) {
|
|
346
|
+
const next = [];
|
|
347
|
+
const seen = new Set();
|
|
348
|
+
for (const id of current) {
|
|
349
|
+
const mapped = sourceIds.has(id) ? targetId : id;
|
|
350
|
+
if (!mapped || seen.has(mapped)) {
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
seen.add(mapped);
|
|
354
|
+
next.push(mapped);
|
|
355
|
+
}
|
|
356
|
+
return next;
|
|
357
|
+
}
|
|
358
|
+
function sameIds(left, right) {
|
|
359
|
+
if (left.length !== right.length) {
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
return left.every((value, index) => value === right[index]);
|
|
363
|
+
}
|
|
364
|
+
export async function mergeBox(db, input, options = {}) {
|
|
365
|
+
const parsed = parseMergeInput(input);
|
|
366
|
+
const boxColumns = await getTableColumns(db, 'c_zettel_box');
|
|
367
|
+
const noteColumns = await getTableColumns(db, 'c_note');
|
|
368
|
+
assertCanWriteBox(boxColumns, parsed.userId, 'update');
|
|
369
|
+
assertRequiredColumns('c_note', noteColumns, REQUIRED_NOTE_BOX_COLUMNS);
|
|
370
|
+
const nodes = await loadAllBoxNodes(db, { userId: parsed.userId });
|
|
371
|
+
const source = requireActivePath(nodes, parsed.from, 'Source box path');
|
|
372
|
+
const target = requireActivePath(nodes, parsed.to, 'Target box path');
|
|
373
|
+
if (source.id === target.id || isDescendantBoxPath(target.path, source.path)) {
|
|
374
|
+
throw new DinoxError('Cannot merge a zettel box into itself or one of its descendants');
|
|
375
|
+
}
|
|
376
|
+
const sourceNodes = descendantsOf(nodes, source);
|
|
377
|
+
const sourceIds = new Set(sourceNodes.map((node) => node.id));
|
|
378
|
+
assertWritableBoxNodes(nodes, new Set([...sourceIds, target.id]), parsed.userId);
|
|
379
|
+
const noteRows = await loadNoteBoxRows(db, parsed.userId);
|
|
380
|
+
const updatedNotes = noteRows
|
|
381
|
+
.map((row) => {
|
|
382
|
+
const before = parseStoredBoxIds(row.zettel_boxes);
|
|
383
|
+
const after = remapNoteBoxIds(before, sourceIds, target.id);
|
|
384
|
+
return { id: trimString(row.id), before, after };
|
|
385
|
+
})
|
|
386
|
+
.filter((row) => row.id && !sameIds(row.before, row.after));
|
|
387
|
+
const softDeletedBoxIds = sourceNodes.map((node) => node.id);
|
|
388
|
+
if (!options.dryRun) {
|
|
389
|
+
await withPowerSyncWriteTransaction(db, async (tx) => {
|
|
390
|
+
const timestamp = nowIsoUtc();
|
|
391
|
+
for (const note of updatedNotes) {
|
|
392
|
+
await tx.execute(`
|
|
393
|
+
UPDATE c_note
|
|
394
|
+
SET zettel_boxes = ?, updated_at = ?, version = COALESCE(version, 0) + 1
|
|
395
|
+
WHERE id = ?
|
|
396
|
+
`, [JSON.stringify(note.after), timestamp, note.id]);
|
|
397
|
+
}
|
|
398
|
+
for (const boxId of softDeletedBoxIds) {
|
|
399
|
+
await tx.execute(`
|
|
400
|
+
UPDATE c_zettel_box
|
|
401
|
+
SET is_del = 1, updated_at = ?
|
|
402
|
+
WHERE id = ?
|
|
403
|
+
`, [timestamp, boxId]);
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
return {
|
|
408
|
+
fromId: source.id,
|
|
409
|
+
toId: target.id,
|
|
410
|
+
fromPath: source.path,
|
|
411
|
+
toPath: target.path,
|
|
412
|
+
softDeletedBoxIds,
|
|
413
|
+
updatedNotes,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
@@ -1,29 +1,36 @@
|
|
|
1
1
|
import type { AbstractPowerSyncDatabase } from '@powersync/common';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
name: string;
|
|
5
|
-
description: string;
|
|
6
|
-
};
|
|
2
|
+
import type { BoxListItem } from './model.js';
|
|
3
|
+
export { canonicalizeBoxPath } from './model.js';
|
|
7
4
|
export type AddBoxInput = {
|
|
8
5
|
name: string;
|
|
9
6
|
userId: string;
|
|
10
7
|
description?: string;
|
|
11
8
|
color?: string;
|
|
12
9
|
};
|
|
10
|
+
export type ListBoxesInput = {
|
|
11
|
+
userId?: string;
|
|
12
|
+
};
|
|
13
13
|
export type AddBoxResult = {
|
|
14
14
|
id: string;
|
|
15
15
|
name: string;
|
|
16
|
+
path: string;
|
|
17
|
+
parentId: string | null;
|
|
16
18
|
description: string;
|
|
17
19
|
color: string;
|
|
18
20
|
restored: boolean;
|
|
21
|
+
createdNodes: number;
|
|
22
|
+
restoredNodes: number;
|
|
19
23
|
};
|
|
20
24
|
export type InspectAddBoxResult = {
|
|
21
25
|
name: string;
|
|
26
|
+
path: string;
|
|
22
27
|
description: string;
|
|
23
28
|
color: string;
|
|
24
29
|
restored: boolean;
|
|
30
|
+
createdPaths: string[];
|
|
31
|
+
restoredPaths: string[];
|
|
25
32
|
existingId?: string;
|
|
26
33
|
};
|
|
27
|
-
export declare function listBoxes(db: AbstractPowerSyncDatabase): Promise<BoxListItem[]>;
|
|
34
|
+
export declare function listBoxes(db: AbstractPowerSyncDatabase, input?: ListBoxesInput): Promise<BoxListItem[]>;
|
|
28
35
|
export declare function addBox(db: AbstractPowerSyncDatabase, input: AddBoxInput): Promise<AddBoxResult>;
|
|
29
36
|
export declare function inspectAddBox(db: AbstractPowerSyncDatabase, input: AddBoxInput): Promise<InspectAddBoxResult>;
|