@dinoxx/dinox-cli 1.0.15 → 1.0.17
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 +102 -6
- package/dist/cli.js +8 -0
- package/dist/commands/graph/index.d.ts +2 -0
- package/dist/commands/graph/index.js +113 -0
- package/dist/commands/graph/repo.d.ts +40 -0
- package/dist/commands/graph/repo.js +126 -0
- package/dist/commands/mcp/auth.d.ts +4 -0
- package/dist/commands/mcp/auth.js +75 -0
- package/dist/commands/mcp/index.d.ts +2 -0
- package/dist/commands/mcp/index.js +85 -0
- package/dist/commands/mcp/server.d.ts +31 -0
- package/dist/commands/mcp/server.js +392 -0
- package/dist/commands/mcp/tools.d.ts +2 -0
- package/dist/commands/mcp/tools.js +104 -0
- package/dist/commands/mcp/types.d.ts +28 -0
- package/dist/commands/mcp/types.js +1 -0
- package/dist/commands/meta/index.d.ts +2 -0
- package/dist/commands/meta/index.js +45 -0
- package/dist/commands/meta/repo.d.ts +27 -0
- package/dist/commands/meta/repo.js +71 -0
- package/dist/commands/notes/index.d.ts +13 -3
- package/dist/commands/notes/index.js +135 -6
- package/dist/commands/notes/repo.d.ts +26 -0
- package/dist/commands/notes/repo.js +99 -0
- package/dist/commands/notes/searchOutput.d.ts +6 -0
- package/dist/commands/notes/searchOutput.js +41 -0
- package/dist/daemon/client.d.ts +2 -0
- package/dist/daemon/client.js +86 -0
- package/dist/daemon/ensure.d.ts +1 -0
- package/dist/daemon/ensure.js +23 -0
- package/dist/daemon/index.d.ts +2 -0
- package/dist/daemon/index.js +151 -0
- package/dist/daemon/pid.d.ts +10 -0
- package/dist/daemon/pid.js +55 -0
- package/dist/daemon/server.d.ts +10 -0
- package/dist/daemon/server.js +140 -0
- package/dist/daemon/types.d.ts +19 -0
- package/dist/daemon/types.js +1 -0
- package/dist/dinox.js +6 -14
- package/dist/utils/errors.d.ts +22 -5
- package/dist/utils/errors.js +29 -0
- package/package.json +1 -1
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
-
import { type
|
|
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:
|
|
29
|
+
notes: NoteSearchResultItem[];
|
|
24
30
|
};
|
|
25
31
|
type BuildNoteSearchResponseInput = {
|
|
26
32
|
query: string;
|
|
27
|
-
notes:
|
|
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:
|
|
122
|
-
total_count:
|
|
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:
|
|
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
|
-
.
|
|
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
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { request as httpRequest } from 'node:http';
|
|
2
|
+
import { DEFAULT_DAEMON_PORT } from './types.js';
|
|
3
|
+
import { readPid } from './pid.js';
|
|
4
|
+
const DEFAULT_DAEMON_REQUEST_TIMEOUT_MS = 2_000;
|
|
5
|
+
const MAX_DAEMON_RESPONSE_BYTES = 5 * 1024 * 1024;
|
|
6
|
+
export async function sendDaemonRequest(method, params) {
|
|
7
|
+
const pidInfo = readPid();
|
|
8
|
+
const port = pidInfo?.port || DEFAULT_DAEMON_PORT;
|
|
9
|
+
const request = {
|
|
10
|
+
id: Date.now().toString(),
|
|
11
|
+
method,
|
|
12
|
+
params,
|
|
13
|
+
};
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
let settled = false;
|
|
16
|
+
const finalize = (error, value) => {
|
|
17
|
+
if (settled)
|
|
18
|
+
return;
|
|
19
|
+
settled = true;
|
|
20
|
+
if (error) {
|
|
21
|
+
reject(error);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
resolve(value);
|
|
25
|
+
};
|
|
26
|
+
const data = JSON.stringify(request);
|
|
27
|
+
const options = {
|
|
28
|
+
hostname: '127.0.0.1',
|
|
29
|
+
port,
|
|
30
|
+
path: '/',
|
|
31
|
+
method: 'POST',
|
|
32
|
+
headers: {
|
|
33
|
+
'Content-Type': 'application/json',
|
|
34
|
+
'Content-Length': Buffer.byteLength(data),
|
|
35
|
+
},
|
|
36
|
+
timeout: DEFAULT_DAEMON_REQUEST_TIMEOUT_MS,
|
|
37
|
+
};
|
|
38
|
+
const req = httpRequest(options, (res) => {
|
|
39
|
+
const statusCode = res.statusCode ?? 0;
|
|
40
|
+
const chunks = [];
|
|
41
|
+
let totalBytes = 0;
|
|
42
|
+
res.on('data', (chunk) => {
|
|
43
|
+
totalBytes += chunk.length;
|
|
44
|
+
if (totalBytes > MAX_DAEMON_RESPONSE_BYTES) {
|
|
45
|
+
req.destroy(new Error('Daemon response too large'));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
chunks.push(chunk);
|
|
49
|
+
});
|
|
50
|
+
res.on('end', () => {
|
|
51
|
+
try {
|
|
52
|
+
const body = Buffer.concat(chunks).toString('utf-8');
|
|
53
|
+
if (statusCode < 200 || statusCode >= 300) {
|
|
54
|
+
finalize(new Error(`Daemon request failed (HTTP ${statusCode || 'unknown'}): ${body || '(empty)'}`));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const response = JSON.parse(body);
|
|
58
|
+
if (response.ok) {
|
|
59
|
+
finalize(undefined, response.result);
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
finalize(new Error(response.error || 'Unknown error'));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
finalize(error);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
req.on('timeout', () => {
|
|
71
|
+
req.destroy(new Error('Daemon request timed out'));
|
|
72
|
+
});
|
|
73
|
+
req.on('error', (error) => finalize(error));
|
|
74
|
+
req.write(data);
|
|
75
|
+
req.end();
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
export async function isDaemonRunning() {
|
|
79
|
+
try {
|
|
80
|
+
await sendDaemonRequest('ping');
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function ensureDaemonRunning(): Promise<void>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { isDaemonRunning } from './client.js';
|
|
3
|
+
import { DEFAULT_DAEMON_PORT } from './types.js';
|
|
4
|
+
export async function ensureDaemonRunning() {
|
|
5
|
+
if (await isDaemonRunning()) {
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
8
|
+
console.log('Starting daemon...');
|
|
9
|
+
const args = ['daemon', 'start', '--port', String(DEFAULT_DAEMON_PORT), '--no-detach'];
|
|
10
|
+
const child = spawn(process.argv[0], [process.argv[1], ...args], {
|
|
11
|
+
detached: true,
|
|
12
|
+
stdio: 'ignore',
|
|
13
|
+
});
|
|
14
|
+
child.unref();
|
|
15
|
+
for (let i = 0; i < 10; i++) {
|
|
16
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
17
|
+
if (await isDaemonRunning()) {
|
|
18
|
+
console.log('Daemon started');
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
throw new Error('Failed to start daemon');
|
|
23
|
+
}
|