@dinoxx/dinox-cli 1.1.0 → 2.0.0

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.
Files changed (111) hide show
  1. package/README.md +48 -4
  2. package/dist/cli.js +4 -0
  3. package/dist/commands/auth/index.js +1 -2
  4. package/dist/commands/boxes/index.js +37 -13
  5. package/dist/commands/boxes/operations.d.ts +3 -3
  6. package/dist/commands/boxes/operations.js +51 -28
  7. package/dist/commands/info/index.js +30 -5
  8. package/dist/commands/meta/repo.js +2 -1
  9. package/dist/commands/notes/actions.js +20 -37
  10. package/dist/commands/notes/bulk.d.ts +2 -2
  11. package/dist/commands/notes/bulk.js +3 -18
  12. package/dist/commands/notes/contentPatch.d.ts +5 -1
  13. package/dist/commands/notes/contentPatch.js +12 -1
  14. package/dist/commands/notes/contentPatchCommand.d.ts +3 -0
  15. package/dist/commands/notes/contentPatchCommand.js +29 -8
  16. package/dist/commands/notes/descriptors.js +19 -16
  17. package/dist/commands/notes/export.js +3 -0
  18. package/dist/commands/notes/repo.d.ts +9 -0
  19. package/dist/commands/notes/repo.js +65 -15
  20. package/dist/commands/notes/searchOutput.d.ts +2 -1
  21. package/dist/commands/notes/searchOutput.js +2 -1
  22. package/dist/commands/notes/searchSql.js +9 -3
  23. package/dist/commands/prompt/index.d.ts +1 -0
  24. package/dist/commands/prompt/index.js +26 -11
  25. package/dist/commands/runtime.js +17 -6
  26. package/dist/commands/savedViews/index.d.ts +7 -0
  27. package/dist/commands/savedViews/index.js +458 -0
  28. package/dist/commands/savedViews/model.d.ts +87 -0
  29. package/dist/commands/savedViews/model.js +306 -0
  30. package/dist/commands/savedViews/repo.d.ts +123 -0
  31. package/dist/commands/savedViews/repo.js +512 -0
  32. package/dist/commands/savedViews/semantics.d.ts +46 -0
  33. package/dist/commands/savedViews/semantics.js +738 -0
  34. package/dist/commands/schema/renderSkills.d.ts +2 -0
  35. package/dist/commands/schema/renderSkills.js +69 -10
  36. package/dist/commands/schema/spec.js +4 -0
  37. package/dist/commands/skills/index.d.ts +54 -0
  38. package/dist/commands/skills/index.js +468 -0
  39. package/dist/commands/storage/index.js +2 -2
  40. package/dist/commands/sync.d.ts +23 -1
  41. package/dist/commands/sync.js +100 -58
  42. package/dist/commands/tags/index.js +30 -11
  43. package/dist/commands/tags/operations.d.ts +3 -3
  44. package/dist/commands/tags/operations.js +45 -20
  45. package/dist/commands/templates/model.d.ts +50 -0
  46. package/dist/commands/templates/model.js +239 -0
  47. package/dist/commands/todo/index.js +3 -3
  48. package/dist/commands/update.d.ts +1 -0
  49. package/dist/commands/update.js +46 -9
  50. package/dist/commands/writeConfirmation.d.ts +13 -0
  51. package/dist/commands/writeConfirmation.js +54 -0
  52. package/dist/config/resolve.js +5 -10
  53. package/dist/config/types.d.ts +3 -6
  54. package/dist/constants/links.d.ts +18 -1
  55. package/dist/constants/links.js +26 -1
  56. package/dist/daemon/client.d.ts +2 -2
  57. package/dist/daemon/client.js +106 -52
  58. package/dist/daemon/index.d.ts +5 -3
  59. package/dist/daemon/index.js +28 -5
  60. package/dist/daemon/lifecycle.d.ts +10 -3
  61. package/dist/daemon/lifecycle.js +54 -11
  62. package/dist/daemon/pid.d.ts +1 -1
  63. package/dist/daemon/pid.js +6 -3
  64. package/dist/dinox.js +9 -1
  65. package/dist/powersync/exclusiveRuntime.d.ts +2 -0
  66. package/dist/powersync/exclusiveRuntime.js +28 -4
  67. package/dist/powersync/runtime.d.ts +4 -2
  68. package/dist/powersync/runtime.js +43 -5
  69. package/dist/powersync/schema/content.d.ts +1 -78
  70. package/dist/powersync/schema/content.js +1 -78
  71. package/dist/powersync/schema/electronCanonical.d.ts +1561 -0
  72. package/dist/powersync/schema/electronCanonical.js +1154 -0
  73. package/dist/powersync/schema/index.d.ts +573 -129
  74. package/dist/powersync/schema/index.js +3 -32
  75. package/dist/powersync/schema/local.d.ts +1 -56
  76. package/dist/powersync/schema/local.js +1 -60
  77. package/dist/powersync/schema/note.d.ts +1 -35
  78. package/dist/powersync/schema/note.js +1 -35
  79. package/dist/powersync/schema/notesExtras.d.ts +1 -62
  80. package/dist/powersync/schema/notesExtras.js +1 -71
  81. package/dist/powersync/schema/projects.d.ts +1 -104
  82. package/dist/powersync/schema/projects.js +1 -104
  83. package/dist/powersync/schema/savedViews.d.ts +1 -0
  84. package/dist/powersync/schema/savedViews.js +1 -0
  85. package/dist/powersync/schema/storage.d.ts +1 -24
  86. package/dist/powersync/schema/storage.js +1 -24
  87. package/dist/powersync/schema/tags.d.ts +1 -37
  88. package/dist/powersync/schema/tags.js +1 -37
  89. package/dist/powersync/syncGate.d.ts +22 -3
  90. package/dist/powersync/syncGate.js +4 -1
  91. package/dist/powersync/syncPass.d.ts +2 -0
  92. package/dist/powersync/syncPass.js +15 -4
  93. package/dist/powersync/tokenIndex.d.ts +2 -0
  94. package/dist/powersync/tokenIndex.js +60 -18
  95. package/dist/powersync/uploader.d.ts +16 -2
  96. package/dist/powersync/uploader.js +152 -158
  97. package/dist/skills/check.d.ts +1 -0
  98. package/dist/skills/check.js +538 -70
  99. package/dist/skills/retired.d.ts +60 -0
  100. package/dist/skills/retired.js +58 -0
  101. package/dist/utils/deadline.d.ts +8 -0
  102. package/dist/utils/deadline.js +31 -0
  103. package/dist/utils/dryRun.d.ts +20 -1
  104. package/dist/utils/dryRun.js +22 -3
  105. package/dist/utils/errors.d.ts +2 -0
  106. package/dist/utils/errors.js +5 -3
  107. package/dist/utils/processExit.d.ts +1 -0
  108. package/dist/utils/processExit.js +33 -0
  109. package/dist/utils/updateNotice.d.ts +1 -0
  110. package/dist/utils/updateNotice.js +1 -1
  111. package/package.json +10 -5
@@ -3,12 +3,14 @@ import { printDryRunPreview } from '../../utils/dryRun.js';
3
3
  import { getRequestedStructuredOutputFormat, printHumanReadableLine, printSuccess, } from '../../utils/output.js';
4
4
  import { DinoxError } from '../../utils/errors.js';
5
5
  import { commandStale, printStaleCacheWarning, resolveWriteDurabilityForRuntime, waitForMutationUpload, } from '../runtime.js';
6
+ import { parseExpectedCount } from '../writeConfirmation.js';
6
7
  import { resolveZettelBoxIdsForBulk, resolveZettelBoxIdsForSearch, searchNotes, validateTagsForBulk, } from './repo.js';
7
8
  import { applyNoteMetadataMutation, planNoteMetadataMutation, } from './metadataMutation.js';
8
9
  import { parseCreatedAtRange } from './searchTime.js';
9
10
  import { normalizeDaysForMeta, } from './searchResponse.js';
10
11
  import { parseMutationListInput, parseSearchListInput, parseStarredOption, syncBeforeNoteCommand, withNoteDatabase, } from './runtime.js';
11
12
  import { buildBatchWriteReceiptFields, readNoteRevisionReceipts, } from './writeReceipt.js';
13
+ export { parseExpectedCount };
12
14
  const BULK_PREVIEW_LIMIT = 100;
13
15
  export function selectBulkMutation(options) {
14
16
  const candidates = [
@@ -25,23 +27,6 @@ export function selectBulkMutation(options) {
25
27
  }
26
28
  return selected[0];
27
29
  }
28
- export function parseExpectedCount(value) {
29
- if (value === undefined || value === null || value === '') {
30
- return null;
31
- }
32
- if (typeof value !== 'string') {
33
- throw new DinoxError('--expected-count must be a non-negative integer');
34
- }
35
- const trimmed = value.trim();
36
- if (!/^\d+$/.test(trimmed)) {
37
- throw new DinoxError('--expected-count must be a non-negative integer');
38
- }
39
- const parsed = Number(trimmed);
40
- if (!Number.isSafeInteger(parsed) || parsed < 0) {
41
- throw new DinoxError('--expected-count must be a non-negative integer');
42
- }
43
- return parsed;
44
- }
45
30
  function hasConcreteTargetFilter(input) {
46
31
  const { query, options } = input;
47
32
  const hasValue = (value) => typeof value === 'string' && value.trim().length > 0;
@@ -169,7 +154,7 @@ export async function runNoteBulk({ command, options, args }) {
169
154
  [`${selected.field}_to`]: item.after,
170
155
  changed: item.changed,
171
156
  })),
172
- });
157
+ }, runtime);
173
158
  return;
174
159
  }
175
160
  if (!bulkOptions.confirm) {
@@ -1,5 +1,6 @@
1
1
  import type { AbstractPowerSyncDatabase } from '@powersync/common';
2
2
  import { type NoteContentBlockDescriptor, type NoteContentOutlineItem } from './contentEngine.js';
3
+ export type NoteContentReadScope = 'structural-metadata' | 'full-content';
3
4
  export type NoteContentPatchOperation = {
4
5
  kind: 'appendSection';
5
6
  heading: string;
@@ -24,6 +25,7 @@ export type NoteContentReadResult = {
24
25
  title: string;
25
26
  readToken: string;
26
27
  readTokenExpiresAt: string;
28
+ readTokenScope: NoteContentReadScope;
27
29
  base: {
28
30
  contentHash: string;
29
31
  updatedAt: string | null;
@@ -72,7 +74,9 @@ export declare function planMarkdownPatch(input: {
72
74
  contentMd: string;
73
75
  operation: NoteContentPatchOperation;
74
76
  }): NoteContentPatchPlan;
75
- export declare function readNoteContentForPatch(db: AbstractPowerSyncDatabase, id: string, databaseScope: string, userId: string): Promise<NoteContentReadResult>;
77
+ export declare function readNoteContentForPatch(db: AbstractPowerSyncDatabase, id: string, databaseScope: string, userId: string, options?: {
78
+ includeContent?: boolean;
79
+ }): Promise<NoteContentReadResult>;
76
80
  export declare function planNoteContentPatch(db: AbstractPowerSyncDatabase, input: {
77
81
  id: string;
78
82
  userId: string;
@@ -154,6 +154,7 @@ async function rememberReadToken(input) {
154
154
  contentHash: input.contentHash,
155
155
  updatedAt: input.updatedAt,
156
156
  version: input.version,
157
+ reviewScope: input.reviewScope,
157
158
  createdAt: new Date(nowMs).toISOString(),
158
159
  expiresAt,
159
160
  };
@@ -296,7 +297,7 @@ export function planMarkdownPatch(input) {
296
297
  });
297
298
  return plan;
298
299
  }
299
- export async function readNoteContentForPatch(db, id, databaseScope, userId) {
300
+ export async function readNoteContentForPatch(db, id, databaseScope, userId, options = {}) {
300
301
  const row = await readNoteContentRow(db, id);
301
302
  const tagLookup = await loadTagReferenceLookup(db, userId);
302
303
  const document = parseStoredNoteDocument({
@@ -308,18 +309,23 @@ export async function readNoteContentForPatch(db, id, databaseScope, userId) {
308
309
  });
309
310
  const contentHash = getNoteContentHash(document);
310
311
  const version = normalizeVersion(row.version);
312
+ const readTokenScope = options.includeContent === true
313
+ ? 'full-content'
314
+ : 'structural-metadata';
311
315
  const token = await rememberReadToken({
312
316
  databaseScope,
313
317
  noteId: row.id,
314
318
  contentHash,
315
319
  updatedAt: row.updated_at ?? null,
316
320
  version,
321
+ reviewScope: readTokenScope,
317
322
  });
318
323
  return {
319
324
  noteId: row.id,
320
325
  title: row.title?.trim() ?? '',
321
326
  readToken: token.token,
322
327
  readTokenExpiresAt: token.expiresAt,
328
+ readTokenScope,
323
329
  base: {
324
330
  contentHash,
325
331
  updatedAt: row.updated_at ?? null,
@@ -353,6 +359,11 @@ export async function applyNoteContentPatch(db, input) {
353
359
  if (token.databaseScopeFingerprint !== fingerprintDatabaseScope(input.databaseScope)) {
354
360
  throw new DinoxError('read token was issued for a different local account database. Run `dino note content-read <id>` again.');
355
361
  }
362
+ if ('allowProtectedReplace' in input.operation
363
+ && input.operation.allowProtectedReplace === true
364
+ && token.reviewScope !== 'full-content') {
365
+ throw new DinoxError('Protected content replacement requires a full-content read token. Run `dino note content-read <id> --include-content --format json`, review the affected protected blocks, and retry with the new token.');
366
+ }
356
367
  const result = await withPowerSyncWriteTransaction(db, async (tx) => {
357
368
  const tagLookup = await loadTagReferenceLookup(tx, input.userId);
358
369
  const row = await readNoteContentRow(tx, input.id);
@@ -1,3 +1,6 @@
1
1
  import type { CommandExecutionContext } from '../descriptor/types.js';
2
+ import type { NoteContentBlockDescriptor } from './contentEngine.js';
3
+ export type PublicNoteContentBlockDescriptor = Pick<NoteContentBlockDescriptor, 'anchorId' | 'hasStableAnchor' | 'nodeIndex' | 'nodeRange' | 'type' | 'editMode' | 'level' | 'textHash' | 'nodeHash' | 'charCount'>;
4
+ export declare function toPublicNoteContentBlocks(blocks: NoteContentBlockDescriptor[]): PublicNoteContentBlockDescriptor[];
2
5
  export declare function runNoteContentRead({ command, options, args }: CommandExecutionContext): Promise<void>;
3
6
  export declare function runNotePatch({ command, options, args }: CommandExecutionContext): Promise<void>;
@@ -7,6 +7,20 @@ import { commandStale, printStaleCacheWarning, resolveWriteDurabilityForRuntime,
7
7
  import { readNoteWriteReceipt } from './writeReceipt.js';
8
8
  import { applyNoteContentPatch, planNoteContentPatch, readNoteContentForPatch, } from './contentPatch.js';
9
9
  import { parseStringInput, syncBeforeNoteCommand, withNoteDatabase, } from './runtime.js';
10
+ export function toPublicNoteContentBlocks(blocks) {
11
+ return blocks.map((block) => ({
12
+ anchorId: block.anchorId,
13
+ hasStableAnchor: block.hasStableAnchor,
14
+ nodeIndex: block.nodeIndex,
15
+ nodeRange: block.nodeRange,
16
+ type: block.type,
17
+ editMode: block.editMode,
18
+ level: block.level,
19
+ textHash: block.textHash,
20
+ nodeHash: block.nodeHash,
21
+ charCount: block.charCount,
22
+ }));
23
+ }
10
24
  function requireNoteId(id) {
11
25
  const normalized = id?.trim() ?? '';
12
26
  if (!normalized) {
@@ -60,11 +74,11 @@ export async function runNoteContentRead({ command, options, args }) {
60
74
  await withNoteDatabase(command, async (runtime) => {
61
75
  await syncBeforeNoteCommand(runtime);
62
76
  const identity = await resolveAuthIdentity(runtime.config);
63
- const result = await readNoteContentForPatch(runtime.db, id, getPowerSyncDbPath(identity.userId), identity.userId);
64
- const { content, ...rest } = result;
77
+ const result = await readNoteContentForPatch(runtime.db, id, getPowerSyncDbPath(identity.userId), identity.userId, { includeContent: readOptions.includeContent === true });
78
+ const { content, blocks, ...rest } = result;
65
79
  const payload = readOptions.includeContent
66
- ? { ...rest, content, stale: commandStale(runtime) }
67
- : { ...rest, stale: commandStale(runtime) };
80
+ ? { ...rest, blocks, content, stale: commandStale(runtime) }
81
+ : { ...rest, blocks: toPublicNoteContentBlocks(blocks), stale: commandStale(runtime) };
68
82
  printSuccess(payload);
69
83
  });
70
84
  }
@@ -88,6 +102,9 @@ export async function runNotePatch({ command, options, args }) {
88
102
  summary: `Preview ${plan.operation} for note "${plan.title}" (${plan.noteId}); content ${plan.changed ? 'would change' : 'is unchanged'}.`,
89
103
  warnings: [
90
104
  'Run `dino note content-read <id> --format json` before the real edit and pass the returned readToken as --read-token.',
105
+ ...('allowProtectedReplace' in operation && operation.allowProtectedReplace === true
106
+ ? ['Protected replacement requires `content-read --include-content`; review the affected protected blocks before using its full-content token.']
107
+ : []),
91
108
  ...(offline ? ['Execution would run in offline mode and only use the local cache.'] : []),
92
109
  ],
93
110
  inputs: {
@@ -102,12 +119,14 @@ export async function runNotePatch({ command, options, args }) {
102
119
  after_chars: plan.afterContent.length,
103
120
  changed: plan.changed,
104
121
  },
105
- });
122
+ }, runtime);
106
123
  return;
107
124
  }
108
- const readToken = patchOptions.readToken?.trim() ?? '';
125
+ const readToken = typeof patchOptions.readToken === 'string'
126
+ ? (await parseStringInput(patchOptions.readToken)).trim()
127
+ : '';
109
128
  if (!readToken) {
110
- throw new DinoxError('Real note content edits require --read-token from `dino note content-read <id>`.');
129
+ throw new DinoxError('Real note content edits require --read-token <token|@file> from `dino note content-read <id>`.');
111
130
  }
112
131
  const result = await applyNoteContentPatch(db, {
113
132
  id,
@@ -124,10 +143,12 @@ export async function runNotePatch({ command, options, args }) {
124
143
  upload,
125
144
  stale: commandStale(runtime),
126
145
  });
127
- const { changed: contentChanged, ...publicResult } = result;
146
+ const { changed: contentChanged, beforeBlocks, afterBlocks, ...publicResult } = result;
128
147
  const payload = {
129
148
  ok: true,
130
149
  ...publicResult,
150
+ beforeBlocks: toPublicNoteContentBlocks(beforeBlocks),
151
+ afterBlocks: toPublicNoteContentBlocks(afterBlocks),
131
152
  content_changed: contentChanged,
132
153
  ...receipt,
133
154
  };
@@ -42,7 +42,7 @@ export const noteCommandGroup = {
42
42
  { flags: '--sql <expr>', description: 'SQL-like expression over id/content_md/summary/tags/zettel_boxes/created_at/type/is_starred', type: 'string' },
43
43
  { flags: '--limit <n>', description: 'Maximum returned notes', type: 'number' },
44
44
  { flags: '--offset <n>', description: 'Result offset for pagination', type: 'number', defaultValue: 0 },
45
- { flags: '--fields <list>', description: 'Comma-separated fields: id,title,summary,tags,created_at,boxes,is_starred', type: 'string[]' },
45
+ { flags: '--fields <list>', description: 'Comma-separated fields: id,title,summary,tags,created_at,starred_at,boxes,is_starred', type: 'string[]' },
46
46
  { flags: '--include-deleted', description: 'Include soft-deleted notes', type: 'boolean' },
47
47
  ],
48
48
  schema: {
@@ -54,7 +54,7 @@ export const noteCommandGroup = {
54
54
  type: 'structured',
55
55
  summary: 'AI-friendly note search payload',
56
56
  entity: 'note_search_result',
57
- fields: ['id', 'title', 'summary', 'tags', 'created_at', 'boxes', 'is_starred'],
57
+ fields: ['id', 'title', 'summary', 'tags', 'created_at', 'starred_at', 'boxes', 'is_starred'],
58
58
  metaFields: ['query', 'returned_count', 'total_count', 'stale', 'pagination', 'fields', 'applied_filters'],
59
59
  },
60
60
  examples: [
@@ -66,9 +66,9 @@ export const noteCommandGroup = {
66
66
  },
67
67
  {
68
68
  name: 'get',
69
- description: 'Get one note by id, optionally in lightweight context mode',
69
+ description: 'Get lightweight note context by id without exposing full note content',
70
70
  arguments: [{ syntax: '<id>', description: 'Note id' }],
71
- options: [{ flags: '--context-only', description: 'Return lightweight note context (title/tags/summary/links)', type: 'boolean' }],
71
+ options: [{ flags: '--context-only', description: 'Compatibility flag; note get is context-only by default', type: 'boolean' }],
72
72
  schema: {
73
73
  path: 'note.get',
74
74
  risk: 'read',
@@ -76,11 +76,11 @@ export const noteCommandGroup = {
76
76
  globalOptions: NOTE_GLOBAL_OPTIONS,
77
77
  output: {
78
78
  type: 'structured',
79
- summary: 'Single note or note context payload',
79
+ summary: 'Lightweight note context payload; use note detail or content-read --include-content for full content',
80
80
  entity: 'note_context',
81
- fields: ['id', 'title', 'summary', 'tags', 'created_at', 'updated_at', 'is_del', 'boxes', 'links'],
81
+ fields: ['stale', 'note.id', 'note.title', 'note.summary', 'note.tags', 'note.created_at', 'note.updated_at', 'note.is_del', 'note.starred_at', 'note.is_starred', 'note.boxes', 'note.links'],
82
82
  },
83
- examples: ['dino note get <id> --context-only --format json', 'dino note get <id> --format json'],
83
+ examples: ['dino note get <id> --format json'],
84
84
  },
85
85
  action: runNoteGet,
86
86
  },
@@ -115,8 +115,8 @@ export const noteCommandGroup = {
115
115
  globalOptions: NOTE_GLOBAL_OPTIONS,
116
116
  output: {
117
117
  type: 'structured',
118
- summary: 'One full note object or an ordered array of note objects',
119
- fields: ['id', 'title', 'content_md', 'content_text', 'content_json', 'tags', 'boxes', 'created_at', 'updated_at', 'is_del'],
118
+ summary: 'Ordered full-note collection with freshness metadata',
119
+ fields: ['stale', 'count', 'notes.id', 'notes.title', 'notes.content_md', 'notes.content_text', 'notes.content_json', 'notes.properties', 'notes.is_pub', 'notes.starred_at', 'notes.is_starred', 'notes.tags', 'notes.boxes', 'notes.created_at', 'notes.updated_at', 'notes.is_del'],
120
120
  },
121
121
  examples: ['dino note detail <id> --format json', 'dino note detail --ids @/tmp/note-ids.txt --format json'],
122
122
  },
@@ -155,7 +155,7 @@ export const noteCommandGroup = {
155
155
  description: 'Read note content context and issue a short-lived token required before content patching',
156
156
  arguments: [{ syntax: '<id>', description: 'Note id' }],
157
157
  options: [
158
- { flags: '--include-content', description: 'Include full markdown content in the response', type: 'boolean' },
158
+ { flags: '--include-content', description: 'Include full markdown plus detailed block text and attributes', type: 'boolean' },
159
159
  ],
160
160
  schema: {
161
161
  path: 'note.content-read',
@@ -164,10 +164,13 @@ export const noteCommandGroup = {
164
164
  globalOptions: NOTE_GLOBAL_OPTIONS,
165
165
  output: {
166
166
  type: 'structured',
167
- summary: 'Note content read context with readToken, base hash, heading outline, block index, and derived metadata',
168
- fields: ['noteId', 'title', 'readToken', 'readTokenExpiresAt', 'base', 'outline', 'blocks', 'derived', 'content', 'stale'],
167
+ summary: 'Privacy-preserving note edit context; full content and detailed blocks require --include-content',
168
+ fields: ['noteId', 'title', 'readToken', 'readTokenExpiresAt', 'readTokenScope', 'base', 'outline', 'blocks.type', 'blocks.editMode', 'blocks.nodeRange', 'blocks.textHash', 'blocks.nodeHash', 'blocks.charCount', 'derived', 'content', 'stale'],
169
169
  },
170
- examples: ['dino note content-read <id> --include-content --format json'],
170
+ examples: [
171
+ 'dino note content-read <id> --format json',
172
+ 'dino note content-read <id> --include-content --format json',
173
+ ],
171
174
  },
172
175
  action: runNoteContentRead,
173
176
  },
@@ -295,8 +298,8 @@ export const noteCommandGroup = {
295
298
  { flags: '--replace-block', description: 'Replace one exact markdown block matched by --match', type: 'boolean' },
296
299
  { flags: '--match <string|@file>', description: 'Exact markdown to replace when using --replace-block', type: 'string' },
297
300
  { flags: '--content <string|@file>', description: 'Markdown content to insert', type: 'string' },
298
- { flags: '--read-token <token>', description: 'Required for real writes; returned by note content-read', type: 'string' },
299
- { flags: '--allow-protected-replace', description: 'Allow replace operations to affect media, table, container, or unknown blocks after reviewing content-read output', type: 'boolean' },
301
+ { flags: '--read-token <token|@file>', description: 'Single-use patch capability returned by note content-read; prefer @file to avoid exposing it in argv', type: 'string' },
302
+ { flags: '--allow-protected-replace', description: 'Allow replacement of media, table, container, or unknown blocks only with a reviewed full-content read token', type: 'boolean' },
300
303
  WRITE_DURABILITY_OPTION,
301
304
  { flags: '--dry-run', description: 'Preview the patch without writing', type: 'boolean' },
302
305
  ],
@@ -312,7 +315,7 @@ export const noteCommandGroup = {
312
315
  },
313
316
  examples: [
314
317
  'dino note content-read <id> --format json',
315
- 'dino note patch <id> --append-section "复盘" --content @/tmp/section.md --read-token <token> --format json',
318
+ 'dino note patch <id> --append-section "复盘" --content @/tmp/section.md --read-token @/tmp/dinox-read-token --format json',
316
319
  ],
317
320
  },
318
321
  action: runNotePatch,
@@ -59,6 +59,9 @@ function serializeMarkdownExport(note) {
59
59
  tags: note.tags,
60
60
  boxes: note.boxes,
61
61
  box_ids: note.box_ids,
62
+ properties: note.properties,
63
+ is_pub: note.is_pub,
64
+ starred_at: note.starred_at,
62
65
  is_starred: note.is_starred,
63
66
  is_del: note.is_del ?? 0,
64
67
  }, {
@@ -11,6 +11,7 @@ export type NoteRow = {
11
11
  created_at: string | null;
12
12
  updated_at: string | null;
13
13
  is_del: number | null;
14
+ starred_at: string | null;
14
15
  is_starred: number | null;
15
16
  version: number | null;
16
17
  };
@@ -30,6 +31,7 @@ export type SearchedNote = {
30
31
  summary: string;
31
32
  tags: string[];
32
33
  created_at: string | null;
34
+ starred_at: string | null;
33
35
  is_starred: boolean;
34
36
  zettel_boxes: string[];
35
37
  };
@@ -42,6 +44,9 @@ export type NoteDetail = {
42
44
  source: string | null;
43
45
  tags: string[];
44
46
  is_del: number | null;
47
+ is_pub: boolean;
48
+ properties: Record<string, unknown>;
49
+ starred_at: string | null;
45
50
  is_starred: boolean;
46
51
  content_md: string | null;
47
52
  content_text: string | null;
@@ -58,6 +63,9 @@ export type NoteExportItem = {
58
63
  summary: string | null;
59
64
  tags: string[];
60
65
  is_del: number | null;
66
+ is_pub: boolean;
67
+ properties: Record<string, unknown>;
68
+ starred_at: string | null;
61
69
  is_starred: boolean;
62
70
  content_md: string;
63
71
  content_text: string | null;
@@ -73,6 +81,7 @@ export type NoteContext = {
73
81
  created_at: string | null;
74
82
  updated_at: string | null;
75
83
  is_del: number | null;
84
+ starred_at: string | null;
76
85
  is_starred: boolean;
77
86
  zettel_boxes: string[];
78
87
  links: {
@@ -6,7 +6,7 @@ import { listTags } from '../tags/repo.js';
6
6
  import { deriveImageDetailFromContentJson } from '../todo/contentModel.js';
7
7
  import { buildNoteSearchSqlFilter } from './searchSql.js';
8
8
  const MAX_SEARCH_SUMMARY_CHARS = 200;
9
- function normalizeStoredStarred(value) {
9
+ function normalizeStoredFlag(value) {
10
10
  if (typeof value === 'number') {
11
11
  return value === 1;
12
12
  }
@@ -15,6 +15,9 @@ function normalizeStoredStarred(value) {
15
15
  }
16
16
  return false;
17
17
  }
18
+ function normalizeStoredStarred(value) {
19
+ return normalizeStoredFlag(value);
20
+ }
18
21
  /**
19
22
  * Resolve box names to ids and run the note search. Shared by the local command path and
20
23
  * the daemon RPC handler so both produce identical results from one implementation.
@@ -129,7 +132,8 @@ export async function searchNotesPage(db, query, options) {
129
132
  n.tags,
130
133
  n.zettel_boxes,
131
134
  n.created_at,
132
- n.is_starred
135
+ n.starred_at,
136
+ CASE WHEN n.starred_at IS NOT NULL THEN 1 ELSE 0 END AS is_starred
133
137
  ${baseFromWhere}
134
138
  ORDER BY COALESCE(n.updated_at, n.created_at) DESC, n.id DESC
135
139
  ${buildPaginationSql(options.limit, options.offset)}
@@ -178,7 +182,8 @@ async function searchNotesWithFts(db, matchQuery, tagFilter, createdAtFilter, st
178
182
  n.tags,
179
183
  n.zettel_boxes,
180
184
  n.created_at,
181
- n.is_starred
185
+ n.starred_at,
186
+ CASE WHEN n.starred_at IS NOT NULL THEN 1 ELSE 0 END AS is_starred
182
187
  ${baseFromWhere}
183
188
  ORDER BY bm25(note_local_fts, 0.0, 8.0, 4.0, 1.0) ASC, COALESCE(n.updated_at, n.created_at) DESC, n.id DESC
184
189
  ${buildPaginationSql(limit, offset)}
@@ -219,6 +224,7 @@ async function enrichSearchRows(db, rows) {
219
224
  summary,
220
225
  tags: parseStoredTags(row.tags),
221
226
  created_at: row.created_at,
227
+ starred_at: row.starred_at ?? null,
222
228
  is_starred: normalizeStoredStarred(row.is_starred),
223
229
  zettel_boxes: zettelBoxes,
224
230
  };
@@ -342,8 +348,8 @@ function buildStarredFilter(starred, noteAlias) {
342
348
  return null;
343
349
  }
344
350
  return {
345
- sql: `(${noteAlias}.is_starred = ?)`,
346
- params: [starred ? '1' : '0'],
351
+ sql: starred ? `(${noteAlias}.starred_at IS NOT NULL)` : `(${noteAlias}.starred_at IS NULL)`,
352
+ params: [],
347
353
  };
348
354
  }
349
355
  function buildZettelBoxFilter(zettelBoxIds, matchEmptyZettelBoxes, noteAlias) {
@@ -588,7 +594,8 @@ export async function getNote(db, id) {
588
594
  created_at,
589
595
  updated_at,
590
596
  is_del,
591
- is_starred,
597
+ starred_at,
598
+ CASE WHEN starred_at IS NOT NULL THEN 1 ELSE 0 END AS is_starred,
592
599
  version
593
600
  FROM c_note
594
601
  WHERE id = ?
@@ -638,7 +645,8 @@ export async function getNoteContext(db, id) {
638
645
  n.created_at,
639
646
  n.updated_at,
640
647
  n.is_del,
641
- n.is_starred,
648
+ n.starred_at,
649
+ CASE WHEN n.starred_at IS NOT NULL THEN 1 ELSE 0 END AS is_starred,
642
650
  n.zettel_boxes AS zettel_boxes_raw
643
651
  FROM c_note n
644
652
  WHERE n.id = ?
@@ -660,6 +668,7 @@ export async function getNoteContext(db, id) {
660
668
  created_at: row.created_at,
661
669
  updated_at: row.updated_at,
662
670
  is_del: row.is_del,
671
+ starred_at: row.starred_at ?? null,
663
672
  is_starred: normalizeStoredStarred(row.is_starred),
664
673
  zettel_boxes: boxIds
665
674
  .map((boxId) => boxNameMap.get(boxId))
@@ -709,6 +718,9 @@ function mapNoteDetailRow(note, boxNameMap) {
709
718
  source: note.source,
710
719
  tags: parseStoredTags(note.tags),
711
720
  is_del: note.is_del,
721
+ is_pub: normalizeStoredFlag(note.is_pub),
722
+ properties: parseStoredNoteProperties(note.properties),
723
+ starred_at: note.starred_at ?? null,
712
724
  is_starred: normalizeStoredStarred(note.is_starred),
713
725
  content_md: note.content_md,
714
726
  content_text: note.content_text,
@@ -728,6 +740,30 @@ function parseStoredJsonValue(raw) {
728
740
  return raw;
729
741
  }
730
742
  }
743
+ function parseStoredNoteProperties(raw) {
744
+ const value = raw?.trim() ?? '';
745
+ if (!value) {
746
+ return {};
747
+ }
748
+ let parsed;
749
+ try {
750
+ parsed = JSON.parse(value);
751
+ }
752
+ catch (cause) {
753
+ throw new DinoxError('c_note.properties must be valid JSON', {
754
+ code: 'NOTE_INVALID_PROPERTIES',
755
+ details: { cause: cause instanceof Error ? cause.message : String(cause) },
756
+ recoverable: false,
757
+ });
758
+ }
759
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
760
+ throw new DinoxError('c_note.properties must be a JSON object', {
761
+ code: 'NOTE_INVALID_PROPERTIES',
762
+ recoverable: false,
763
+ });
764
+ }
765
+ return Object.fromEntries(Object.entries(parsed));
766
+ }
731
767
  function mapNoteExportRow(note, boxNameMap) {
732
768
  const boxIds = parseZettelBoxIds(note.zettel_boxes_raw);
733
769
  const boxes = boxIds
@@ -743,6 +779,9 @@ function mapNoteExportRow(note, boxNameMap) {
743
779
  summary: note.summary,
744
780
  tags: parseStoredTags(note.tags),
745
781
  is_del: note.is_del,
782
+ is_pub: normalizeStoredFlag(note.is_pub),
783
+ properties: parseStoredNoteProperties(note.properties),
784
+ starred_at: note.starred_at ?? null,
746
785
  is_starred: normalizeStoredStarred(note.is_starred),
747
786
  content_md: note.content_md ?? '',
748
787
  content_text: note.content_text,
@@ -773,7 +812,10 @@ export async function getNoteDetails(db, ids) {
773
812
  source,
774
813
  tags,
775
814
  is_del,
776
- is_starred,
815
+ is_pub,
816
+ properties,
817
+ starred_at,
818
+ CASE WHEN starred_at IS NOT NULL THEN 1 ELSE 0 END AS is_starred,
777
819
  content_md,
778
820
  content_text,
779
821
  content_json,
@@ -828,7 +870,10 @@ export async function getNoteExportItems(db, ids) {
828
870
  tags,
829
871
  summary,
830
872
  is_del,
831
- is_starred,
873
+ is_pub,
874
+ properties,
875
+ starred_at,
876
+ CASE WHEN starred_at IS NOT NULL THEN 1 ELSE 0 END AS is_starred,
832
877
  content_md,
833
878
  content_text,
834
879
  content_json,
@@ -930,11 +975,13 @@ export async function createNote(db, input) {
930
975
  ['resource_id', null],
931
976
  ['note_ids', '[]'],
932
977
  ['extra_data', '{}'],
978
+ ['properties', null],
979
+ ['is_pub', 0],
933
980
  ['format_type', ''],
934
981
  ['comments', '[]'],
935
- ['is_starred', 0],
936
982
  ['is_top', 0],
937
983
  ['pinned_at', '1997-01-01T00:00:00.000Z'],
984
+ ['starred_at', null],
938
985
  ['chat_context', '{}'],
939
986
  ['bid_links', '[]'],
940
987
  ];
@@ -1082,7 +1129,7 @@ export async function updateNote(db, input) {
1082
1129
  throw new DinoxError('At least one field is required: tags, boxes, or starred');
1083
1130
  }
1084
1131
  const existing = await db.getOptional(`
1085
- SELECT id, tags, zettel_boxes, is_del, is_starred
1132
+ SELECT id, tags, zettel_boxes, is_del, starred_at
1086
1133
  FROM c_note
1087
1134
  WHERE id = ?
1088
1135
  `, [id]);
@@ -1094,7 +1141,7 @@ export async function updateNote(db, input) {
1094
1141
  }
1095
1142
  const currentTags = parseStoredTags(existing.tags);
1096
1143
  const currentZettelBoxIds = parseZettelBoxIds(existing.zettel_boxes);
1097
- const currentStarred = normalizeStoredStarred(existing.is_starred);
1144
+ const currentStarred = existing.starred_at !== null;
1098
1145
  const nextTags = shouldUpdateTags ? normalizeDistinctStrings(input.tags) : currentTags;
1099
1146
  const nextZettelBoxIds = shouldUpdateBoxes ? normalizeDistinctStrings(input.zettelBoxIds) : currentZettelBoxIds;
1100
1147
  const nextStarred = shouldUpdateStarred ? input.starred : currentStarred;
@@ -1122,11 +1169,14 @@ export async function updateNote(db, input) {
1122
1169
  params.push(JSON.stringify(nextZettelBoxIds));
1123
1170
  }
1124
1171
  if (starredChanged) {
1125
- setParts.push('is_starred = ?');
1126
- params.push(nextStarred ? 1 : 0);
1172
+ setParts.push('starred_at = ?');
1173
+ }
1174
+ const updatedAt = nowIsoUtc();
1175
+ if (starredChanged) {
1176
+ params.push(nextStarred ? updatedAt : null);
1127
1177
  }
1128
1178
  setParts.push('updated_at = ?');
1129
- params.push(nowIsoUtc());
1179
+ params.push(updatedAt);
1130
1180
  setParts.push('version = COALESCE(version, 0) + 1');
1131
1181
  params.push(id);
1132
1182
  await db.execute(`
@@ -1,5 +1,5 @@
1
1
  import type { SearchedNote } from './repo.js';
2
- export declare const NOTE_SEARCH_FIELDS: readonly ["id", "title", "summary", "tags", "created_at", "boxes", "is_starred"];
2
+ export declare const NOTE_SEARCH_FIELDS: readonly ["id", "title", "summary", "tags", "created_at", "starred_at", "boxes", "is_starred"];
3
3
  export type NoteSearchField = (typeof NOTE_SEARCH_FIELDS)[number];
4
4
  export type NoteSearchResultItem = {
5
5
  id?: string;
@@ -7,6 +7,7 @@ export type NoteSearchResultItem = {
7
7
  summary?: string;
8
8
  tags?: string[];
9
9
  created_at?: string | null;
10
+ starred_at?: string | null;
10
11
  is_starred?: boolean;
11
12
  boxes?: string[];
12
13
  };
@@ -1,5 +1,5 @@
1
1
  import { DinoxError } from '../../utils/errors.js';
2
- export const NOTE_SEARCH_FIELDS = ['id', 'title', 'summary', 'tags', 'created_at', 'boxes', 'is_starred'];
2
+ export const NOTE_SEARCH_FIELDS = ['id', 'title', 'summary', 'tags', 'created_at', 'starred_at', 'boxes', 'is_starred'];
3
3
  const NOTE_SEARCH_FIELD_SET = new Set(NOTE_SEARCH_FIELDS);
4
4
  export function parseNoteSearchFields(value, optionName) {
5
5
  if (value === undefined || value === null || value === '') {
@@ -35,6 +35,7 @@ export function projectNoteSearchFields(notes, fields) {
35
35
  summary: note.summary,
36
36
  tags: note.tags,
37
37
  created_at: note.created_at,
38
+ starred_at: note.starred_at,
38
39
  is_starred: note.is_starred,
39
40
  boxes: note.zettel_boxes,
40
41
  }));
@@ -32,8 +32,14 @@ const SCALAR_FIELD_COLUMNS = {
32
32
  summary: 'summary',
33
33
  created_at: 'created_at',
34
34
  type: 'type',
35
- is_starred: 'is_starred',
35
+ is_starred: 'starred_at',
36
36
  };
37
+ function scalarFieldSql(field, noteAlias) {
38
+ if (field === 'is_starred') {
39
+ return `(CASE WHEN ${noteAlias}.starred_at IS NOT NULL THEN 1 ELSE 0 END)`;
40
+ }
41
+ return `${noteAlias}.${SCALAR_FIELD_COLUMNS[field]}`;
42
+ }
37
43
  export async function buildNoteSearchSqlFilter(options) {
38
44
  const expression = options.expression?.trim() ?? '';
39
45
  if (!expression) {
@@ -527,7 +533,7 @@ function compileComparison(node, noteAlias, params, zettelBoxIdByName) {
527
533
  `;
528
534
  return node.operator === '!=' ? `NOT (${existsSql})` : existsSql;
529
535
  }
530
- const column = `${noteAlias}.${SCALAR_FIELD_COLUMNS[node.field]}`;
536
+ const column = scalarFieldSql(node.field, noteAlias);
531
537
  params.push(node.value);
532
538
  if (node.operator === 'LIKE' || node.operator === 'NOT LIKE') {
533
539
  return `(${column} IS NOT NULL AND ${column} ${node.operator} ?)`;
@@ -586,7 +592,7 @@ function compileInCondition(node, noteAlias, params, zettelBoxIdByName) {
586
592
  if (values.length === 0) {
587
593
  throw new Error('IN list cannot be empty');
588
594
  }
589
- const column = `${noteAlias}.${SCALAR_FIELD_COLUMNS[node.field]}`;
595
+ const column = scalarFieldSql(node.field, noteAlias);
590
596
  const placeholders = values.map(() => '?').join(', ');
591
597
  params.push(...values);
592
598
  if (node.not) {
@@ -1,4 +1,5 @@
1
1
  import { Command } from 'commander';
2
2
  import type { CommandGroupDescriptor } from '../descriptor/types.js';
3
+ export declare function parsePromptTextInput(value: unknown): Promise<string>;
3
4
  export declare const promptCommandGroup: CommandGroupDescriptor;
4
5
  export declare function registerPromptCommands(program: Command): void;