@dinoxx/dinox-cli 1.0.20 → 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 +72 -139
- package/dist/auth/userInfo.d.ts +8 -1
- package/dist/auth/userInfo.js +38 -6
- package/dist/cli.js +0 -2
- package/dist/commands/auth/index.js +11 -4
- 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 +43 -15
- 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/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/resolve.js +2 -2
- package/dist/daemon/index.d.ts +2 -0
- package/dist/daemon/index.js +176 -58
- package/dist/powersync/runtime.d.ts +1 -0
- package/dist/powersync/runtime.js +10 -0
- 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,100 @@
|
|
|
1
|
+
import { DinoxError } from '../../utils/errors.js';
|
|
2
|
+
export const MAX_TAG_SEGMENT_LENGTH = 500;
|
|
3
|
+
export const MAX_TAG_PATH_LENGTH = 2000;
|
|
4
|
+
export const MAX_TAG_EMOJI_LENGTH = 32;
|
|
5
|
+
export const REQUIRED_TAG_NODE_COLUMNS = [
|
|
6
|
+
'id',
|
|
7
|
+
'user_id',
|
|
8
|
+
'emoji',
|
|
9
|
+
'full_path',
|
|
10
|
+
'path_key',
|
|
11
|
+
'parent_id',
|
|
12
|
+
'depth',
|
|
13
|
+
'is_actual',
|
|
14
|
+
'is_del',
|
|
15
|
+
'created_at',
|
|
16
|
+
'updated_at',
|
|
17
|
+
'meta',
|
|
18
|
+
];
|
|
19
|
+
export const REQUIRED_NOTE_TAG_COLUMNS = [
|
|
20
|
+
'id',
|
|
21
|
+
'tags',
|
|
22
|
+
'content_json',
|
|
23
|
+
'content_md',
|
|
24
|
+
'content_text',
|
|
25
|
+
'updated_at',
|
|
26
|
+
'version',
|
|
27
|
+
'is_del',
|
|
28
|
+
'user_no',
|
|
29
|
+
];
|
|
30
|
+
export function trimString(value) {
|
|
31
|
+
return value?.trim() ?? '';
|
|
32
|
+
}
|
|
33
|
+
export function canonicalizeTagPath(value) {
|
|
34
|
+
return value
|
|
35
|
+
.split(/[\\/]+/g)
|
|
36
|
+
.map((segment) => segment.trim())
|
|
37
|
+
.filter(Boolean)
|
|
38
|
+
.join('/');
|
|
39
|
+
}
|
|
40
|
+
export function normalizeTagLookupKey(value) {
|
|
41
|
+
return canonicalizeTagPath(value).toLowerCase();
|
|
42
|
+
}
|
|
43
|
+
export function getLeafName(path) {
|
|
44
|
+
return path.split('/').at(-1) ?? path;
|
|
45
|
+
}
|
|
46
|
+
export function parentPathOf(path) {
|
|
47
|
+
const segments = path.split('/');
|
|
48
|
+
if (segments.length <= 1) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
return segments.slice(0, -1).join('/');
|
|
52
|
+
}
|
|
53
|
+
export function isActiveTagNode(node) {
|
|
54
|
+
return node.isDel === null || node.isDel === 0;
|
|
55
|
+
}
|
|
56
|
+
export function isActualTagNode(node) {
|
|
57
|
+
return node.isActual === null || node.isActual !== 0;
|
|
58
|
+
}
|
|
59
|
+
export function isTagPathWithinPrefix(path, prefix) {
|
|
60
|
+
const pathKey = normalizeTagLookupKey(path);
|
|
61
|
+
const prefixKey = normalizeTagLookupKey(prefix);
|
|
62
|
+
return pathKey === prefixKey || pathKey.startsWith(`${prefixKey}/`);
|
|
63
|
+
}
|
|
64
|
+
export function renameTagPathWithPrefix(path, oldPrefix, newPrefix) {
|
|
65
|
+
const oldSegments = canonicalizeTagPath(oldPrefix).split('/');
|
|
66
|
+
const pathSegments = canonicalizeTagPath(path).split('/');
|
|
67
|
+
const suffix = pathSegments.slice(oldSegments.length).join('/');
|
|
68
|
+
return suffix ? `${canonicalizeTagPath(newPrefix)}/${suffix}` : canonicalizeTagPath(newPrefix);
|
|
69
|
+
}
|
|
70
|
+
export function assertMaxLength(label, value, maxLength) {
|
|
71
|
+
if (value.length > maxLength) {
|
|
72
|
+
throw new DinoxError(`${label} must be at most ${maxLength} characters`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export function parseStoredTags(raw) {
|
|
76
|
+
if (!raw) {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
const parsed = JSON.parse(raw);
|
|
81
|
+
if (!Array.isArray(parsed)) {
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
const result = [];
|
|
85
|
+
const seen = new Set();
|
|
86
|
+
for (const value of parsed) {
|
|
87
|
+
const normalized = canonicalizeTagPath(String(value));
|
|
88
|
+
const key = normalizeTagLookupKey(normalized);
|
|
89
|
+
if (!key || seen.has(key)) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
seen.add(key);
|
|
93
|
+
result.push(normalized);
|
|
94
|
+
}
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return [];
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { AbstractPowerSyncDatabase } from '@powersync/common';
|
|
2
|
+
import type { TagListItem } from './model.js';
|
|
3
|
+
export type TagTreeNode = TagListItem & {
|
|
4
|
+
isActual: boolean;
|
|
5
|
+
children: TagTreeNode[];
|
|
6
|
+
};
|
|
7
|
+
export type TagStatsItem = TagListItem & {
|
|
8
|
+
isActual: boolean;
|
|
9
|
+
directNoteCount: number;
|
|
10
|
+
descendantNoteCount: number;
|
|
11
|
+
latestNoteUpdatedAt: string | null;
|
|
12
|
+
isUnused: boolean;
|
|
13
|
+
};
|
|
14
|
+
export type TagStatsResult = {
|
|
15
|
+
totalTags: number;
|
|
16
|
+
actualTags: number;
|
|
17
|
+
unusedTags: number;
|
|
18
|
+
tags: TagStatsItem[];
|
|
19
|
+
};
|
|
20
|
+
export type TagCleanupIssue = {
|
|
21
|
+
type: 'non_actual_node' | 'unused_tag' | 'duplicate_path' | 'orphan_parent' | 'deleted_with_active_children' | 'soft_deleted_actual';
|
|
22
|
+
id: string;
|
|
23
|
+
path: string;
|
|
24
|
+
name: string;
|
|
25
|
+
parentId: string | null;
|
|
26
|
+
detail: string;
|
|
27
|
+
duplicateIds?: string[];
|
|
28
|
+
childIds?: string[];
|
|
29
|
+
};
|
|
30
|
+
export type TagCleanupResult = {
|
|
31
|
+
issues: TagCleanupIssue[];
|
|
32
|
+
nonActualNodes: number;
|
|
33
|
+
unusedTags: number;
|
|
34
|
+
duplicatePaths: number;
|
|
35
|
+
orphanParents: number;
|
|
36
|
+
deletedWithActiveChildren: number;
|
|
37
|
+
softDeletedActual: number;
|
|
38
|
+
};
|
|
39
|
+
export type TagSubtreeChange = {
|
|
40
|
+
id: string;
|
|
41
|
+
fromPath: string;
|
|
42
|
+
toPath: string;
|
|
43
|
+
};
|
|
44
|
+
export type TagRenameMoveResult = {
|
|
45
|
+
id: string;
|
|
46
|
+
fromPath: string;
|
|
47
|
+
toPath: string;
|
|
48
|
+
updatedTags: TagSubtreeChange[];
|
|
49
|
+
updatedNotes: Array<{
|
|
50
|
+
id: string;
|
|
51
|
+
before: string[];
|
|
52
|
+
after: string[];
|
|
53
|
+
}>;
|
|
54
|
+
};
|
|
55
|
+
export type TagMergeResult = {
|
|
56
|
+
fromId: string;
|
|
57
|
+
toId: string;
|
|
58
|
+
fromPath: string;
|
|
59
|
+
toPath: string;
|
|
60
|
+
softDeletedTagIds: string[];
|
|
61
|
+
updatedNotes: Array<{
|
|
62
|
+
id: string;
|
|
63
|
+
before: string[];
|
|
64
|
+
after: string[];
|
|
65
|
+
}>;
|
|
66
|
+
};
|
|
67
|
+
export type TagSuggestResult = {
|
|
68
|
+
suggestions: Array<{
|
|
69
|
+
canonical: string;
|
|
70
|
+
variants: string[];
|
|
71
|
+
noteCount: number;
|
|
72
|
+
reason: string;
|
|
73
|
+
}>;
|
|
74
|
+
};
|
|
75
|
+
export type TagReadInput = {
|
|
76
|
+
userId?: string;
|
|
77
|
+
};
|
|
78
|
+
export type TagRenameInput = {
|
|
79
|
+
path: string;
|
|
80
|
+
newName: string;
|
|
81
|
+
userId: string;
|
|
82
|
+
};
|
|
83
|
+
export type TagMoveInput = {
|
|
84
|
+
path: string;
|
|
85
|
+
to: string;
|
|
86
|
+
userId: string;
|
|
87
|
+
};
|
|
88
|
+
export type TagMergeInput = {
|
|
89
|
+
from: string;
|
|
90
|
+
to: string;
|
|
91
|
+
userId: string;
|
|
92
|
+
};
|
|
93
|
+
export declare function getTagTree(db: AbstractPowerSyncDatabase, input?: TagReadInput): Promise<TagTreeNode[]>;
|
|
94
|
+
export declare function getTagStats(db: AbstractPowerSyncDatabase, input?: TagReadInput): Promise<TagStatsResult>;
|
|
95
|
+
export declare function renameTag(db: AbstractPowerSyncDatabase, input: TagRenameInput, options?: {
|
|
96
|
+
dryRun?: boolean;
|
|
97
|
+
}): Promise<TagRenameMoveResult>;
|
|
98
|
+
export declare function moveTag(db: AbstractPowerSyncDatabase, input: TagMoveInput, options?: {
|
|
99
|
+
dryRun?: boolean;
|
|
100
|
+
}): Promise<TagRenameMoveResult>;
|
|
101
|
+
export declare function mergeTag(db: AbstractPowerSyncDatabase, input: TagMergeInput, options?: {
|
|
102
|
+
dryRun?: boolean;
|
|
103
|
+
}): Promise<TagMergeResult>;
|
|
104
|
+
export declare function inspectTagCleanup(db: AbstractPowerSyncDatabase, input?: TagReadInput): Promise<TagCleanupResult>;
|
|
105
|
+
export declare function suggestTagMerges(db: AbstractPowerSyncDatabase, input?: TagReadInput): Promise<TagSuggestResult>;
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
import { withPowerSyncWriteTransaction } from '../../powersync/runtime.js';
|
|
2
|
+
import { DinoxError } from '../../utils/errors.js';
|
|
3
|
+
import { nowIsoUtc } from '../../utils/time.js';
|
|
4
|
+
import { mergeTagReferencesInNote, renameTagReferencesInNote } from './content.js';
|
|
5
|
+
import { MAX_TAG_PATH_LENGTH, MAX_TAG_SEGMENT_LENGTH, REQUIRED_NOTE_TAG_COLUMNS, REQUIRED_TAG_NODE_COLUMNS, assertMaxLength, canonicalizeTagPath, getLeafName, isActiveTagNode, isActualTagNode, isTagPathWithinPrefix, normalizeTagLookupKey, parseStoredTags, parentPathOf, renameTagPathWithPrefix, trimString, } from './model.js';
|
|
6
|
+
import { activeTagNodes, actualActiveTagNodes, assertCanWriteTag, assertRequiredColumns, assertWritableTagNodes, descendantsOf, ensureNoTagPathConflict, getTableColumns, loadTagNodes, parentIdForPath, requireActiveTagPath, toTagListItem, } from './store.js';
|
|
7
|
+
function parseSingleSegment(value, label) {
|
|
8
|
+
const normalized = canonicalizeTagPath(value);
|
|
9
|
+
if (!normalized || normalized.includes('/')) {
|
|
10
|
+
throw new DinoxError(`${label} must be a single path segment`);
|
|
11
|
+
}
|
|
12
|
+
assertMaxLength(label, normalized, MAX_TAG_SEGMENT_LENGTH);
|
|
13
|
+
return normalized;
|
|
14
|
+
}
|
|
15
|
+
function assertPath(value, label) {
|
|
16
|
+
const normalized = canonicalizeTagPath(value);
|
|
17
|
+
if (!normalized) {
|
|
18
|
+
throw new DinoxError(`${label} is required`);
|
|
19
|
+
}
|
|
20
|
+
assertMaxLength(label, normalized, MAX_TAG_PATH_LENGTH);
|
|
21
|
+
return normalized;
|
|
22
|
+
}
|
|
23
|
+
function buildSubtreeChanges(nodes, root, newRootPath) {
|
|
24
|
+
const targetPath = assertPath(newRootPath, 'Target tag path');
|
|
25
|
+
return descendantsOf(nodes, root).map((node) => ({
|
|
26
|
+
id: node.id,
|
|
27
|
+
fromPath: node.fullPath,
|
|
28
|
+
toPath: renameTagPathWithPrefix(node.fullPath, root.fullPath, targetPath),
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
function toTreeNode(node) {
|
|
32
|
+
return {
|
|
33
|
+
...toTagListItem(node),
|
|
34
|
+
isActual: isActualTagNode(node),
|
|
35
|
+
children: [],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export async function getTagTree(db, input = {}) {
|
|
39
|
+
const columns = await getTableColumns(db, 'c_tag_node');
|
|
40
|
+
assertRequiredColumns('c_tag_node', columns, REQUIRED_TAG_NODE_COLUMNS);
|
|
41
|
+
const nodes = activeTagNodes(await loadTagNodes(db, columns, { userId: input.userId, dedupeByPath: true }));
|
|
42
|
+
const byId = new Map(nodes.map((node) => [node.id, toTreeNode(node)]));
|
|
43
|
+
const roots = [];
|
|
44
|
+
for (const node of nodes) {
|
|
45
|
+
const treeNode = byId.get(node.id);
|
|
46
|
+
const parent = node.parentId ? byId.get(node.parentId) : null;
|
|
47
|
+
if (parent) {
|
|
48
|
+
parent.children.push(treeNode);
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
roots.push(treeNode);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const sortTree = (items) => {
|
|
55
|
+
items.sort((left, right) => left.tag.localeCompare(right.tag));
|
|
56
|
+
for (const item of items) {
|
|
57
|
+
sortTree(item.children);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
sortTree(roots);
|
|
61
|
+
return roots;
|
|
62
|
+
}
|
|
63
|
+
async function loadNoteTagRows(db, userId) {
|
|
64
|
+
return db.getAll(`
|
|
65
|
+
SELECT
|
|
66
|
+
TRIM(COALESCE(id, '')) AS id,
|
|
67
|
+
title,
|
|
68
|
+
tags,
|
|
69
|
+
content_json,
|
|
70
|
+
content_md,
|
|
71
|
+
content_text,
|
|
72
|
+
is_del,
|
|
73
|
+
TRIM(COALESCE(user_no, '')) AS user_no,
|
|
74
|
+
updated_at
|
|
75
|
+
FROM c_note
|
|
76
|
+
WHERE TRIM(COALESCE(id, '')) <> ''
|
|
77
|
+
AND (is_del IS NULL OR is_del = 0)
|
|
78
|
+
AND (TRIM(COALESCE(user_no, '')) = ? OR TRIM(COALESCE(user_no, '')) = '')
|
|
79
|
+
`, [userId]);
|
|
80
|
+
}
|
|
81
|
+
async function loadNoteTagStats(db, userId) {
|
|
82
|
+
const normalizedUserId = trimString(userId);
|
|
83
|
+
const userFilter = normalizedUserId
|
|
84
|
+
? `AND (TRIM(COALESCE(n.user_no, '')) = ? OR TRIM(COALESCE(n.user_no, '')) = '')`
|
|
85
|
+
: '';
|
|
86
|
+
const rows = await db.getAll(`
|
|
87
|
+
SELECT
|
|
88
|
+
TRIM(CAST(tag_item.value AS TEXT)) AS tag,
|
|
89
|
+
COUNT(DISTINCT n.id) AS note_count,
|
|
90
|
+
MAX(COALESCE(n.updated_at, n.created_at)) AS latest_note_updated_at
|
|
91
|
+
FROM c_note n,
|
|
92
|
+
json_each(
|
|
93
|
+
CASE
|
|
94
|
+
WHEN json_valid(COALESCE(n.tags, '')) THEN n.tags
|
|
95
|
+
ELSE '[]'
|
|
96
|
+
END
|
|
97
|
+
) tag_item
|
|
98
|
+
WHERE (n.is_del IS NULL OR n.is_del = 0)
|
|
99
|
+
${userFilter}
|
|
100
|
+
AND TRIM(CAST(tag_item.value AS TEXT)) <> ''
|
|
101
|
+
GROUP BY TRIM(CAST(tag_item.value AS TEXT))
|
|
102
|
+
`, normalizedUserId ? [normalizedUserId] : []);
|
|
103
|
+
const stats = new Map();
|
|
104
|
+
for (const row of rows) {
|
|
105
|
+
const key = normalizeTagLookupKey(row.tag ?? '');
|
|
106
|
+
if (!key) {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
stats.set(key, {
|
|
110
|
+
directNoteCount: Number(row.note_count ?? 0),
|
|
111
|
+
latestNoteUpdatedAt: row.latest_note_updated_at ?? null,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return stats;
|
|
115
|
+
}
|
|
116
|
+
export async function getTagStats(db, input = {}) {
|
|
117
|
+
const columns = await getTableColumns(db, 'c_tag_node');
|
|
118
|
+
assertRequiredColumns('c_tag_node', columns, REQUIRED_TAG_NODE_COLUMNS);
|
|
119
|
+
const nodes = activeTagNodes(await loadTagNodes(db, columns, { userId: input.userId, dedupeByPath: true }));
|
|
120
|
+
const statsByPath = await loadNoteTagStats(db, input.userId);
|
|
121
|
+
const tags = nodes.map((node) => {
|
|
122
|
+
let descendantNoteCount = 0;
|
|
123
|
+
let latestNoteUpdatedAt = null;
|
|
124
|
+
for (const descendant of descendantsOf(nodes, node)) {
|
|
125
|
+
const stats = statsByPath.get(normalizeTagLookupKey(descendant.fullPath));
|
|
126
|
+
descendantNoteCount += stats?.directNoteCount ?? 0;
|
|
127
|
+
if (stats?.latestNoteUpdatedAt && (!latestNoteUpdatedAt || stats.latestNoteUpdatedAt > latestNoteUpdatedAt)) {
|
|
128
|
+
latestNoteUpdatedAt = stats.latestNoteUpdatedAt;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const direct = statsByPath.get(normalizeTagLookupKey(node.fullPath));
|
|
132
|
+
return {
|
|
133
|
+
...toTagListItem(node),
|
|
134
|
+
isActual: isActualTagNode(node),
|
|
135
|
+
directNoteCount: direct?.directNoteCount ?? 0,
|
|
136
|
+
descendantNoteCount,
|
|
137
|
+
latestNoteUpdatedAt,
|
|
138
|
+
isUnused: isActualTagNode(node) && descendantNoteCount === 0,
|
|
139
|
+
};
|
|
140
|
+
});
|
|
141
|
+
tags.sort((left, right) => left.tag.localeCompare(right.tag));
|
|
142
|
+
return {
|
|
143
|
+
totalTags: tags.length,
|
|
144
|
+
actualTags: tags.filter((tag) => tag.isActual).length,
|
|
145
|
+
unusedTags: tags.filter((tag) => tag.isUnused).length,
|
|
146
|
+
tags,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
function buildTagReferenceLookup(nodes, changes = [], aliases = []) {
|
|
150
|
+
const idByPath = new Map();
|
|
151
|
+
const pathById = new Map();
|
|
152
|
+
for (const node of activeTagNodes(nodes)) {
|
|
153
|
+
const path = canonicalizeTagPath(node.fullPath);
|
|
154
|
+
if (!path) {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
idByPath.set(normalizeTagLookupKey(path), node.id);
|
|
158
|
+
pathById.set(node.id, path);
|
|
159
|
+
}
|
|
160
|
+
for (const change of changes) {
|
|
161
|
+
const path = canonicalizeTagPath(change.toPath);
|
|
162
|
+
if (!path) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
idByPath.set(normalizeTagLookupKey(path), change.id);
|
|
166
|
+
}
|
|
167
|
+
for (const alias of aliases) {
|
|
168
|
+
const path = canonicalizeTagPath(alias.path);
|
|
169
|
+
if (!path || !alias.id) {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
idByPath.set(normalizeTagLookupKey(path), alias.id);
|
|
173
|
+
}
|
|
174
|
+
return { idByPath, pathById };
|
|
175
|
+
}
|
|
176
|
+
function updateNoteTagsForPrefix(row, oldPrefix, newPrefix, mode, lookup) {
|
|
177
|
+
const before = parseStoredTags(row.tags);
|
|
178
|
+
const rewrite = mode === 'merge' ? mergeTagReferencesInNote : renameTagReferencesInNote;
|
|
179
|
+
const update = rewrite({
|
|
180
|
+
tags: before,
|
|
181
|
+
contentJson: row.content_json,
|
|
182
|
+
contentMd: row.content_md,
|
|
183
|
+
contentText: row.content_text,
|
|
184
|
+
oldPrefix,
|
|
185
|
+
newPrefix,
|
|
186
|
+
lookup,
|
|
187
|
+
});
|
|
188
|
+
return {
|
|
189
|
+
id: trimString(row.id),
|
|
190
|
+
before,
|
|
191
|
+
after: update.tags,
|
|
192
|
+
contentJson: update.contentJson,
|
|
193
|
+
contentMd: update.contentMd,
|
|
194
|
+
contentText: update.contentText,
|
|
195
|
+
changed: update.tagsChanged || update.contentJsonChanged || update.contentMdChanged,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
async function applyNoteUpdates(tx, updates, timestamp) {
|
|
199
|
+
for (const update of updates.filter((item) => item.id && item.changed)) {
|
|
200
|
+
await tx.execute(`
|
|
201
|
+
UPDATE c_note
|
|
202
|
+
SET tags = ?, content_json = ?, content_md = ?, content_text = ?, updated_at = ?, version = COALESCE(version, 0) + 1
|
|
203
|
+
WHERE id = ?
|
|
204
|
+
`, [
|
|
205
|
+
JSON.stringify(update.after),
|
|
206
|
+
update.contentJson,
|
|
207
|
+
update.contentMd,
|
|
208
|
+
update.contentText,
|
|
209
|
+
timestamp,
|
|
210
|
+
update.id,
|
|
211
|
+
]);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
async function applyTagPathChanges(tx, nodes, changes, timestamp) {
|
|
215
|
+
for (const change of changes) {
|
|
216
|
+
const parentId = parentIdForPath(change.toPath, nodes, changes);
|
|
217
|
+
const depth = change.toPath.split('/').filter(Boolean).length - 1;
|
|
218
|
+
await tx.execute(`
|
|
219
|
+
UPDATE c_tag_node
|
|
220
|
+
SET full_path = ?, path_key = ?, parent_id = ?, depth = ?, updated_at = ?
|
|
221
|
+
WHERE id = ?
|
|
222
|
+
`, [change.toPath, change.toPath, parentId, depth, timestamp, change.id]);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
export async function renameTag(db, input, options = {}) {
|
|
226
|
+
const path = assertPath(input.path, 'Tag path');
|
|
227
|
+
const newName = parseSingleSegment(input.newName, 'New tag name');
|
|
228
|
+
const userId = trimString(input.userId);
|
|
229
|
+
const tagColumns = await getTableColumns(db, 'c_tag_node');
|
|
230
|
+
const noteColumns = await getTableColumns(db, 'c_note');
|
|
231
|
+
assertCanWriteTag(tagColumns, userId, 'update');
|
|
232
|
+
assertRequiredColumns('c_note', noteColumns, REQUIRED_NOTE_TAG_COLUMNS);
|
|
233
|
+
const nodes = await loadTagNodes(db, tagColumns, { userId, dedupeByPath: true });
|
|
234
|
+
const root = requireActiveTagPath(nodes, path, 'Tag path');
|
|
235
|
+
const newRootPath = parentPathOf(root.fullPath) ? `${parentPathOf(root.fullPath)}/${newName}` : newName;
|
|
236
|
+
const changes = buildSubtreeChanges(nodes, root, newRootPath);
|
|
237
|
+
ensureNoTagPathConflict(nodes, changes);
|
|
238
|
+
assertWritableTagNodes(nodes, new Set(changes.map((change) => change.id)), userId);
|
|
239
|
+
const lookup = buildTagReferenceLookup(nodes, changes);
|
|
240
|
+
const noteUpdates = (await loadNoteTagRows(db, userId))
|
|
241
|
+
.map((row) => updateNoteTagsForPrefix(row, root.fullPath, newRootPath, 'rename', lookup))
|
|
242
|
+
.filter((row) => row.id && row.changed);
|
|
243
|
+
if (!options.dryRun) {
|
|
244
|
+
await withPowerSyncWriteTransaction(db, async (tx) => {
|
|
245
|
+
const timestamp = nowIsoUtc();
|
|
246
|
+
await applyTagPathChanges(tx, nodes, changes, timestamp);
|
|
247
|
+
await applyNoteUpdates(tx, noteUpdates, timestamp);
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
return {
|
|
251
|
+
id: root.id,
|
|
252
|
+
fromPath: root.fullPath,
|
|
253
|
+
toPath: newRootPath,
|
|
254
|
+
updatedTags: changes,
|
|
255
|
+
updatedNotes: noteUpdates.map(({ id, before, after }) => ({ id, before, after })),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
export async function moveTag(db, input, options = {}) {
|
|
259
|
+
const path = assertPath(input.path, 'Tag path');
|
|
260
|
+
const to = assertPath(input.to, 'Target parent path');
|
|
261
|
+
const userId = trimString(input.userId);
|
|
262
|
+
const tagColumns = await getTableColumns(db, 'c_tag_node');
|
|
263
|
+
const noteColumns = await getTableColumns(db, 'c_note');
|
|
264
|
+
assertCanWriteTag(tagColumns, userId, 'update');
|
|
265
|
+
assertRequiredColumns('c_note', noteColumns, REQUIRED_NOTE_TAG_COLUMNS);
|
|
266
|
+
const nodes = await loadTagNodes(db, tagColumns, { userId, dedupeByPath: true });
|
|
267
|
+
const root = requireActiveTagPath(nodes, path, 'Tag path');
|
|
268
|
+
const parent = requireActiveTagPath(nodes, to, 'Target parent path');
|
|
269
|
+
if (root.id === parent.id || isTagPathWithinPrefix(parent.fullPath, root.fullPath)) {
|
|
270
|
+
throw new DinoxError('Cannot move a tag under itself or one of its descendants');
|
|
271
|
+
}
|
|
272
|
+
const newRootPath = `${parent.fullPath}/${getLeafName(root.fullPath)}`;
|
|
273
|
+
const changes = buildSubtreeChanges(nodes, root, newRootPath);
|
|
274
|
+
ensureNoTagPathConflict(nodes, changes);
|
|
275
|
+
assertWritableTagNodes(nodes, new Set(changes.map((change) => change.id)), userId);
|
|
276
|
+
const lookup = buildTagReferenceLookup(nodes, changes);
|
|
277
|
+
const noteUpdates = (await loadNoteTagRows(db, userId))
|
|
278
|
+
.map((row) => updateNoteTagsForPrefix(row, root.fullPath, newRootPath, 'rename', lookup))
|
|
279
|
+
.filter((row) => row.id && row.changed);
|
|
280
|
+
if (!options.dryRun) {
|
|
281
|
+
await withPowerSyncWriteTransaction(db, async (tx) => {
|
|
282
|
+
const timestamp = nowIsoUtc();
|
|
283
|
+
await applyTagPathChanges(tx, nodes, changes, timestamp);
|
|
284
|
+
await applyNoteUpdates(tx, noteUpdates, timestamp);
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
return {
|
|
288
|
+
id: root.id,
|
|
289
|
+
fromPath: root.fullPath,
|
|
290
|
+
toPath: newRootPath,
|
|
291
|
+
updatedTags: changes,
|
|
292
|
+
updatedNotes: noteUpdates.map(({ id, before, after }) => ({ id, before, after })),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
export async function mergeTag(db, input, options = {}) {
|
|
296
|
+
const from = assertPath(input.from, 'Source tag path');
|
|
297
|
+
const to = assertPath(input.to, 'Target tag path');
|
|
298
|
+
const userId = trimString(input.userId);
|
|
299
|
+
const tagColumns = await getTableColumns(db, 'c_tag_node');
|
|
300
|
+
const noteColumns = await getTableColumns(db, 'c_note');
|
|
301
|
+
assertCanWriteTag(tagColumns, userId, 'update');
|
|
302
|
+
assertRequiredColumns('c_note', noteColumns, REQUIRED_NOTE_TAG_COLUMNS);
|
|
303
|
+
const nodes = await loadTagNodes(db, tagColumns, { userId, dedupeByPath: true });
|
|
304
|
+
const source = requireActiveTagPath(nodes, from, 'Source tag path');
|
|
305
|
+
const target = requireActiveTagPath(nodes, to, 'Target tag path');
|
|
306
|
+
if (source.id === target.id || isTagPathWithinPrefix(target.fullPath, source.fullPath)) {
|
|
307
|
+
throw new DinoxError('Cannot merge a tag into itself or one of its descendants');
|
|
308
|
+
}
|
|
309
|
+
const sourceNodes = descendantsOf(nodes, source);
|
|
310
|
+
assertWritableTagNodes(nodes, new Set([...sourceNodes.map((node) => node.id), target.id]), userId);
|
|
311
|
+
const lookup = buildTagReferenceLookup(nodes, [], sourceNodes.map((node) => ({ path: node.fullPath, id: target.id })));
|
|
312
|
+
const noteUpdates = (await loadNoteTagRows(db, userId))
|
|
313
|
+
.map((row) => updateNoteTagsForPrefix(row, source.fullPath, target.fullPath, 'merge', lookup))
|
|
314
|
+
.filter((row) => row.id && row.changed);
|
|
315
|
+
const softDeletedTagIds = sourceNodes.map((node) => node.id);
|
|
316
|
+
if (!options.dryRun) {
|
|
317
|
+
await withPowerSyncWriteTransaction(db, async (tx) => {
|
|
318
|
+
const timestamp = nowIsoUtc();
|
|
319
|
+
await applyNoteUpdates(tx, noteUpdates, timestamp);
|
|
320
|
+
for (const tagId of softDeletedTagIds) {
|
|
321
|
+
await tx.execute(`
|
|
322
|
+
UPDATE c_tag_node
|
|
323
|
+
SET is_del = 1, updated_at = ?
|
|
324
|
+
WHERE id = ?
|
|
325
|
+
`, [timestamp, tagId]);
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
return {
|
|
330
|
+
fromId: source.id,
|
|
331
|
+
toId: target.id,
|
|
332
|
+
fromPath: source.fullPath,
|
|
333
|
+
toPath: target.fullPath,
|
|
334
|
+
softDeletedTagIds,
|
|
335
|
+
updatedNotes: noteUpdates.map(({ id, before, after }) => ({ id, before, after })),
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
export async function inspectTagCleanup(db, input = {}) {
|
|
339
|
+
const columns = await getTableColumns(db, 'c_tag_node');
|
|
340
|
+
assertRequiredColumns('c_tag_node', columns, REQUIRED_TAG_NODE_COLUMNS);
|
|
341
|
+
const nodes = await loadTagNodes(db, columns, { userId: input.userId });
|
|
342
|
+
const active = activeTagNodes(nodes);
|
|
343
|
+
const activeIds = new Set(active.map((node) => node.id));
|
|
344
|
+
const stats = await getTagStats(db, input);
|
|
345
|
+
const statsById = new Map(stats.tags.map((tag) => [tag.id, tag]));
|
|
346
|
+
const issues = [];
|
|
347
|
+
for (const node of active) {
|
|
348
|
+
const item = toTagListItem(node);
|
|
349
|
+
if (!isActualTagNode(node)) {
|
|
350
|
+
issues.push({ type: 'non_actual_node', id: node.id, path: node.fullPath, name: item.name, parentId: node.parentId, detail: 'Node is a non-actual hierarchy grouping node.' });
|
|
351
|
+
}
|
|
352
|
+
if (node.parentId && !activeIds.has(node.parentId)) {
|
|
353
|
+
issues.push({ type: 'orphan_parent', id: node.id, path: node.fullPath, name: item.name, parentId: node.parentId, detail: `Parent tag is missing or deleted: ${node.parentId}` });
|
|
354
|
+
}
|
|
355
|
+
if (statsById.get(node.id)?.isUnused) {
|
|
356
|
+
issues.push({ type: 'unused_tag', id: node.id, path: node.fullPath, name: item.name, parentId: node.parentId, detail: 'Actual tag has no notes in itself or descendants.' });
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
const byPath = new Map();
|
|
360
|
+
for (const node of active) {
|
|
361
|
+
const key = normalizeTagLookupKey(node.fullPath);
|
|
362
|
+
byPath.set(key, [...(byPath.get(key) ?? []), node]);
|
|
363
|
+
}
|
|
364
|
+
for (const duplicates of byPath.values()) {
|
|
365
|
+
if (duplicates.length <= 1) {
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
for (const node of duplicates) {
|
|
369
|
+
issues.push({
|
|
370
|
+
type: 'duplicate_path',
|
|
371
|
+
id: node.id,
|
|
372
|
+
path: node.fullPath,
|
|
373
|
+
name: getLeafName(node.fullPath),
|
|
374
|
+
parentId: node.parentId,
|
|
375
|
+
duplicateIds: duplicates.map((item) => item.id),
|
|
376
|
+
detail: `Active duplicate path appears ${duplicates.length} times.`,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
for (const node of nodes.filter((item) => !isActiveTagNode(item) && isActualTagNode(item))) {
|
|
381
|
+
issues.push({ type: 'soft_deleted_actual', id: node.id, path: node.fullPath, name: getLeafName(node.fullPath), parentId: node.parentId, detail: 'Soft-deleted row is still marked as actual.' });
|
|
382
|
+
}
|
|
383
|
+
for (const deletedParent of nodes.filter((node) => !isActiveTagNode(node))) {
|
|
384
|
+
const children = active.filter((child) => child.parentId === deletedParent.id);
|
|
385
|
+
if (children.length > 0) {
|
|
386
|
+
issues.push({
|
|
387
|
+
type: 'deleted_with_active_children',
|
|
388
|
+
id: deletedParent.id,
|
|
389
|
+
path: deletedParent.fullPath,
|
|
390
|
+
name: getLeafName(deletedParent.fullPath),
|
|
391
|
+
parentId: deletedParent.parentId,
|
|
392
|
+
childIds: children.map((child) => child.id),
|
|
393
|
+
detail: 'Deleted tag still has active children.',
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
issues.sort((left, right) => `${left.type}:${left.path}:${left.id}`.localeCompare(`${right.type}:${right.path}:${right.id}`));
|
|
398
|
+
return {
|
|
399
|
+
issues,
|
|
400
|
+
nonActualNodes: issues.filter((issue) => issue.type === 'non_actual_node').length,
|
|
401
|
+
unusedTags: issues.filter((issue) => issue.type === 'unused_tag').length,
|
|
402
|
+
duplicatePaths: issues.filter((issue) => issue.type === 'duplicate_path').length,
|
|
403
|
+
orphanParents: issues.filter((issue) => issue.type === 'orphan_parent').length,
|
|
404
|
+
deletedWithActiveChildren: issues.filter((issue) => issue.type === 'deleted_with_active_children').length,
|
|
405
|
+
softDeletedActual: issues.filter((issue) => issue.type === 'soft_deleted_actual').length,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
function normalizeSuggestionKey(tag) {
|
|
409
|
+
return canonicalizeTagPath(tag)
|
|
410
|
+
.toLowerCase()
|
|
411
|
+
.replace(/[\s_-]+/g, '')
|
|
412
|
+
.replace(/^人工智能$/u, 'ai')
|
|
413
|
+
.replace(/^aigc$/u, 'ai');
|
|
414
|
+
}
|
|
415
|
+
export async function suggestTagMerges(db, input = {}) {
|
|
416
|
+
const columns = await getTableColumns(db, 'c_tag_node');
|
|
417
|
+
assertRequiredColumns('c_tag_node', columns, REQUIRED_TAG_NODE_COLUMNS);
|
|
418
|
+
const nodes = actualActiveTagNodes(await loadTagNodes(db, columns, { userId: input.userId, dedupeByPath: true }));
|
|
419
|
+
const stats = await getTagStats(db, input);
|
|
420
|
+
const counts = new Map(stats.tags.map((tag) => [normalizeTagLookupKey(tag.tag), tag.directNoteCount]));
|
|
421
|
+
const groups = new Map();
|
|
422
|
+
for (const node of nodes) {
|
|
423
|
+
const key = normalizeSuggestionKey(node.fullPath);
|
|
424
|
+
groups.set(key, [...(groups.get(key) ?? []), node.fullPath]);
|
|
425
|
+
}
|
|
426
|
+
const suggestions = Array.from(groups.values())
|
|
427
|
+
.filter((variants) => variants.length > 1)
|
|
428
|
+
.map((variants) => {
|
|
429
|
+
variants.sort((left, right) => (counts.get(normalizeTagLookupKey(right)) ?? 0) - (counts.get(normalizeTagLookupKey(left)) ?? 0));
|
|
430
|
+
return {
|
|
431
|
+
canonical: variants[0],
|
|
432
|
+
variants,
|
|
433
|
+
noteCount: variants.reduce((sum, tag) => sum + (counts.get(normalizeTagLookupKey(tag)) ?? 0), 0),
|
|
434
|
+
reason: 'Tags normalize to the same lowercase/spacing semantic key.',
|
|
435
|
+
};
|
|
436
|
+
});
|
|
437
|
+
return { suggestions };
|
|
438
|
+
}
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import type { AbstractPowerSyncDatabase } from '@powersync/common';
|
|
2
|
+
export { canonicalizeTagPath } from './model.js';
|
|
2
3
|
export type AddTagInput = {
|
|
3
4
|
name: string;
|
|
4
5
|
userId: string;
|
|
5
6
|
emoji?: string;
|
|
6
7
|
};
|
|
8
|
+
export type ListTagsInput = {
|
|
9
|
+
userId?: string;
|
|
10
|
+
};
|
|
7
11
|
export type AddTagResult = {
|
|
8
12
|
id: string;
|
|
9
13
|
tag: string;
|
|
@@ -16,6 +20,6 @@ export type InspectAddTagResult = {
|
|
|
16
20
|
restoredPaths: string[];
|
|
17
21
|
emoji: string;
|
|
18
22
|
};
|
|
19
|
-
export declare function listTags(db: AbstractPowerSyncDatabase): Promise<string[]>;
|
|
23
|
+
export declare function listTags(db: AbstractPowerSyncDatabase, input?: ListTagsInput): Promise<string[]>;
|
|
20
24
|
export declare function addTag(db: AbstractPowerSyncDatabase, input: AddTagInput): Promise<AddTagResult>;
|
|
21
25
|
export declare function inspectAddTag(db: AbstractPowerSyncDatabase, input: AddTagInput): Promise<InspectAddTagResult>;
|