@dinoxx/dinox-cli 1.0.16 → 1.0.18

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,45 @@
1
+ import { loadConfig } from '../../config/store.js';
2
+ import { resolveConfig } from '../../config/resolve.js';
3
+ import { printYaml } from '../../utils/output.js';
4
+ import { parseSyncTimeoutMs } from '../../utils/syncTimeout.js';
5
+ import { connectPowerSync, waitForIdleDataFlow } from '../../powersync/runtime.js';
6
+ import { getMetaSchema, getMetaStats } from './repo.js';
7
+ async function syncBeforeMetaCommand(db, offline, timeoutMs) {
8
+ if (offline) {
9
+ return;
10
+ }
11
+ await waitForIdleDataFlow(db, Math.min(timeoutMs, 10_000));
12
+ }
13
+ export function registerMetaCommands(program) {
14
+ const meta = program.command('meta').description('Query Dinox metadata');
15
+ meta
16
+ .command('stats')
17
+ .description('Get metadata stats (notes/tags/boxes)')
18
+ .action(async (_options, command) => {
19
+ const globals = command.optsWithGlobals?.() ?? {};
20
+ const offline = Boolean(globals.offline);
21
+ const config = resolveConfig(await loadConfig());
22
+ const timeoutMs = parseSyncTimeoutMs(globals.syncTimeout) ?? config.sync.timeoutMs;
23
+ const { db, stale } = await connectPowerSync({ config, offline, timeoutMs });
24
+ try {
25
+ await syncBeforeMetaCommand(db, offline, timeoutMs);
26
+ const stats = await getMetaStats(db);
27
+ printYaml({
28
+ stale: offline ? true : stale,
29
+ stats,
30
+ });
31
+ }
32
+ finally {
33
+ await db.close().catch(() => undefined);
34
+ }
35
+ });
36
+ meta
37
+ .command('schema')
38
+ .description('Get structured Dinox data schema for AI integrations')
39
+ .action(() => {
40
+ printYaml(getMetaSchema());
41
+ });
42
+ meta.action((_options, command) => {
43
+ command.outputHelp();
44
+ });
45
+ }
@@ -0,0 +1,27 @@
1
+ import type { AbstractPowerSyncDatabase } from '@powersync/common';
2
+ export type MetaStats = {
3
+ notes_count: number;
4
+ tags_count: number;
5
+ boxes_count: number;
6
+ latest_note_updated_at: string | null;
7
+ };
8
+ export type MetaSchema = {
9
+ version: number;
10
+ entities: {
11
+ note_search_result: {
12
+ fields: string[];
13
+ meta_fields: string[];
14
+ };
15
+ note_context: {
16
+ fields: string[];
17
+ };
18
+ graph_stats: {
19
+ fields: string[];
20
+ };
21
+ meta_stats: {
22
+ fields: string[];
23
+ };
24
+ };
25
+ };
26
+ export declare function getMetaStats(db: AbstractPowerSyncDatabase): Promise<MetaStats>;
27
+ export declare function getMetaSchema(): MetaSchema;
@@ -0,0 +1,71 @@
1
+ export async function getMetaStats(db) {
2
+ const [notesRow, tagsRow, boxesRow, latestNoteRow] = await Promise.all([
3
+ db.getOptional(`
4
+ SELECT COUNT(1) AS count
5
+ FROM c_note n
6
+ WHERE n.is_del IS NULL OR n.is_del = 0
7
+ `, []),
8
+ db.getOptional(`
9
+ SELECT COUNT(1) AS count
10
+ FROM c_tag_node t
11
+ WHERE t.is_del IS NULL OR t.is_del = 0
12
+ `, []),
13
+ db.getOptional(`
14
+ SELECT COUNT(1) AS count
15
+ FROM c_zettel_box b
16
+ WHERE b.is_del IS NULL OR b.is_del = 0
17
+ `, []),
18
+ db.getOptional(`
19
+ SELECT MAX(COALESCE(n.updated_at, n.created_at)) AS latest
20
+ FROM c_note n
21
+ WHERE n.is_del IS NULL OR n.is_del = 0
22
+ `, []),
23
+ ]);
24
+ return {
25
+ notes_count: Number(notesRow?.count ?? 0),
26
+ tags_count: Number(tagsRow?.count ?? 0),
27
+ boxes_count: Number(boxesRow?.count ?? 0),
28
+ latest_note_updated_at: latestNoteRow?.latest ?? null,
29
+ };
30
+ }
31
+ export function getMetaSchema() {
32
+ return {
33
+ version: 1,
34
+ entities: {
35
+ note_search_result: {
36
+ fields: ['id', 'title', 'summary', 'tags', 'created_at', 'zettel_boxes'],
37
+ meta_fields: [
38
+ 'query',
39
+ 'returned_count',
40
+ 'total_count',
41
+ 'stale',
42
+ 'pagination.limit',
43
+ 'pagination.offset',
44
+ 'pagination.has_more',
45
+ 'fields',
46
+ 'applied_filters',
47
+ ],
48
+ },
49
+ note_context: {
50
+ fields: [
51
+ 'id',
52
+ 'title',
53
+ 'summary',
54
+ 'tags',
55
+ 'created_at',
56
+ 'updated_at',
57
+ 'is_del',
58
+ 'zettel_boxes',
59
+ 'links.backlinks',
60
+ 'links.outlinks',
61
+ ],
62
+ },
63
+ graph_stats: {
64
+ fields: ['notes_count', 'links_count', 'connected_notes_count', 'isolated_notes_count'],
65
+ },
66
+ meta_stats: {
67
+ fields: ['notes_count', 'tags_count', 'boxes_count', 'latest_note_updated_at'],
68
+ },
69
+ },
70
+ };
71
+ }
@@ -1,11 +1,17 @@
1
1
  import { Command } from 'commander';
2
- import { type SearchedNote } from './repo.js';
2
+ import { type NoteSearchField, type NoteSearchResultItem } from './searchOutput.js';
3
3
  type NoteSearchResponse = {
4
4
  meta: {
5
5
  query: string;
6
6
  returned_count: number;
7
7
  total_count: number;
8
8
  stale: boolean;
9
+ pagination: {
10
+ limit: number | null;
11
+ offset: number;
12
+ has_more: boolean;
13
+ };
14
+ fields: NoteSearchField[] | null;
9
15
  applied_filters: {
10
16
  include_deleted: boolean;
11
17
  tags_expression: string | null;
@@ -20,13 +26,17 @@ type NoteSearchResponse = {
20
26
  sql_expression: string | null;
21
27
  };
22
28
  };
23
- notes: SearchedNote[];
29
+ notes: NoteSearchResultItem[];
24
30
  };
25
31
  type BuildNoteSearchResponseInput = {
26
32
  query: string;
27
- notes: SearchedNote[];
33
+ notes: NoteSearchResultItem[];
34
+ totalCount: number;
28
35
  stale: boolean;
29
36
  includeDeleted: boolean;
37
+ limit?: number;
38
+ offset?: number;
39
+ fields?: NoteSearchField[];
30
40
  tagsExpression?: string;
31
41
  createdAtFrom?: string;
32
42
  createdAtTo?: string;
@@ -9,8 +9,11 @@ import { parseSyncTimeoutMs } from '../../utils/syncTimeout.js';
9
9
  import { resolveAuthIdentity } from '../../auth/userInfo.js';
10
10
  import { connectPowerSync, waitForIdleDataFlow } from '../../powersync/runtime.js';
11
11
  import { syncNoteTokenIndex } from '../../powersync/tokenIndex.js';
12
- import { createNote, getNote, getNoteDetails, assertNotesUpdatable, resolveZettelBoxIdsForCreate, resolveZettelBoxIdsForSearch, resolveZettelBoxIdsForUpdate, searchNotes, softDeleteNote, updateNote, validateTagsForCreate, validateTagsForUpdate, } from './repo.js';
12
+ import { createNote, getNote, getNoteContext, getNoteDetails, getNotePreview, assertNotesUpdatable, resolveZettelBoxIdsForCreate, resolveZettelBoxIdsForSearch, resolveZettelBoxIdsForUpdate, searchNotes, softDeleteNote, updateNote, validateTagsForCreate, validateTagsForUpdate, } from './repo.js';
13
+ import { parseNoteSearchFields, projectNoteSearchFields, } from './searchOutput.js';
13
14
  import { parseCreatedAtRange } from './searchTime.js';
15
+ const DEFAULT_NOTE_SEARCH_LIMIT = 50;
16
+ const MAX_NOTE_SEARCH_LIMIT = 500;
14
17
  function parseNoteType(value) {
15
18
  const normalized = typeof value === 'string' ? value.trim().toLowerCase() : '';
16
19
  if (normalized === 'note' || normalized === 'crawl') {
@@ -18,6 +21,63 @@ function parseNoteType(value) {
18
21
  }
19
22
  throw new DinoxError('--type must be one of: note, crawl');
20
23
  }
24
+ function parseSearchLimit(value) {
25
+ if (value === undefined || value === null || value === '') {
26
+ return DEFAULT_NOTE_SEARCH_LIMIT;
27
+ }
28
+ if (typeof value !== 'string') {
29
+ throw new DinoxError('--limit must be a positive integer');
30
+ }
31
+ const trimmed = value.trim();
32
+ if (!/^\d+$/.test(trimmed)) {
33
+ throw new DinoxError('--limit must be a positive integer');
34
+ }
35
+ const parsed = Number(trimmed);
36
+ if (!Number.isFinite(parsed) || parsed <= 0) {
37
+ throw new DinoxError('--limit must be a positive integer');
38
+ }
39
+ if (parsed > MAX_NOTE_SEARCH_LIMIT) {
40
+ throw new DinoxError(`--limit cannot be greater than ${MAX_NOTE_SEARCH_LIMIT}`);
41
+ }
42
+ return Math.trunc(parsed);
43
+ }
44
+ function parseSearchOffset(value) {
45
+ if (value === undefined || value === null || value === '') {
46
+ return 0;
47
+ }
48
+ if (typeof value !== 'string') {
49
+ throw new DinoxError('--offset must be a non-negative integer');
50
+ }
51
+ const trimmed = value.trim();
52
+ if (!/^\d+$/.test(trimmed)) {
53
+ throw new DinoxError('--offset must be a non-negative integer');
54
+ }
55
+ const parsed = Number(trimmed);
56
+ if (!Number.isFinite(parsed) || parsed < 0) {
57
+ throw new DinoxError('--offset must be a non-negative integer');
58
+ }
59
+ return Math.trunc(parsed);
60
+ }
61
+ function parsePreviewLineCount(value) {
62
+ if (value === undefined || value === null || value === '') {
63
+ return 10;
64
+ }
65
+ if (typeof value !== 'string') {
66
+ throw new DinoxError('--lines must be a positive integer');
67
+ }
68
+ const trimmed = value.trim();
69
+ if (!/^\d+$/.test(trimmed)) {
70
+ throw new DinoxError('--lines must be a positive integer');
71
+ }
72
+ const parsed = Number(trimmed);
73
+ if (!Number.isFinite(parsed) || parsed <= 0) {
74
+ throw new DinoxError('--lines must be a positive integer');
75
+ }
76
+ if (parsed > 500) {
77
+ throw new DinoxError('--lines cannot be greater than 500');
78
+ }
79
+ return Math.trunc(parsed);
80
+ }
21
81
  async function parseStringInput(value) {
22
82
  return readStringOrFile(value);
23
83
  }
@@ -115,12 +175,23 @@ export function buildNoteSearchResponse(input) {
115
175
  const tagsExpression = input.tagsExpression?.trim() ?? '';
116
176
  const sqlExpression = input.sqlExpression?.trim() ?? '';
117
177
  const hasCreatedAtRange = Boolean(input.createdAtFrom || input.createdAtTo);
178
+ const offset = input.offset ?? 0;
179
+ const limit = input.limit ?? null;
180
+ const returnedCount = input.notes.length;
181
+ const totalCount = input.totalCount;
182
+ const hasMore = offset + returnedCount < totalCount;
118
183
  return {
119
184
  meta: {
120
185
  query,
121
- returned_count: input.notes.length,
122
- total_count: input.notes.length,
186
+ returned_count: returnedCount,
187
+ total_count: totalCount,
123
188
  stale: input.stale,
189
+ pagination: {
190
+ limit,
191
+ offset,
192
+ has_more: hasMore,
193
+ },
194
+ fields: input.fields && input.fields.length > 0 ? [...input.fields] : null,
124
195
  applied_filters: {
125
196
  include_deleted: input.includeDeleted,
126
197
  tags_expression: tagsExpression || null,
@@ -152,6 +223,9 @@ export function registerNoteCommands(program) {
152
223
  .option('--days <n>', 'recent N days by created_at (cannot be used with --from/--to)')
153
224
  .option('--box <string|@file>', 'zettel box names (JSON array/comma list), or [] for empty boxes')
154
225
  .option('--sql <expr>', 'SQL-like expression over id/content_md/summary/tags/zettel_boxes/created_at/type')
226
+ .option('--limit <n>', `maximum returned notes (default: ${DEFAULT_NOTE_SEARCH_LIMIT}, max: ${MAX_NOTE_SEARCH_LIMIT})`)
227
+ .option('--offset <n>', 'result offset for pagination (default: 0)')
228
+ .option('--fields <list>', 'comma-separated fields: id,title,summary,tags,created_at,zettel_boxes')
155
229
  .option('--include-deleted', 'include soft-deleted notes')
156
230
  .action(async (query, options, command) => {
157
231
  const globals = command.optsWithGlobals?.() ?? {};
@@ -174,6 +248,9 @@ export function registerNoteCommands(program) {
174
248
  : { values: [], isExplicitEmptyArray: false };
175
249
  const zettelBoxIds = await resolveZettelBoxIdsForSearch(db, parsedBoxInput.values);
176
250
  const sqlExpression = typeof options.sql === 'string' ? options.sql.trim() : '';
251
+ const limit = parseSearchLimit(options.limit);
252
+ const offset = parseSearchOffset(options.offset);
253
+ const fields = parseNoteSearchFields(options.fields, '--fields');
177
254
  const rows = await searchNotes(db, keyword, {
178
255
  includeDeleted: Boolean(options.includeDeleted),
179
256
  tagsExpression: tagsExpression || undefined,
@@ -183,11 +260,18 @@ export function registerNoteCommands(program) {
183
260
  matchEmptyZettelBoxes: parsedBoxInput.isExplicitEmptyArray,
184
261
  sqlExpression: sqlExpression || undefined,
185
262
  });
263
+ const totalCount = rows.length;
264
+ const pagedRows = rows.slice(offset, offset + limit);
265
+ const projectedRows = projectNoteSearchFields(pagedRows, fields);
186
266
  const response = buildNoteSearchResponse({
187
267
  query: keyword,
188
- notes: rows,
268
+ notes: projectedRows,
269
+ totalCount,
189
270
  stale: offline ? true : stale,
190
271
  includeDeleted: Boolean(options.includeDeleted),
272
+ limit,
273
+ offset,
274
+ fields,
191
275
  tagsExpression: tagsExpression || undefined,
192
276
  createdAtFrom,
193
277
  createdAtTo,
@@ -207,7 +291,8 @@ export function registerNoteCommands(program) {
207
291
  .command('get')
208
292
  .description('Get note by id')
209
293
  .argument('<id>', 'note id')
210
- .action(async (id, _options, command) => {
294
+ .option('--context-only', 'return lightweight note context (title/tags/summary/links)')
295
+ .action(async (id, options, command) => {
211
296
  const globals = command.optsWithGlobals?.() ?? {};
212
297
  const offline = Boolean(globals.offline);
213
298
  const jsonOutput = Boolean(globals.json);
@@ -216,9 +301,25 @@ export function registerNoteCommands(program) {
216
301
  const { db, stale } = await connectPowerSync({ config, offline, timeoutMs });
217
302
  try {
218
303
  await syncBeforeNoteCommand(db, offline, timeoutMs);
304
+ if (options.contextOnly) {
305
+ const context = await getNoteContext(db, id);
306
+ if (!context) {
307
+ throw new DinoxError(`Note not found: ${id}`, {
308
+ code: 'NOTE_NOT_FOUND',
309
+ suggestion: "Run 'dino note search <keyword>' to locate the note id.",
310
+ recoverable: true,
311
+ });
312
+ }
313
+ printYaml({ stale: offline ? true : stale, note: context });
314
+ return;
315
+ }
219
316
  const note = await getNote(db, id);
220
317
  if (!note) {
221
- throw new DinoxError(`Note not found: ${id}`);
318
+ throw new DinoxError(`Note not found: ${id}`, {
319
+ code: 'NOTE_NOT_FOUND',
320
+ suggestion: "Run 'dino note search <keyword>' to locate the note id.",
321
+ recoverable: true,
322
+ });
222
323
  }
223
324
  if (jsonOutput) {
224
325
  printYaml({ stale, note });
@@ -240,6 +341,34 @@ export function registerNoteCommands(program) {
240
341
  await db.close().catch(() => undefined);
241
342
  }
242
343
  });
344
+ note
345
+ .command('preview')
346
+ .description('Preview the first N lines of note markdown')
347
+ .argument('<id>', 'note id')
348
+ .option('--lines <n>', 'number of lines to return (default: 10)')
349
+ .action(async (id, options, command) => {
350
+ const globals = command.optsWithGlobals?.() ?? {};
351
+ const offline = Boolean(globals.offline);
352
+ const config = resolveConfig(await loadConfig());
353
+ const timeoutMs = parseSyncTimeoutMs(globals.syncTimeout) ?? config.sync.timeoutMs;
354
+ const lines = parsePreviewLineCount(options.lines);
355
+ const { db, stale } = await connectPowerSync({ config, offline, timeoutMs });
356
+ try {
357
+ await syncBeforeNoteCommand(db, offline, timeoutMs);
358
+ const preview = await getNotePreview(db, id, lines);
359
+ if (!preview) {
360
+ throw new DinoxError(`Note not found: ${id}`, {
361
+ code: 'NOTE_NOT_FOUND',
362
+ suggestion: "Run 'dino note search <keyword>' to locate the note id.",
363
+ recoverable: true,
364
+ });
365
+ }
366
+ printYaml({ stale: offline ? true : stale, note: preview });
367
+ }
368
+ finally {
369
+ await db.close().catch(() => undefined);
370
+ }
371
+ });
243
372
  note
244
373
  .command('detail')
245
374
  .description('Get full note details by id')
@@ -41,6 +41,30 @@ export type NoteDetail = {
41
41
  content_md: string | null;
42
42
  zettel_boxes: string[];
43
43
  };
44
+ export type NoteContext = {
45
+ id: string;
46
+ title: string;
47
+ summary: string;
48
+ tags: string[];
49
+ created_at: string | null;
50
+ updated_at: string | null;
51
+ is_del: number | null;
52
+ zettel_boxes: string[];
53
+ links: {
54
+ backlinks: number;
55
+ outlinks: number;
56
+ };
57
+ };
58
+ export type NotePreview = {
59
+ id: string;
60
+ title: string;
61
+ requested_lines: number;
62
+ total_lines: number;
63
+ returned_lines: number;
64
+ truncated: boolean;
65
+ preview: string;
66
+ updated_at: string | null;
67
+ };
44
68
  export type UpdateNoteResult = {
45
69
  id: string;
46
70
  updated: boolean;
@@ -49,6 +73,8 @@ export type UpdateNoteResult = {
49
73
  };
50
74
  export declare function searchNotes(db: AbstractPowerSyncDatabase, query: string, options: SearchNotesOptions): Promise<SearchedNote[]>;
51
75
  export declare function getNote(db: AbstractPowerSyncDatabase, id: string): Promise<NoteRow | null>;
76
+ export declare function getNoteContext(db: AbstractPowerSyncDatabase, id: string): Promise<NoteContext | null>;
77
+ export declare function getNotePreview(db: AbstractPowerSyncDatabase, id: string, requestedLines: number): Promise<NotePreview | null>;
52
78
  export declare function getNoteDetails(db: AbstractPowerSyncDatabase, ids: string[]): Promise<NoteDetail[]>;
53
79
  export declare function getNoteDetail(db: AbstractPowerSyncDatabase, id: string): Promise<NoteDetail | null>;
54
80
  export declare function createNote(db: AbstractPowerSyncDatabase, input: {
@@ -495,6 +495,105 @@ export async function getNote(db, id) {
495
495
  WHERE id = ?
496
496
  `, [id]);
497
497
  }
498
+ async function getLinkCount(db, direction, noteId) {
499
+ const [column, boundColumn] = direction === 'in'
500
+ ? ['src_note_id', 'dst_note_id']
501
+ : ['dst_note_id', 'src_note_id'];
502
+ const row = await db.getOptional(`
503
+ SELECT COUNT(1) AS count
504
+ FROM c_note_link l
505
+ JOIN c_note n ON n.id = l.${column}
506
+ WHERE l.${boundColumn} = ?
507
+ AND l.is_del = 0
508
+ AND (n.is_del IS NULL OR n.is_del = 0)
509
+ `, [noteId]);
510
+ return Number(row?.count ?? 0);
511
+ }
512
+ function buildPreviewText(contentMd, requestedLines) {
513
+ const normalized = (contentMd ?? '').replace(/\r\n/g, '\n');
514
+ if (!normalized) {
515
+ return {
516
+ preview: '',
517
+ totalLines: 0,
518
+ returnedLines: 0,
519
+ truncated: false,
520
+ };
521
+ }
522
+ const lines = normalized.split('\n');
523
+ const selected = lines.slice(0, requestedLines);
524
+ return {
525
+ preview: selected.join('\n'),
526
+ totalLines: lines.length,
527
+ returnedLines: selected.length,
528
+ truncated: selected.length < lines.length,
529
+ };
530
+ }
531
+ export async function getNoteContext(db, id) {
532
+ const row = await db.getOptional(`
533
+ SELECT
534
+ n.id,
535
+ n.title,
536
+ n.summary,
537
+ n.content_text,
538
+ n.tags,
539
+ n.created_at,
540
+ n.updated_at,
541
+ n.is_del,
542
+ n.zettel_boxes AS zettel_boxes_raw
543
+ FROM c_note n
544
+ WHERE n.id = ?
545
+ `, [id]);
546
+ if (!row) {
547
+ return null;
548
+ }
549
+ const boxIds = parseZettelBoxIds(row.zettel_boxes_raw);
550
+ const boxNameMap = await loadZettelBoxNameMap(db, boxIds);
551
+ const [backlinks, outlinks] = await Promise.all([
552
+ getLinkCount(db, 'in', id),
553
+ getLinkCount(db, 'out', id),
554
+ ]);
555
+ return {
556
+ id: row.id,
557
+ title: row.title?.trim() ?? '',
558
+ summary: resolveSummary(row.summary, row.content_text),
559
+ tags: parseStoredTags(row.tags),
560
+ created_at: row.created_at,
561
+ updated_at: row.updated_at,
562
+ is_del: row.is_del,
563
+ zettel_boxes: boxIds
564
+ .map((boxId) => boxNameMap.get(boxId))
565
+ .filter((name) => typeof name === 'string' && name.length > 0),
566
+ links: {
567
+ backlinks,
568
+ outlinks,
569
+ },
570
+ };
571
+ }
572
+ export async function getNotePreview(db, id, requestedLines) {
573
+ const row = await db.getOptional(`
574
+ SELECT
575
+ n.id,
576
+ n.title,
577
+ n.content_md,
578
+ n.updated_at
579
+ FROM c_note n
580
+ WHERE n.id = ?
581
+ `, [id]);
582
+ if (!row) {
583
+ return null;
584
+ }
585
+ const preview = buildPreviewText(row.content_md, requestedLines);
586
+ return {
587
+ id: row.id,
588
+ title: row.title?.trim() ?? '',
589
+ requested_lines: requestedLines,
590
+ total_lines: preview.totalLines,
591
+ returned_lines: preview.returnedLines,
592
+ truncated: preview.truncated,
593
+ preview: preview.preview,
594
+ updated_at: row.updated_at ?? null,
595
+ };
596
+ }
498
597
  function mapNoteDetailRow(note, boxNameMap) {
499
598
  const boxIds = parseZettelBoxIds(note.zettel_boxes_raw);
500
599
  const zettelBoxes = boxIds
@@ -0,0 +1,6 @@
1
+ import type { SearchedNote } from './repo.js';
2
+ export declare const NOTE_SEARCH_FIELDS: readonly ["id", "title", "summary", "tags", "created_at", "zettel_boxes"];
3
+ export type NoteSearchField = (typeof NOTE_SEARCH_FIELDS)[number];
4
+ export type NoteSearchResultItem = Partial<SearchedNote>;
5
+ export declare function parseNoteSearchFields(value: unknown, optionName: string): NoteSearchField[] | undefined;
6
+ export declare function projectNoteSearchFields(notes: SearchedNote[], fields: NoteSearchField[] | undefined): NoteSearchResultItem[];
@@ -0,0 +1,41 @@
1
+ import { DinoxError } from '../../utils/errors.js';
2
+ export const NOTE_SEARCH_FIELDS = ['id', 'title', 'summary', 'tags', 'created_at', 'zettel_boxes'];
3
+ const NOTE_SEARCH_FIELD_SET = new Set(NOTE_SEARCH_FIELDS);
4
+ export function parseNoteSearchFields(value, optionName) {
5
+ if (value === undefined || value === null || value === '') {
6
+ return undefined;
7
+ }
8
+ let rawFields = [];
9
+ if (Array.isArray(value)) {
10
+ rawFields = value.map((item) => String(item).trim());
11
+ }
12
+ else if (typeof value === 'string') {
13
+ rawFields = value
14
+ .split(/[\n,,]/g)
15
+ .map((item) => item.trim());
16
+ }
17
+ else {
18
+ throw new DinoxError(`${optionName} must be a comma-separated string or string array`);
19
+ }
20
+ const normalized = Array.from(new Set(rawFields.filter(Boolean)));
21
+ if (normalized.length === 0) {
22
+ throw new DinoxError(`${optionName} must include at least one field`);
23
+ }
24
+ const invalidField = normalized.find((field) => !NOTE_SEARCH_FIELD_SET.has(field));
25
+ if (invalidField) {
26
+ throw new DinoxError(`${optionName} has unsupported field: ${invalidField}. Allowed: ${NOTE_SEARCH_FIELDS.join(', ')}`);
27
+ }
28
+ return normalized;
29
+ }
30
+ export function projectNoteSearchFields(notes, fields) {
31
+ if (!fields || fields.length === 0) {
32
+ return notes.map((note) => ({ ...note }));
33
+ }
34
+ return notes.map((note) => {
35
+ const item = {};
36
+ for (const field of fields) {
37
+ item[field] = note[field];
38
+ }
39
+ return item;
40
+ });
41
+ }
@@ -3,7 +3,7 @@ import { loadConfig } from '../config/store.js';
3
3
  import { resolveConfig } from '../config/resolve.js';
4
4
  import { connectPowerSync, waitForIdleDataFlow } from '../powersync/runtime.js';
5
5
  import { searchNotes, getNoteDetails } from '../commands/notes/repo.js';
6
- import { getBacklinks, getOutlinks, getRelatedNotes } from '../commands/graph/repo.js';
6
+ import { getBacklinks, getGraphStats, getOutlinks, getRelatedNotes } from '../commands/graph/repo.js';
7
7
  import { searchTodoTasks } from '../commands/todo/repo.js';
8
8
  import { savePid, removePid } from './pid.js';
9
9
  import { getPackageVersion } from '../utils/version.js';
@@ -75,6 +75,9 @@ export class DaemonServer {
75
75
  case 'graph.related':
76
76
  const related = await getRelatedNotes(this.db, params.id, params.depth || 2);
77
77
  return { id: req.id, ok: true, result: related };
78
+ case 'graph.stats':
79
+ const graphStats = await getGraphStats(this.db);
80
+ return { id: req.id, ok: true, result: graphStats };
78
81
  case 'todo.search':
79
82
  const todos = await searchTodoTasks(this.db, params.query || '', {
80
83
  includeDeleted: false,
package/dist/dinox.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { createCli } from './cli.js';
3
- import { isDinoxError } from './utils/errors.js';
3
+ import { buildStructuredErrorPayload, isDinoxError } from './utils/errors.js';
4
4
  import { printYaml } from './utils/output.js';
5
5
  function normalizeArgv(argv) {
6
6
  // pnpm/npm script forwarding can prepend a standalone "--".
@@ -25,26 +25,18 @@ main().catch((error) => {
25
25
  const argv = process.argv;
26
26
  const json = shouldOutputJson(argv);
27
27
  const verbose = isVerbose(argv);
28
- const message = error instanceof Error ? error.message : String(error);
29
- const name = error instanceof Error ? error.name : 'Error';
30
- const stack = error instanceof Error ? error.stack : undefined;
31
- const details = isDinoxError(error) ? error.details : undefined;
28
+ const payload = buildStructuredErrorPayload(error, verbose);
32
29
  const exitCode = isDinoxError(error) ? error.exitCode : 1;
33
30
  process.exitCode = exitCode;
34
31
  if (json) {
35
32
  printYaml({
36
33
  ok: false,
37
- error: {
38
- name,
39
- message,
40
- ...(details !== undefined ? { details } : {}),
41
- ...(verbose && stack ? { stack } : {}),
42
- },
34
+ error: payload,
43
35
  });
44
36
  return;
45
37
  }
46
- console.error(message);
47
- if (verbose && stack) {
48
- console.error(stack);
38
+ console.error(payload.message);
39
+ if (verbose && payload.stack) {
40
+ console.error(payload.stack);
49
41
  }
50
42
  });