@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,325 @@
|
|
|
1
|
+
import { DinoxError } from '../../utils/errors.js';
|
|
2
|
+
import { generateId } from '../../utils/id.js';
|
|
3
|
+
import { stripMarkdown } from '../../utils/text.js';
|
|
4
|
+
const CONTENT_TEXT_PREVIEW_LIMIT = 300;
|
|
5
|
+
const DEFAULT_TITLE_PREFIX = '待办清单';
|
|
6
|
+
export function parseTiptapDocFromDb(rawContentJson) {
|
|
7
|
+
const raw = (rawContentJson ?? '').trim();
|
|
8
|
+
if (!raw) {
|
|
9
|
+
throw new DinoxError('Invalid note content_json: empty value');
|
|
10
|
+
}
|
|
11
|
+
let parsed;
|
|
12
|
+
try {
|
|
13
|
+
parsed = JSON.parse(raw);
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
throw new DinoxError('Invalid note content_json: malformed JSON', { cause: error });
|
|
17
|
+
}
|
|
18
|
+
return assertTiptapDoc(parsed);
|
|
19
|
+
}
|
|
20
|
+
export function createTodoDocFromTasks(taskTexts) {
|
|
21
|
+
const tasks = buildTaskItemNodes(taskTexts);
|
|
22
|
+
return {
|
|
23
|
+
type: 'doc',
|
|
24
|
+
content: [
|
|
25
|
+
{
|
|
26
|
+
type: 'taskList',
|
|
27
|
+
content: tasks,
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export function appendTasksToDoc(doc, taskTexts) {
|
|
33
|
+
const nextDoc = cloneJson(doc);
|
|
34
|
+
const tasks = buildTaskItemNodes(taskTexts);
|
|
35
|
+
const content = ensureDocContentArray(nextDoc);
|
|
36
|
+
const lastIndex = content.length - 1;
|
|
37
|
+
const lastNode = lastIndex >= 0 ? content[lastIndex] : null;
|
|
38
|
+
if (isTaskListNode(lastNode)) {
|
|
39
|
+
const listContent = ensureNodeContentArray(lastNode);
|
|
40
|
+
listContent.push(...tasks);
|
|
41
|
+
return nextDoc;
|
|
42
|
+
}
|
|
43
|
+
content.push({
|
|
44
|
+
type: 'taskList',
|
|
45
|
+
content: tasks,
|
|
46
|
+
});
|
|
47
|
+
return nextDoc;
|
|
48
|
+
}
|
|
49
|
+
export function deriveImageDetailFromContentJson(doc) {
|
|
50
|
+
const root = assertTiptapDoc(doc);
|
|
51
|
+
const rootContent = ensureNodeContentArray(root);
|
|
52
|
+
const result = [];
|
|
53
|
+
const nodeStack = [];
|
|
54
|
+
const insideTaskStack = [];
|
|
55
|
+
for (let index = rootContent.length - 1; index >= 0; index -= 1) {
|
|
56
|
+
nodeStack.push(rootContent[index]);
|
|
57
|
+
insideTaskStack.push(false);
|
|
58
|
+
}
|
|
59
|
+
while (nodeStack.length > 0) {
|
|
60
|
+
const current = nodeStack.pop();
|
|
61
|
+
const insideTaskItem = insideTaskStack.pop() ?? false;
|
|
62
|
+
if (!current || typeof current !== 'object') {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const node = current;
|
|
66
|
+
const type = typeof node.type === 'string' ? node.type : '';
|
|
67
|
+
const content = Array.isArray(node.content) ? node.content : [];
|
|
68
|
+
if (type === 'taskItem' && !insideTaskItem) {
|
|
69
|
+
result.push(normalizeTaskItemNode(node));
|
|
70
|
+
}
|
|
71
|
+
const nextInsideTaskItem = insideTaskItem || type === 'taskItem';
|
|
72
|
+
for (let index = content.length - 1; index >= 0; index -= 1) {
|
|
73
|
+
nodeStack.push(content[index]);
|
|
74
|
+
insideTaskStack.push(nextInsideTaskItem);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
export function buildAppendMarkdown(existingMarkdown, taskTexts) {
|
|
80
|
+
const trimmed = (existingMarkdown ?? '').trimEnd();
|
|
81
|
+
const taskBlock = buildTaskMarkdown(taskTexts);
|
|
82
|
+
if (!trimmed) {
|
|
83
|
+
return taskBlock;
|
|
84
|
+
}
|
|
85
|
+
return `${trimmed}\n\n${taskBlock}`;
|
|
86
|
+
}
|
|
87
|
+
export function buildCreateMarkdown(taskTexts) {
|
|
88
|
+
return buildTaskMarkdown(taskTexts);
|
|
89
|
+
}
|
|
90
|
+
export function buildContentTextPreview(contentMd) {
|
|
91
|
+
const plain = stripMarkdown(contentMd);
|
|
92
|
+
const chars = Array.from(plain);
|
|
93
|
+
if (chars.length <= CONTENT_TEXT_PREVIEW_LIMIT) {
|
|
94
|
+
return plain;
|
|
95
|
+
}
|
|
96
|
+
return chars.slice(0, CONTENT_TEXT_PREVIEW_LIMIT).join('');
|
|
97
|
+
}
|
|
98
|
+
export function buildSummaryTitle(taskTexts) {
|
|
99
|
+
const count = taskTexts.length;
|
|
100
|
+
const keywords = taskTexts
|
|
101
|
+
.slice(0, 2)
|
|
102
|
+
.map((task) => task.trim())
|
|
103
|
+
.filter(Boolean)
|
|
104
|
+
.map((task) => truncateChars(task, 12))
|
|
105
|
+
.join('、');
|
|
106
|
+
if (!keywords) {
|
|
107
|
+
return `${DEFAULT_TITLE_PREFIX}(${count}项)`;
|
|
108
|
+
}
|
|
109
|
+
const base = `${DEFAULT_TITLE_PREFIX}(${count}项):${keywords}`;
|
|
110
|
+
return truncateChars(base, 60);
|
|
111
|
+
}
|
|
112
|
+
export function normalizeTaskTexts(values) {
|
|
113
|
+
const normalized = values
|
|
114
|
+
.map((value) => value.replace(/\s+/g, ' ').trim())
|
|
115
|
+
.filter((value) => value.length > 0);
|
|
116
|
+
if (normalized.length === 0) {
|
|
117
|
+
throw new DinoxError('At least one non-empty task is required');
|
|
118
|
+
}
|
|
119
|
+
return normalized;
|
|
120
|
+
}
|
|
121
|
+
export function applyTaskCheckedStatus(doc, taskId, checked) {
|
|
122
|
+
const normalizedTaskId = taskId.trim();
|
|
123
|
+
if (!normalizedTaskId) {
|
|
124
|
+
throw new DinoxError('taskId is required');
|
|
125
|
+
}
|
|
126
|
+
const nextDoc = cloneJson(assertTiptapDoc(doc));
|
|
127
|
+
const taskOrder = [];
|
|
128
|
+
const matchedIndices = [];
|
|
129
|
+
let changedCount = 0;
|
|
130
|
+
const visitNode = (value) => {
|
|
131
|
+
if (!value || typeof value !== 'object') {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const node = value;
|
|
135
|
+
const type = typeof node.type === 'string' ? node.type : '';
|
|
136
|
+
if (type === 'taskItem') {
|
|
137
|
+
const currentTaskId = resolveTaskId(node);
|
|
138
|
+
if (currentTaskId) {
|
|
139
|
+
const currentIndex = taskOrder.length;
|
|
140
|
+
taskOrder.push(currentTaskId);
|
|
141
|
+
if (currentTaskId === normalizedTaskId) {
|
|
142
|
+
matchedIndices.push(currentIndex);
|
|
143
|
+
const attrs = ensureNodeAttrs(node);
|
|
144
|
+
const previousChecked = normalizeChecked(node.checked) ?? normalizeChecked(attrs.checked) ?? false;
|
|
145
|
+
if (previousChecked !== checked) {
|
|
146
|
+
changedCount += 1;
|
|
147
|
+
}
|
|
148
|
+
node.id = currentTaskId;
|
|
149
|
+
node.checked = checked;
|
|
150
|
+
attrs.id = currentTaskId;
|
|
151
|
+
attrs.checked = checked;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const content = Array.isArray(node.content) ? node.content : [];
|
|
156
|
+
for (const child of content) {
|
|
157
|
+
visitNode(child);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
visitNode(nextDoc);
|
|
161
|
+
return {
|
|
162
|
+
nextDoc,
|
|
163
|
+
taskOrder,
|
|
164
|
+
matchedIndices,
|
|
165
|
+
changedCount,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
export function patchTaskMarkdownByOrder(contentMd, taskOrder, matchedIndices, checked) {
|
|
169
|
+
if (taskOrder.length === 0 || matchedIndices.length === 0) {
|
|
170
|
+
return contentMd ?? '';
|
|
171
|
+
}
|
|
172
|
+
const markdown = contentMd ?? '';
|
|
173
|
+
if (!markdown.trim()) {
|
|
174
|
+
throw new DinoxError('Cannot update task markdown: note has task entries in content_json but empty content_md.');
|
|
175
|
+
}
|
|
176
|
+
const lines = markdown.split('\n');
|
|
177
|
+
const checklistLineIndexes = [];
|
|
178
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
179
|
+
if (TASK_MARKDOWN_LINE_RE.test(lines[index] ?? '')) {
|
|
180
|
+
checklistLineIndexes.push(index);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (checklistLineIndexes.length !== taskOrder.length) {
|
|
184
|
+
throw new DinoxError(`Cannot update task markdown: checklist line count (${checklistLineIndexes.length}) does not match content_json task count (${taskOrder.length}).`);
|
|
185
|
+
}
|
|
186
|
+
for (const matchedIndex of matchedIndices) {
|
|
187
|
+
const lineIndex = checklistLineIndexes[matchedIndex];
|
|
188
|
+
if (lineIndex === undefined) {
|
|
189
|
+
throw new DinoxError('Cannot update task markdown: task index is out of range.');
|
|
190
|
+
}
|
|
191
|
+
lines[lineIndex] = patchChecklistLine(lines[lineIndex] ?? '', checked);
|
|
192
|
+
}
|
|
193
|
+
return lines.join('\n');
|
|
194
|
+
}
|
|
195
|
+
function buildTaskItemNodes(taskTexts) {
|
|
196
|
+
const normalizedTasks = normalizeTaskTexts(taskTexts);
|
|
197
|
+
return normalizedTasks.map((text) => {
|
|
198
|
+
const taskId = generateId();
|
|
199
|
+
return {
|
|
200
|
+
type: 'taskItem',
|
|
201
|
+
id: taskId,
|
|
202
|
+
checked: false,
|
|
203
|
+
attrs: {
|
|
204
|
+
id: taskId,
|
|
205
|
+
checked: false,
|
|
206
|
+
},
|
|
207
|
+
content: [
|
|
208
|
+
{
|
|
209
|
+
type: 'paragraph',
|
|
210
|
+
content: [
|
|
211
|
+
{
|
|
212
|
+
type: 'text',
|
|
213
|
+
text,
|
|
214
|
+
},
|
|
215
|
+
],
|
|
216
|
+
},
|
|
217
|
+
],
|
|
218
|
+
};
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
function buildTaskMarkdown(taskTexts) {
|
|
222
|
+
const normalizedTasks = normalizeTaskTexts(taskTexts);
|
|
223
|
+
return normalizedTasks.map((task) => `- [ ] ${task}`).join('\n');
|
|
224
|
+
}
|
|
225
|
+
function assertTiptapDoc(value) {
|
|
226
|
+
if (!value || typeof value !== 'object') {
|
|
227
|
+
throw new DinoxError('Invalid note content_json: expected object');
|
|
228
|
+
}
|
|
229
|
+
const doc = value;
|
|
230
|
+
if (doc.type !== 'doc') {
|
|
231
|
+
throw new DinoxError('Invalid note content_json: root.type must be "doc"');
|
|
232
|
+
}
|
|
233
|
+
if (!Array.isArray(doc.content)) {
|
|
234
|
+
throw new DinoxError('Invalid note content_json: root.content must be an array');
|
|
235
|
+
}
|
|
236
|
+
return doc;
|
|
237
|
+
}
|
|
238
|
+
function ensureDocContentArray(doc) {
|
|
239
|
+
const content = doc.content;
|
|
240
|
+
if (Array.isArray(content)) {
|
|
241
|
+
return content;
|
|
242
|
+
}
|
|
243
|
+
doc.content = [];
|
|
244
|
+
return doc.content;
|
|
245
|
+
}
|
|
246
|
+
function ensureNodeContentArray(node) {
|
|
247
|
+
const content = node.content;
|
|
248
|
+
if (Array.isArray(content)) {
|
|
249
|
+
return content;
|
|
250
|
+
}
|
|
251
|
+
node.content = [];
|
|
252
|
+
return node.content;
|
|
253
|
+
}
|
|
254
|
+
function isTaskListNode(node) {
|
|
255
|
+
return Boolean(node && typeof node === 'object' && node.type === 'taskList');
|
|
256
|
+
}
|
|
257
|
+
function ensureNodeAttrs(node) {
|
|
258
|
+
if (!isObject(node.attrs)) {
|
|
259
|
+
node.attrs = {};
|
|
260
|
+
}
|
|
261
|
+
return node.attrs;
|
|
262
|
+
}
|
|
263
|
+
function resolveTaskId(node) {
|
|
264
|
+
const nodeId = normalizeString(node.id);
|
|
265
|
+
if (nodeId) {
|
|
266
|
+
return nodeId;
|
|
267
|
+
}
|
|
268
|
+
const attrs = isObject(node.attrs) ? node.attrs : null;
|
|
269
|
+
const attrsId = normalizeString(attrs?.id);
|
|
270
|
+
return attrsId;
|
|
271
|
+
}
|
|
272
|
+
function normalizeTaskItemNode(node) {
|
|
273
|
+
const attrsSource = isObject(node.attrs) ? node.attrs : {};
|
|
274
|
+
const nodeId = normalizeString(node.id);
|
|
275
|
+
const attrsId = normalizeString(attrsSource.id);
|
|
276
|
+
const taskId = nodeId || attrsId || generateId();
|
|
277
|
+
const checked = normalizeChecked(node.checked) ?? normalizeChecked(attrsSource.checked) ?? false;
|
|
278
|
+
return {
|
|
279
|
+
type: 'taskItem',
|
|
280
|
+
id: taskId,
|
|
281
|
+
checked,
|
|
282
|
+
attrs: {
|
|
283
|
+
...attrsSource,
|
|
284
|
+
id: taskId,
|
|
285
|
+
checked,
|
|
286
|
+
},
|
|
287
|
+
content: Array.isArray(node.content) ? cloneJson(node.content) : [],
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
function normalizeChecked(value) {
|
|
291
|
+
if (value === true)
|
|
292
|
+
return true;
|
|
293
|
+
if (value === false)
|
|
294
|
+
return false;
|
|
295
|
+
if (value === 'true')
|
|
296
|
+
return true;
|
|
297
|
+
if (value === 'false')
|
|
298
|
+
return false;
|
|
299
|
+
return undefined;
|
|
300
|
+
}
|
|
301
|
+
function normalizeString(value) {
|
|
302
|
+
if (typeof value !== 'string') {
|
|
303
|
+
return '';
|
|
304
|
+
}
|
|
305
|
+
return value.trim();
|
|
306
|
+
}
|
|
307
|
+
function truncateChars(value, limit) {
|
|
308
|
+
const chars = Array.from(value);
|
|
309
|
+
if (chars.length <= limit) {
|
|
310
|
+
return value;
|
|
311
|
+
}
|
|
312
|
+
return chars.slice(0, limit).join('');
|
|
313
|
+
}
|
|
314
|
+
function isObject(value) {
|
|
315
|
+
return Boolean(value && typeof value === 'object');
|
|
316
|
+
}
|
|
317
|
+
function cloneJson(value) {
|
|
318
|
+
return JSON.parse(JSON.stringify(value));
|
|
319
|
+
}
|
|
320
|
+
function patchChecklistLine(line, checked) {
|
|
321
|
+
return line.replace(TASK_MARKDOWN_LINE_RE, (_, prefix, suffix) => {
|
|
322
|
+
return `${prefix}${checked ? 'x' : ' '}${suffix}`;
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
const TASK_MARKDOWN_LINE_RE = /^(\s*[-*+]\s+\[)[ xX](\].*)$/;
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { loadConfig } from '../../config/store.js';
|
|
2
|
+
import { resolveConfig } from '../../config/resolve.js';
|
|
3
|
+
import { DinoxError } from '../../utils/errors.js';
|
|
4
|
+
import { readStringOrFile } from '../../utils/argValue.js';
|
|
5
|
+
import { printYaml } from '../../utils/output.js';
|
|
6
|
+
import { connectPowerSync, waitForIdleDataFlow } from '../../powersync/runtime.js';
|
|
7
|
+
import { resolveAuthIdentity } from '../../auth/userInfo.js';
|
|
8
|
+
import { appendTodoTasks, createTodoNote, normalizeTaskTexts, searchTodoTasks, updateTodoTaskStatus, } from './repo.js';
|
|
9
|
+
const DATE_ONLY_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
10
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
11
|
+
const DEFAULT_TASK_LIMIT = 50;
|
|
12
|
+
const DEFAULT_NOTE_SCAN_LIMIT = 200;
|
|
13
|
+
const MAX_TASK_LIMIT = 500;
|
|
14
|
+
const MAX_NOTE_SCAN_LIMIT = 5000;
|
|
15
|
+
function parseSyncTimeoutMs(value) {
|
|
16
|
+
if (typeof value !== 'string') {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
const parsed = Number(value);
|
|
20
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
21
|
+
throw new DinoxError('--sync-timeout must be a positive integer');
|
|
22
|
+
}
|
|
23
|
+
return Math.trunc(parsed);
|
|
24
|
+
}
|
|
25
|
+
function parseTaskStatus(value) {
|
|
26
|
+
const normalized = typeof value === 'string' ? value.trim().toLowerCase() : '';
|
|
27
|
+
if (normalized === 'all' || normalized === 'completed' || normalized === 'uncompleted') {
|
|
28
|
+
return normalized;
|
|
29
|
+
}
|
|
30
|
+
throw new DinoxError('--status must be one of: all, completed, uncompleted');
|
|
31
|
+
}
|
|
32
|
+
function parseTaskCheckedStatus(value) {
|
|
33
|
+
const normalized = typeof value === 'string' ? value.trim().toLowerCase() : '';
|
|
34
|
+
if (normalized === 'completed' || normalized === 'done' || normalized === 'true' || normalized === '1') {
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
if (normalized === 'uncompleted' ||
|
|
38
|
+
normalized === 'todo' ||
|
|
39
|
+
normalized === 'undone' ||
|
|
40
|
+
normalized === 'false' ||
|
|
41
|
+
normalized === '0') {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
throw new DinoxError('--status must be one of: completed, uncompleted, done, undone, true, false, 1, 0');
|
|
45
|
+
}
|
|
46
|
+
function parsePositiveIntegerOption(value, optionName, fallback, max) {
|
|
47
|
+
if (value === undefined || value === null || value === '') {
|
|
48
|
+
return fallback;
|
|
49
|
+
}
|
|
50
|
+
if (typeof value !== 'string') {
|
|
51
|
+
throw new DinoxError(`${optionName} must be a positive integer`);
|
|
52
|
+
}
|
|
53
|
+
const trimmed = value.trim();
|
|
54
|
+
if (!/^\d+$/.test(trimmed)) {
|
|
55
|
+
throw new DinoxError(`${optionName} must be a positive integer`);
|
|
56
|
+
}
|
|
57
|
+
const parsed = Number(trimmed);
|
|
58
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
59
|
+
throw new DinoxError(`${optionName} must be a positive integer`);
|
|
60
|
+
}
|
|
61
|
+
if (parsed > max) {
|
|
62
|
+
throw new DinoxError(`${optionName} cannot be greater than ${max}`);
|
|
63
|
+
}
|
|
64
|
+
return Math.trunc(parsed);
|
|
65
|
+
}
|
|
66
|
+
function parseLocalDateOnly(value) {
|
|
67
|
+
const match = value.match(DATE_ONLY_RE);
|
|
68
|
+
if (!match) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
const year = Number(match[1]);
|
|
72
|
+
const month = Number(match[2]);
|
|
73
|
+
const day = Number(match[3]);
|
|
74
|
+
const parsed = new Date(year, month - 1, day, 0, 0, 0, 0);
|
|
75
|
+
if (parsed.getFullYear() !== year ||
|
|
76
|
+
parsed.getMonth() !== month - 1 ||
|
|
77
|
+
parsed.getDate() !== day) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
return parsed;
|
|
81
|
+
}
|
|
82
|
+
function parseDateInput(value, optionName) {
|
|
83
|
+
if (typeof value !== 'string') {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
const raw = value.trim();
|
|
87
|
+
if (!raw) {
|
|
88
|
+
throw new DinoxError(`${optionName} requires a non-empty date value`);
|
|
89
|
+
}
|
|
90
|
+
const isDateOnly = DATE_ONLY_RE.test(raw);
|
|
91
|
+
const dateOnly = parseLocalDateOnly(raw);
|
|
92
|
+
if (dateOnly) {
|
|
93
|
+
return dateOnly;
|
|
94
|
+
}
|
|
95
|
+
if (isDateOnly) {
|
|
96
|
+
throw new DinoxError(`${optionName} must be a valid date (YYYY-MM-DD or ISO datetime)`);
|
|
97
|
+
}
|
|
98
|
+
const parsed = new Date(raw);
|
|
99
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
100
|
+
throw new DinoxError(`${optionName} must be a valid date (YYYY-MM-DD or ISO datetime)`);
|
|
101
|
+
}
|
|
102
|
+
return parsed;
|
|
103
|
+
}
|
|
104
|
+
function parseDaysInput(value) {
|
|
105
|
+
if (value === undefined || value === null || value === '') {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
if (typeof value !== 'string') {
|
|
109
|
+
throw new DinoxError('--days must be a positive integer');
|
|
110
|
+
}
|
|
111
|
+
const trimmed = value.trim();
|
|
112
|
+
if (!/^\d+$/.test(trimmed)) {
|
|
113
|
+
throw new DinoxError('--days must be a positive integer');
|
|
114
|
+
}
|
|
115
|
+
const parsed = Number(trimmed);
|
|
116
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
117
|
+
throw new DinoxError('--days must be a positive integer');
|
|
118
|
+
}
|
|
119
|
+
return Math.trunc(parsed);
|
|
120
|
+
}
|
|
121
|
+
function parseTaskDateRange(input, now = new Date()) {
|
|
122
|
+
const from = parseDateInput(input.from, '--from');
|
|
123
|
+
const to = parseDateInput(input.to, '--to');
|
|
124
|
+
const days = parseDaysInput(input.days);
|
|
125
|
+
if (days !== undefined && (from || to)) {
|
|
126
|
+
throw new DinoxError('--days cannot be used together with --from/--to');
|
|
127
|
+
}
|
|
128
|
+
if (days !== undefined) {
|
|
129
|
+
const end = new Date(now);
|
|
130
|
+
const start = new Date(now.getTime() - (days - 1) * DAY_MS);
|
|
131
|
+
return {
|
|
132
|
+
startDate: start,
|
|
133
|
+
endDate: end,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
if (!from && !to) {
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
if (from && to && from.getTime() > to.getTime()) {
|
|
140
|
+
throw new DinoxError('--from must be earlier than or equal to --to');
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
startDate: from,
|
|
144
|
+
endDate: to,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
function parseListInput(raw, optionName) {
|
|
148
|
+
const value = raw.trim();
|
|
149
|
+
if (!value) {
|
|
150
|
+
return [];
|
|
151
|
+
}
|
|
152
|
+
if (value.startsWith('[')) {
|
|
153
|
+
try {
|
|
154
|
+
const parsed = JSON.parse(value);
|
|
155
|
+
if (!Array.isArray(parsed)) {
|
|
156
|
+
throw new DinoxError(`${optionName} must be a JSON array`);
|
|
157
|
+
}
|
|
158
|
+
return Array.from(new Set(parsed
|
|
159
|
+
.map((entry) => String(entry).trim())
|
|
160
|
+
.filter((entry) => entry.length > 0)));
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
if (error instanceof DinoxError) {
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
throw new DinoxError(`Invalid list for ${optionName}`, { cause: error });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return Array.from(new Set(value
|
|
170
|
+
.split(/[\n,,]/g)
|
|
171
|
+
.map((entry) => entry.trim())
|
|
172
|
+
.filter((entry) => entry.length > 0)));
|
|
173
|
+
}
|
|
174
|
+
async function parseTagFilterInput(value) {
|
|
175
|
+
const raw = await readStringOrFile(value);
|
|
176
|
+
return parseListInput(raw, '--tags');
|
|
177
|
+
}
|
|
178
|
+
async function parseTaskListInput(value) {
|
|
179
|
+
const raw = await readStringOrFile(value);
|
|
180
|
+
return parseListInput(raw, '--tasks');
|
|
181
|
+
}
|
|
182
|
+
function collectStringOptionValue(value, previous = []) {
|
|
183
|
+
return [...previous, value];
|
|
184
|
+
}
|
|
185
|
+
async function parseTaskInput(args) {
|
|
186
|
+
const merged = [];
|
|
187
|
+
if (typeof args.positionalTask === 'string') {
|
|
188
|
+
merged.push(args.positionalTask);
|
|
189
|
+
}
|
|
190
|
+
if (Array.isArray(args.taskOptionList)) {
|
|
191
|
+
merged.push(...args.taskOptionList);
|
|
192
|
+
}
|
|
193
|
+
if (typeof args.tasksOption === 'string') {
|
|
194
|
+
const parsed = await parseTaskListInput(args.tasksOption);
|
|
195
|
+
merged.push(...parsed);
|
|
196
|
+
}
|
|
197
|
+
return normalizeTaskTexts(merged);
|
|
198
|
+
}
|
|
199
|
+
async function syncBeforeTodoCommand(db, offline, timeoutMs) {
|
|
200
|
+
if (offline) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
await waitForIdleDataFlow(db, Math.min(timeoutMs, 10_000));
|
|
204
|
+
}
|
|
205
|
+
export function registerTodoCommands(program) {
|
|
206
|
+
const todo = program.command('todo').description('Manage todo tasks');
|
|
207
|
+
todo
|
|
208
|
+
.command('search')
|
|
209
|
+
.description('Search tasks from note image_detail')
|
|
210
|
+
.argument('[query]', 'task text query')
|
|
211
|
+
.option('--status <status>', 'task status: all | completed | uncompleted', 'all')
|
|
212
|
+
.option('--tags <string|@file>', 'task tags (JSON array or comma/newline-separated)')
|
|
213
|
+
.option('--from <date>', 'task date range start (YYYY-MM-DD or ISO datetime)')
|
|
214
|
+
.option('--to <date>', 'task date range end (YYYY-MM-DD or ISO datetime)')
|
|
215
|
+
.option('--days <n>', 'recent N days for task date range (cannot be used with --from/--to)')
|
|
216
|
+
.option('--limit <n>', `maximum returned tasks (default: ${DEFAULT_TASK_LIMIT}, max: ${MAX_TASK_LIMIT})`)
|
|
217
|
+
.option('--scan-limit <n>', `maximum note rows scanned from c_note (default: ${DEFAULT_NOTE_SCAN_LIMIT}, max: ${MAX_NOTE_SCAN_LIMIT})`)
|
|
218
|
+
.option('--include-deleted', 'include soft-deleted notes')
|
|
219
|
+
.action(async (query, options, command) => {
|
|
220
|
+
const globals = command.optsWithGlobals?.() ?? {};
|
|
221
|
+
const offline = Boolean(globals.offline);
|
|
222
|
+
const config = resolveConfig(await loadConfig());
|
|
223
|
+
const timeoutMs = parseSyncTimeoutMs(globals.syncTimeout) ?? config.sync.timeoutMs;
|
|
224
|
+
const { db } = await connectPowerSync({ config, offline, timeoutMs });
|
|
225
|
+
try {
|
|
226
|
+
await syncBeforeTodoCommand(db, offline, timeoutMs);
|
|
227
|
+
const keyword = query?.trim() ?? '';
|
|
228
|
+
const taskStatus = parseTaskStatus(options.status);
|
|
229
|
+
const taskTagFilter = typeof options.tags === 'string'
|
|
230
|
+
? await parseTagFilterInput(options.tags)
|
|
231
|
+
: [];
|
|
232
|
+
const taskDateRange = parseTaskDateRange({
|
|
233
|
+
from: options.from,
|
|
234
|
+
to: options.to,
|
|
235
|
+
days: options.days,
|
|
236
|
+
});
|
|
237
|
+
const limit = parsePositiveIntegerOption(options.limit, '--limit', DEFAULT_TASK_LIMIT, MAX_TASK_LIMIT);
|
|
238
|
+
const scanLimit = parsePositiveIntegerOption(options.scanLimit, '--scan-limit', DEFAULT_NOTE_SCAN_LIMIT, MAX_NOTE_SCAN_LIMIT);
|
|
239
|
+
const result = await searchTodoTasks(db, keyword, {
|
|
240
|
+
includeDeleted: Boolean(options.includeDeleted),
|
|
241
|
+
taskStatus,
|
|
242
|
+
taskTagFilter,
|
|
243
|
+
taskDateRange,
|
|
244
|
+
limit,
|
|
245
|
+
scanLimit,
|
|
246
|
+
});
|
|
247
|
+
printYaml(result);
|
|
248
|
+
}
|
|
249
|
+
finally {
|
|
250
|
+
await db.close().catch(() => undefined);
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
todo
|
|
254
|
+
.command('append')
|
|
255
|
+
.description('Append one or more todo items to a note')
|
|
256
|
+
.argument('[task]', 'single task text')
|
|
257
|
+
.option('--task <text>', 'task text (repeatable)', collectStringOptionValue, [])
|
|
258
|
+
.option('--tasks <string|@file>', 'task list (JSON array or comma/newline-separated)')
|
|
259
|
+
.option('--note-id <id>', 'target note id; defaults to latest note with non-empty image_detail')
|
|
260
|
+
.action(async (taskArg, options, command) => {
|
|
261
|
+
const globals = command.optsWithGlobals?.() ?? {};
|
|
262
|
+
const offline = Boolean(globals.offline);
|
|
263
|
+
const config = resolveConfig(await loadConfig());
|
|
264
|
+
const timeoutMs = parseSyncTimeoutMs(globals.syncTimeout) ?? config.sync.timeoutMs;
|
|
265
|
+
const { db } = await connectPowerSync({ config, offline, timeoutMs });
|
|
266
|
+
try {
|
|
267
|
+
await syncBeforeTodoCommand(db, offline, timeoutMs);
|
|
268
|
+
const taskTexts = await parseTaskInput({
|
|
269
|
+
positionalTask: taskArg,
|
|
270
|
+
tasksOption: typeof options.tasks === 'string' ? options.tasks : undefined,
|
|
271
|
+
taskOptionList: Array.isArray(options.task) ? options.task : [],
|
|
272
|
+
});
|
|
273
|
+
const result = await appendTodoTasks(db, {
|
|
274
|
+
noteId: typeof options.noteId === 'string' ? options.noteId : undefined,
|
|
275
|
+
taskTexts,
|
|
276
|
+
});
|
|
277
|
+
printYaml({ ok: true, ...result });
|
|
278
|
+
}
|
|
279
|
+
finally {
|
|
280
|
+
await db.close().catch(() => undefined);
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
todo
|
|
284
|
+
.command('create')
|
|
285
|
+
.description('Create a new note containing one or more todo items')
|
|
286
|
+
.argument('[task]', 'single task text')
|
|
287
|
+
.option('--task <text>', 'task text (repeatable)', collectStringOptionValue, [])
|
|
288
|
+
.option('--tasks <string|@file>', 'task list (JSON array or comma/newline-separated)')
|
|
289
|
+
.option('--title <string>', 'optional note title; defaults to an auto-generated summary')
|
|
290
|
+
.action(async (taskArg, options, command) => {
|
|
291
|
+
const globals = command.optsWithGlobals?.() ?? {};
|
|
292
|
+
const offline = Boolean(globals.offline);
|
|
293
|
+
const rawConfig = await loadConfig();
|
|
294
|
+
const config = resolveConfig(rawConfig);
|
|
295
|
+
const timeoutMs = parseSyncTimeoutMs(globals.syncTimeout) ?? config.sync.timeoutMs;
|
|
296
|
+
const identity = await resolveAuthIdentity(rawConfig, config);
|
|
297
|
+
const { db } = await connectPowerSync({ config, offline, timeoutMs });
|
|
298
|
+
try {
|
|
299
|
+
await syncBeforeTodoCommand(db, offline, timeoutMs);
|
|
300
|
+
const taskTexts = await parseTaskInput({
|
|
301
|
+
positionalTask: taskArg,
|
|
302
|
+
tasksOption: typeof options.tasks === 'string' ? options.tasks : undefined,
|
|
303
|
+
taskOptionList: Array.isArray(options.task) ? options.task : [],
|
|
304
|
+
});
|
|
305
|
+
const result = await createTodoNote(db, {
|
|
306
|
+
taskTexts,
|
|
307
|
+
title: typeof options.title === 'string' ? options.title : undefined,
|
|
308
|
+
userId: identity.userId,
|
|
309
|
+
});
|
|
310
|
+
if (!offline && !config.powersync.uploadBaseUrl) {
|
|
311
|
+
console.log('warning: upload disabled (powersync.uploadBaseUrl is unset); changes are local-only for now');
|
|
312
|
+
}
|
|
313
|
+
if (!offline) {
|
|
314
|
+
await waitForIdleDataFlow(db, Math.min(timeoutMs, 5_000));
|
|
315
|
+
}
|
|
316
|
+
printYaml({ ok: true, ...result });
|
|
317
|
+
}
|
|
318
|
+
finally {
|
|
319
|
+
await db.close().catch(() => undefined);
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
todo
|
|
323
|
+
.command('update')
|
|
324
|
+
.description('Update checked status for a todo task by taskId')
|
|
325
|
+
.argument('<taskId>', 'task id')
|
|
326
|
+
.requiredOption('--status <status>', 'target status: completed|uncompleted|done|undone|true|false|1|0')
|
|
327
|
+
.action(async (taskId, options, command) => {
|
|
328
|
+
const globals = command.optsWithGlobals?.() ?? {};
|
|
329
|
+
const offline = Boolean(globals.offline);
|
|
330
|
+
const config = resolveConfig(await loadConfig());
|
|
331
|
+
const timeoutMs = parseSyncTimeoutMs(globals.syncTimeout) ?? config.sync.timeoutMs;
|
|
332
|
+
const { db } = await connectPowerSync({ config, offline, timeoutMs });
|
|
333
|
+
try {
|
|
334
|
+
await syncBeforeTodoCommand(db, offline, timeoutMs);
|
|
335
|
+
const checked = parseTaskCheckedStatus(options.status);
|
|
336
|
+
const result = await updateTodoTaskStatus(db, {
|
|
337
|
+
taskId,
|
|
338
|
+
checked,
|
|
339
|
+
});
|
|
340
|
+
printYaml({ ok: true, ...result });
|
|
341
|
+
}
|
|
342
|
+
finally {
|
|
343
|
+
await db.close().catch(() => undefined);
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
todo.action((_options, command) => {
|
|
347
|
+
command.outputHelp();
|
|
348
|
+
});
|
|
349
|
+
}
|