@dinoxx/dinox-cli 1.0.11 → 1.0.13
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 +132 -4
- package/dist/cli.js +2 -0
- package/dist/commands/notes/index.js +27 -4
- package/dist/commands/notes/repo.d.ts +2 -0
- package/dist/commands/notes/repo.js +55 -9
- package/dist/commands/todo/contentModel.d.ts +30 -0
- package/dist/commands/todo/contentModel.js +325 -0
- package/dist/commands/todo/index.d.ts +2 -0
- package/dist/commands/todo/index.js +349 -0
- package/dist/commands/todo/repo.d.ts +85 -0
- package/dist/commands/todo/repo.js +286 -0
- package/dist/commands/todo/taskModel.d.ts +37 -0
- package/dist/commands/todo/taskModel.js +349 -0
- package/package.json +1 -1
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { AbstractPowerSyncDatabase } from '@powersync/common';
|
|
2
|
+
import { type TaskDateRangeFilter, type TaskStatusFilter, type TaskTimePoint } from './taskModel.js';
|
|
3
|
+
export type SearchTodoOptions = {
|
|
4
|
+
includeDeleted: boolean;
|
|
5
|
+
taskStatus: TaskStatusFilter;
|
|
6
|
+
taskTagFilter: string[];
|
|
7
|
+
taskDateRange?: TaskDateRangeFilter;
|
|
8
|
+
limit: number;
|
|
9
|
+
scanLimit: number;
|
|
10
|
+
};
|
|
11
|
+
export type AppendTodoTasksOptions = {
|
|
12
|
+
noteId?: string;
|
|
13
|
+
taskTexts: string[];
|
|
14
|
+
};
|
|
15
|
+
export type AppendTodoTasksResult = {
|
|
16
|
+
note_id: string;
|
|
17
|
+
title: string;
|
|
18
|
+
appended_count: number;
|
|
19
|
+
source: 'explicit' | 'latest_non_empty_image_detail';
|
|
20
|
+
};
|
|
21
|
+
export type CreateTodoNoteOptions = {
|
|
22
|
+
title?: string;
|
|
23
|
+
taskTexts: string[];
|
|
24
|
+
userId: string;
|
|
25
|
+
};
|
|
26
|
+
export type CreateTodoNoteResult = {
|
|
27
|
+
note_id: string;
|
|
28
|
+
title: string;
|
|
29
|
+
created_count: number;
|
|
30
|
+
};
|
|
31
|
+
export type UpdateTodoTaskStatusOptions = {
|
|
32
|
+
taskId: string;
|
|
33
|
+
checked: boolean;
|
|
34
|
+
};
|
|
35
|
+
export type UpdateTodoTaskStatusResult = {
|
|
36
|
+
note_id: string;
|
|
37
|
+
task_id: string;
|
|
38
|
+
checked: boolean;
|
|
39
|
+
updated: boolean;
|
|
40
|
+
title: string;
|
|
41
|
+
};
|
|
42
|
+
export type SearchedTodo = {
|
|
43
|
+
task_key: string;
|
|
44
|
+
note_id: string;
|
|
45
|
+
note_title: string;
|
|
46
|
+
task_id: string;
|
|
47
|
+
text: string;
|
|
48
|
+
checked: boolean;
|
|
49
|
+
status: 'completed' | 'uncompleted';
|
|
50
|
+
created_at: string | null;
|
|
51
|
+
depth: number;
|
|
52
|
+
has_children: boolean;
|
|
53
|
+
parent_id: string | null;
|
|
54
|
+
tags: string[];
|
|
55
|
+
note_tags: string[];
|
|
56
|
+
start_time?: TaskTimePoint;
|
|
57
|
+
due_time?: TaskTimePoint;
|
|
58
|
+
};
|
|
59
|
+
export type SearchedTodoResponse = {
|
|
60
|
+
meta: {
|
|
61
|
+
query: string;
|
|
62
|
+
returned_count: number;
|
|
63
|
+
total_count: number;
|
|
64
|
+
matched_note_count: number;
|
|
65
|
+
limit: number;
|
|
66
|
+
scan_limit: number;
|
|
67
|
+
truncated: boolean;
|
|
68
|
+
applied_filters: {
|
|
69
|
+
status: TaskStatusFilter;
|
|
70
|
+
tags: string[];
|
|
71
|
+
include_deleted: boolean;
|
|
72
|
+
date_range: {
|
|
73
|
+
from: string | null;
|
|
74
|
+
to: string | null;
|
|
75
|
+
} | null;
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
tasks: SearchedTodo[];
|
|
79
|
+
};
|
|
80
|
+
export type { TaskDateRangeFilter, TaskStatusFilter } from './taskModel.js';
|
|
81
|
+
export { normalizeTaskTexts } from './contentModel.js';
|
|
82
|
+
export declare function searchTodoTasks(db: AbstractPowerSyncDatabase, query: string, options: SearchTodoOptions): Promise<SearchedTodoResponse>;
|
|
83
|
+
export declare function appendTodoTasks(db: AbstractPowerSyncDatabase, options: AppendTodoTasksOptions): Promise<AppendTodoTasksResult>;
|
|
84
|
+
export declare function createTodoNote(db: AbstractPowerSyncDatabase, options: CreateTodoNoteOptions): Promise<CreateTodoNoteResult>;
|
|
85
|
+
export declare function updateTodoTaskStatus(db: AbstractPowerSyncDatabase, options: UpdateTodoTaskStatusOptions): Promise<UpdateTodoTaskStatusResult>;
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { createNote } from '../notes/repo.js';
|
|
2
|
+
import { DinoxError } from '../../utils/errors.js';
|
|
3
|
+
import { generateId } from '../../utils/id.js';
|
|
4
|
+
import { nowIsoUtc } from '../../utils/time.js';
|
|
5
|
+
import { applyTaskCheckedStatus, appendTasksToDoc, buildAppendMarkdown, buildContentTextPreview, buildCreateMarkdown, buildSummaryTitle, createTodoDocFromTasks, deriveImageDetailFromContentJson, normalizeTaskTexts, patchTaskMarkdownByOrder, parseTiptapDocFromDb, } from './contentModel.js';
|
|
6
|
+
import { buildTaskKey, extractFlattenedTasks, filterTasksForTodoList, } from './taskModel.js';
|
|
7
|
+
export { normalizeTaskTexts } from './contentModel.js';
|
|
8
|
+
export async function searchTodoTasks(db, query, options) {
|
|
9
|
+
const noteRows = await loadTodoNoteRows(db, options.includeDeleted, options.scanLimit);
|
|
10
|
+
const allTasks = extractFlattenedTasks(noteRows);
|
|
11
|
+
const filteredTasks = filterTasksForTodoList(allTasks, query, options.taskStatus, options.taskTagFilter, options.taskDateRange);
|
|
12
|
+
const visibleTasks = filteredTasks.slice(0, options.limit);
|
|
13
|
+
const tasks = visibleTasks.map((task) => ({
|
|
14
|
+
task_key: buildTaskKey(task.noteId, task.taskId),
|
|
15
|
+
note_id: task.noteId,
|
|
16
|
+
note_title: task.noteTitle,
|
|
17
|
+
task_id: task.taskId,
|
|
18
|
+
text: task.text,
|
|
19
|
+
checked: task.checked,
|
|
20
|
+
status: task.checked ? 'completed' : 'uncompleted',
|
|
21
|
+
created_at: task.createdAtRaw,
|
|
22
|
+
depth: task.depth,
|
|
23
|
+
has_children: task.hasChildren,
|
|
24
|
+
parent_id: task.parentId,
|
|
25
|
+
tags: task.tags ?? [],
|
|
26
|
+
note_tags: task.noteTags ?? [],
|
|
27
|
+
start_time: task.startTime,
|
|
28
|
+
due_time: task.dueTime,
|
|
29
|
+
}));
|
|
30
|
+
return {
|
|
31
|
+
meta: {
|
|
32
|
+
query: query.trim(),
|
|
33
|
+
returned_count: tasks.length,
|
|
34
|
+
total_count: filteredTasks.length,
|
|
35
|
+
matched_note_count: new Set(tasks.map((task) => task.note_id)).size,
|
|
36
|
+
limit: options.limit,
|
|
37
|
+
scan_limit: options.scanLimit,
|
|
38
|
+
truncated: filteredTasks.length > tasks.length,
|
|
39
|
+
applied_filters: {
|
|
40
|
+
status: options.taskStatus,
|
|
41
|
+
tags: [...options.taskTagFilter],
|
|
42
|
+
include_deleted: options.includeDeleted,
|
|
43
|
+
date_range: serializeTaskDateRange(options.taskDateRange),
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
tasks,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export async function appendTodoTasks(db, options) {
|
|
50
|
+
const taskTexts = normalizeTaskTexts(options.taskTexts);
|
|
51
|
+
const explicitNoteId = typeof options.noteId === 'string' ? options.noteId.trim() : '';
|
|
52
|
+
const source = explicitNoteId ? 'explicit' : 'latest_non_empty_image_detail';
|
|
53
|
+
const target = explicitNoteId
|
|
54
|
+
? await getTodoAppendTargetById(db, explicitNoteId)
|
|
55
|
+
: await getLatestTodoAppendTarget(db);
|
|
56
|
+
if (!target) {
|
|
57
|
+
if (explicitNoteId) {
|
|
58
|
+
throw new DinoxError(`Note not found: ${explicitNoteId}`);
|
|
59
|
+
}
|
|
60
|
+
throw new DinoxError('No eligible note found for append. Try `dino todo create ...` first.');
|
|
61
|
+
}
|
|
62
|
+
if (typeof target.is_del === 'number' && target.is_del !== 0) {
|
|
63
|
+
throw new DinoxError(`Note has been deleted: ${target.id}`);
|
|
64
|
+
}
|
|
65
|
+
if (!target.id?.trim()) {
|
|
66
|
+
throw new DinoxError('Note id is required');
|
|
67
|
+
}
|
|
68
|
+
const currentDoc = parseTiptapDocFromDb(target.content_json);
|
|
69
|
+
const hasMarkdownSnapshot = Boolean(target.content_md?.trim());
|
|
70
|
+
const hasDocContent = Array.isArray(currentDoc.content) && currentDoc.content.length > 0;
|
|
71
|
+
if (!hasMarkdownSnapshot && hasDocContent) {
|
|
72
|
+
throw new DinoxError('Cannot append todo: current note has non-empty content_json but empty content_md. Please repair note markdown before append.');
|
|
73
|
+
}
|
|
74
|
+
const nextDoc = appendTasksToDoc(currentDoc, taskTexts);
|
|
75
|
+
const imageDetail = deriveImageDetailFromContentJson(nextDoc);
|
|
76
|
+
const contentMd = buildAppendMarkdown(target.content_md, taskTexts);
|
|
77
|
+
const contentText = buildContentTextPreview(contentMd);
|
|
78
|
+
const now = nowIsoUtc();
|
|
79
|
+
await db.execute(`
|
|
80
|
+
UPDATE c_note
|
|
81
|
+
SET
|
|
82
|
+
content_json = ?,
|
|
83
|
+
image_detail = ?,
|
|
84
|
+
content_md = ?,
|
|
85
|
+
content_text = ?,
|
|
86
|
+
updated_at = ?,
|
|
87
|
+
version = COALESCE(version, 0) + 1
|
|
88
|
+
WHERE id = ?
|
|
89
|
+
AND (is_del IS NULL OR is_del = 0)
|
|
90
|
+
`, [
|
|
91
|
+
JSON.stringify(nextDoc),
|
|
92
|
+
JSON.stringify(imageDetail),
|
|
93
|
+
contentMd,
|
|
94
|
+
contentText,
|
|
95
|
+
now,
|
|
96
|
+
target.id,
|
|
97
|
+
]);
|
|
98
|
+
return {
|
|
99
|
+
note_id: target.id,
|
|
100
|
+
title: (target.title ?? '').trim(),
|
|
101
|
+
appended_count: taskTexts.length,
|
|
102
|
+
source,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
export async function createTodoNote(db, options) {
|
|
106
|
+
const taskTexts = normalizeTaskTexts(options.taskTexts);
|
|
107
|
+
const title = options.title?.trim() || buildSummaryTitle(taskTexts);
|
|
108
|
+
const contentJson = createTodoDocFromTasks(taskTexts);
|
|
109
|
+
const imageDetail = deriveImageDetailFromContentJson(contentJson);
|
|
110
|
+
const contentMd = buildCreateMarkdown(taskTexts);
|
|
111
|
+
const contentText = buildContentTextPreview(contentMd);
|
|
112
|
+
const created = await createNote(db, {
|
|
113
|
+
id: generateId(),
|
|
114
|
+
title,
|
|
115
|
+
noteType: 'note',
|
|
116
|
+
contentMd,
|
|
117
|
+
contentJson,
|
|
118
|
+
contentText,
|
|
119
|
+
tags: [],
|
|
120
|
+
zettelBoxIds: [],
|
|
121
|
+
userId: options.userId,
|
|
122
|
+
imageDetail,
|
|
123
|
+
});
|
|
124
|
+
return {
|
|
125
|
+
note_id: created.id,
|
|
126
|
+
title,
|
|
127
|
+
created_count: taskTexts.length,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
export async function updateTodoTaskStatus(db, options) {
|
|
131
|
+
const taskId = options.taskId.trim();
|
|
132
|
+
if (!taskId) {
|
|
133
|
+
throw new DinoxError('taskId is required');
|
|
134
|
+
}
|
|
135
|
+
const candidates = await loadTodoTaskCandidatesByTaskId(db, taskId);
|
|
136
|
+
const matches = candidates.flatMap((candidate) => {
|
|
137
|
+
try {
|
|
138
|
+
const doc = parseTiptapDocFromDb(candidate.content_json);
|
|
139
|
+
const applied = applyTaskCheckedStatus(doc, taskId, options.checked);
|
|
140
|
+
if (applied.matchedIndices.length === 0) {
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
return [{ candidate, applied }];
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return [];
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
if (matches.length === 0) {
|
|
150
|
+
throw new DinoxError(`Task not found: ${taskId}`);
|
|
151
|
+
}
|
|
152
|
+
if (matches.length > 1) {
|
|
153
|
+
const noteIds = matches
|
|
154
|
+
.map((entry) => entry.candidate.id?.trim())
|
|
155
|
+
.filter((value) => Boolean(value));
|
|
156
|
+
throw new DinoxError(`Task id is not unique; found in multiple notes: ${noteIds.join(', ')}`);
|
|
157
|
+
}
|
|
158
|
+
const match = matches[0];
|
|
159
|
+
const noteId = (match.candidate.id ?? '').trim();
|
|
160
|
+
if (!noteId) {
|
|
161
|
+
throw new DinoxError('Note id is required');
|
|
162
|
+
}
|
|
163
|
+
const nextContentMd = patchTaskMarkdownByOrder(match.candidate.content_md, match.applied.taskOrder, match.applied.matchedIndices, options.checked);
|
|
164
|
+
const nextContentText = buildContentTextPreview(nextContentMd);
|
|
165
|
+
const nextImageDetail = deriveImageDetailFromContentJson(match.applied.nextDoc);
|
|
166
|
+
const hasMarkdownChanged = nextContentMd !== (match.candidate.content_md ?? '');
|
|
167
|
+
const hasContentJsonChanged = match.applied.changedCount > 0;
|
|
168
|
+
if (!hasContentJsonChanged && !hasMarkdownChanged) {
|
|
169
|
+
return {
|
|
170
|
+
note_id: noteId,
|
|
171
|
+
task_id: taskId,
|
|
172
|
+
checked: options.checked,
|
|
173
|
+
updated: false,
|
|
174
|
+
title: (match.candidate.title ?? '').trim(),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
await db.execute(`
|
|
178
|
+
UPDATE c_note
|
|
179
|
+
SET
|
|
180
|
+
content_json = ?,
|
|
181
|
+
image_detail = ?,
|
|
182
|
+
content_md = ?,
|
|
183
|
+
content_text = ?,
|
|
184
|
+
updated_at = ?,
|
|
185
|
+
version = COALESCE(version, 0) + 1
|
|
186
|
+
WHERE id = ?
|
|
187
|
+
AND (is_del IS NULL OR is_del = 0)
|
|
188
|
+
`, [
|
|
189
|
+
JSON.stringify(match.applied.nextDoc),
|
|
190
|
+
JSON.stringify(nextImageDetail),
|
|
191
|
+
nextContentMd,
|
|
192
|
+
nextContentText,
|
|
193
|
+
nowIsoUtc(),
|
|
194
|
+
noteId,
|
|
195
|
+
]);
|
|
196
|
+
return {
|
|
197
|
+
note_id: noteId,
|
|
198
|
+
task_id: taskId,
|
|
199
|
+
checked: options.checked,
|
|
200
|
+
updated: true,
|
|
201
|
+
title: (match.candidate.title ?? '').trim(),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
async function loadTodoNoteRows(db, includeDeleted, scanLimit) {
|
|
205
|
+
const whereClauses = [
|
|
206
|
+
includeDeleted ? '1 = 1' : '(n.is_del IS NULL OR n.is_del = 0)',
|
|
207
|
+
'n.image_detail IS NOT NULL',
|
|
208
|
+
"LENGTH(TRIM(n.image_detail)) > 0",
|
|
209
|
+
"TRIM(n.image_detail) <> '[]'",
|
|
210
|
+
];
|
|
211
|
+
return db.getAll(`
|
|
212
|
+
SELECT
|
|
213
|
+
n.id,
|
|
214
|
+
n.title,
|
|
215
|
+
n.image_detail,
|
|
216
|
+
n.tags,
|
|
217
|
+
n.created_at,
|
|
218
|
+
n.updated_at
|
|
219
|
+
FROM c_note n
|
|
220
|
+
WHERE ${whereClauses.join('\n AND ')}
|
|
221
|
+
ORDER BY COALESCE(n.created_at, n.updated_at) DESC
|
|
222
|
+
LIMIT ?
|
|
223
|
+
`, [scanLimit]);
|
|
224
|
+
}
|
|
225
|
+
async function getTodoAppendTargetById(db, noteId) {
|
|
226
|
+
return db.getOptional(`
|
|
227
|
+
SELECT
|
|
228
|
+
n.id,
|
|
229
|
+
n.title,
|
|
230
|
+
n.content_json,
|
|
231
|
+
n.content_md,
|
|
232
|
+
n.is_del
|
|
233
|
+
FROM c_note n
|
|
234
|
+
WHERE n.id = ?
|
|
235
|
+
`, [noteId]);
|
|
236
|
+
}
|
|
237
|
+
async function getLatestTodoAppendTarget(db) {
|
|
238
|
+
return db.getOptional(`
|
|
239
|
+
SELECT
|
|
240
|
+
n.id,
|
|
241
|
+
n.title,
|
|
242
|
+
n.content_json,
|
|
243
|
+
n.content_md,
|
|
244
|
+
n.is_del
|
|
245
|
+
FROM c_note n
|
|
246
|
+
WHERE (n.is_del IS NULL OR n.is_del = 0)
|
|
247
|
+
AND n.image_detail IS NOT NULL
|
|
248
|
+
AND LENGTH(TRIM(n.image_detail)) > 0
|
|
249
|
+
AND TRIM(n.image_detail) <> '[]'
|
|
250
|
+
ORDER BY n.created_at DESC
|
|
251
|
+
LIMIT 1
|
|
252
|
+
`, []);
|
|
253
|
+
}
|
|
254
|
+
async function loadTodoTaskCandidatesByTaskId(db, taskId) {
|
|
255
|
+
const escapedTaskId = escapeLikePattern(taskId);
|
|
256
|
+
return db.getAll(`
|
|
257
|
+
SELECT
|
|
258
|
+
n.id,
|
|
259
|
+
n.title,
|
|
260
|
+
n.content_json,
|
|
261
|
+
n.content_md,
|
|
262
|
+
n.is_del,
|
|
263
|
+
n.created_at
|
|
264
|
+
FROM c_note n
|
|
265
|
+
WHERE (n.is_del IS NULL OR n.is_del = 0)
|
|
266
|
+
AND n.content_json IS NOT NULL
|
|
267
|
+
AND LENGTH(TRIM(n.content_json)) > 0
|
|
268
|
+
AND n.content_json LIKE ? ESCAPE '\\'
|
|
269
|
+
ORDER BY n.created_at DESC
|
|
270
|
+
LIMIT 500
|
|
271
|
+
`, [`%${escapedTaskId}%`]);
|
|
272
|
+
}
|
|
273
|
+
function escapeLikePattern(value) {
|
|
274
|
+
return value.replace(/[\\%_]/g, (token) => `\\${token}`);
|
|
275
|
+
}
|
|
276
|
+
function serializeTaskDateRange(value) {
|
|
277
|
+
if (!value) {
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
const from = value.startDate ? new Date(value.startDate).toISOString() : null;
|
|
281
|
+
const to = value.endDate ? new Date(value.endDate).toISOString() : null;
|
|
282
|
+
if (!from && !to) {
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
return { from, to };
|
|
286
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export type TaskStatusFilter = 'all' | 'completed' | 'uncompleted';
|
|
2
|
+
export type TaskTimeDisplayFormat = 'date' | 'datetime' | 'datetime-seconds' | 'time';
|
|
3
|
+
export type TaskTimePoint = {
|
|
4
|
+
iso: string;
|
|
5
|
+
displayFormat: TaskTimeDisplayFormat;
|
|
6
|
+
};
|
|
7
|
+
export type TaskDateRangeFilter = {
|
|
8
|
+
startDate?: Date;
|
|
9
|
+
endDate?: Date;
|
|
10
|
+
};
|
|
11
|
+
export type TodoTaskSourceRow = {
|
|
12
|
+
id: string | null;
|
|
13
|
+
title?: string | null;
|
|
14
|
+
image_detail: unknown;
|
|
15
|
+
tags: string | null;
|
|
16
|
+
created_at: string | null;
|
|
17
|
+
updated_at: string | null;
|
|
18
|
+
};
|
|
19
|
+
export type FlattenedTask = {
|
|
20
|
+
noteId: string;
|
|
21
|
+
noteTitle: string;
|
|
22
|
+
taskId: string;
|
|
23
|
+
text: string;
|
|
24
|
+
checked: boolean;
|
|
25
|
+
createdAt: Date;
|
|
26
|
+
createdAtRaw: string | null;
|
|
27
|
+
startTime?: TaskTimePoint;
|
|
28
|
+
dueTime?: TaskTimePoint;
|
|
29
|
+
tags?: string[];
|
|
30
|
+
noteTags?: string[];
|
|
31
|
+
depth: number;
|
|
32
|
+
hasChildren: boolean;
|
|
33
|
+
parentId: string | null;
|
|
34
|
+
};
|
|
35
|
+
export declare function buildTaskKey(noteId: string, taskId: string): string;
|
|
36
|
+
export declare function extractFlattenedTasks(rows: TodoTaskSourceRow[]): FlattenedTask[];
|
|
37
|
+
export declare function filterTasksForTodoList(allTasks: FlattenedTask[], query: string, taskStatus: TaskStatusFilter, taskTagFilter: string[], taskDateRange?: TaskDateRangeFilter): FlattenedTask[];
|