@dinoxx/dinox-cli 1.0.12 → 1.0.14

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.
@@ -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
+ }
@@ -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>;