@dinoxx/dinox-cli 1.0.11 → 1.0.12

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.
@@ -61,6 +61,26 @@ async function parseStringListInput(value, optionName) {
61
61
  const raw = await readStringOrFile(value);
62
62
  return parseListInput(raw, optionName);
63
63
  }
64
+ async function parseSearchListInput(value, optionName) {
65
+ const raw = await readStringOrFile(value);
66
+ return {
67
+ values: parseListInput(raw, optionName),
68
+ isExplicitEmptyArray: isExplicitEmptyArrayLiteral(raw),
69
+ };
70
+ }
71
+ function isExplicitEmptyArrayLiteral(value) {
72
+ const normalized = value.trim();
73
+ if (!normalized.startsWith('[')) {
74
+ return false;
75
+ }
76
+ try {
77
+ const parsed = JSON.parse(normalized);
78
+ return Array.isArray(parsed) && parsed.length === 0;
79
+ }
80
+ catch {
81
+ return false;
82
+ }
83
+ }
64
84
  async function parseTargetNoteIds(idArg, idsOption, commandName) {
65
85
  const merged = [];
66
86
  const idFromArg = typeof idArg === 'string' ? idArg.trim() : '';
@@ -91,11 +111,11 @@ export function registerNoteCommands(program) {
91
111
  .command('search')
92
112
  .description('Search notes and return note list for AI agents')
93
113
  .argument('[query]', 'search query')
94
- .option('--tags <expr>', 'tag expression with AND/OR/NOT, e.g. \'(work OR life) AND NOT archived\'')
114
+ .option('--tags <expr>', 'tag expression with AND/OR/NOT, or [] for empty tags')
95
115
  .option('--from <date>', 'created_at start (YYYY-MM-DD or ISO datetime)')
96
116
  .option('--to <date>', 'created_at end (YYYY-MM-DD or ISO datetime)')
97
117
  .option('--days <n>', 'recent N days by created_at (cannot be used with --from/--to)')
98
- .option('--box <string|@file>', 'zettel box names (JSON array or comma/newline-separated)')
118
+ .option('--box <string|@file>', 'zettel box names (JSON array/comma list), or [] for empty boxes')
99
119
  .option('--sql <expr>', 'SQL-like expression over id/content_md/summary/tags/zettel_boxes/created_at/type')
100
120
  .option('--include-deleted', 'include soft-deleted notes')
101
121
  .action(async (query, options, command) => {
@@ -114,8 +134,10 @@ export function registerNoteCommands(program) {
114
134
  days: options.days,
115
135
  });
116
136
  const boxInput = typeof options.box === 'string' ? options.box : '';
117
- const zettelBoxNames = boxInput ? await parseStringListInput(boxInput, '--box') : [];
118
- const zettelBoxIds = await resolveZettelBoxIdsForSearch(db, zettelBoxNames);
137
+ const parsedBoxInput = boxInput
138
+ ? await parseSearchListInput(boxInput, '--box')
139
+ : { values: [], isExplicitEmptyArray: false };
140
+ const zettelBoxIds = await resolveZettelBoxIdsForSearch(db, parsedBoxInput.values);
119
141
  const sqlExpression = typeof options.sql === 'string' ? options.sql.trim() : '';
120
142
  const rows = await searchNotes(db, keyword, {
121
143
  includeDeleted: Boolean(options.includeDeleted),
@@ -123,6 +145,7 @@ export function registerNoteCommands(program) {
123
145
  createdAtFrom,
124
146
  createdAtTo,
125
147
  zettelBoxIds,
148
+ matchEmptyZettelBoxes: parsedBoxInput.isExplicitEmptyArray,
126
149
  sqlExpression: sqlExpression || undefined,
127
150
  });
128
151
  printYaml(rows);
@@ -19,6 +19,7 @@ type SearchNotesOptions = {
19
19
  createdAtFrom?: string;
20
20
  createdAtTo?: string;
21
21
  zettelBoxIds?: string[];
22
+ matchEmptyZettelBoxes?: boolean;
22
23
  sqlExpression?: string;
23
24
  };
24
25
  export type SearchedNote = {
@@ -9,7 +9,7 @@ export async function searchNotes(db, query, options) {
9
9
  const normalizedQuery = query.trim();
10
10
  const tagFilter = buildTagFilter(options.tagsExpression, 'n');
11
11
  const createdAtFilter = buildCreatedAtFilter(options.createdAtFrom, options.createdAtTo, 'n');
12
- const zettelBoxFilter = buildZettelBoxFilter(options.zettelBoxIds, 'n');
12
+ const zettelBoxFilter = buildZettelBoxFilter(options.zettelBoxIds, options.matchEmptyZettelBoxes, 'n');
13
13
  const sqlFilter = await buildNoteSearchSqlFilter({
14
14
  expression: options.sqlExpression,
15
15
  noteAlias: 'n',
@@ -211,6 +211,12 @@ function buildTagFilter(tagsExpression, noteAlias) {
211
211
  if (!expression) {
212
212
  return null;
213
213
  }
214
+ if (isExplicitEmptyArrayLiteral(expression)) {
215
+ return {
216
+ sql: `NOT (${buildJsonArrayHasValuesSql(noteAlias, 'tags', 'tag_item')})`,
217
+ params: [],
218
+ };
219
+ }
214
220
  let ast;
215
221
  try {
216
222
  ast = parseTagExpression(expression);
@@ -242,16 +248,19 @@ function buildCreatedAtFilter(createdAtFrom, createdAtTo, noteAlias) {
242
248
  params,
243
249
  };
244
250
  }
245
- function buildZettelBoxFilter(zettelBoxIds, noteAlias) {
251
+ function buildZettelBoxFilter(zettelBoxIds, matchEmptyZettelBoxes, noteAlias) {
246
252
  const normalizedIds = Array.from(new Set((zettelBoxIds ?? [])
247
253
  .map((id) => id.trim())
248
254
  .filter((id) => id.length > 0)));
249
- if (normalizedIds.length === 0) {
255
+ const shouldMatchEmpty = Boolean(matchEmptyZettelBoxes);
256
+ if (normalizedIds.length === 0 && !shouldMatchEmpty) {
250
257
  return null;
251
258
  }
252
- const placeholders = normalizedIds.map(() => '?').join(', ');
253
- return {
254
- sql: `
259
+ const clauses = [];
260
+ const params = [];
261
+ if (normalizedIds.length > 0) {
262
+ const placeholders = normalizedIds.map(() => '?').join(', ');
263
+ clauses.push(`
255
264
  EXISTS (
256
265
  SELECT 1
257
266
  FROM json_each(
@@ -262,8 +271,18 @@ function buildZettelBoxFilter(zettelBoxIds, noteAlias) {
262
271
  ) box_item
263
272
  WHERE TRIM(CAST(box_item.value AS TEXT)) IN (${placeholders})
264
273
  )
265
- `,
266
- params: normalizedIds,
274
+ `);
275
+ params.push(...normalizedIds);
276
+ }
277
+ if (shouldMatchEmpty) {
278
+ clauses.push(`NOT (${buildJsonArrayHasValuesSql(noteAlias, 'zettel_boxes', 'box_item')})`);
279
+ }
280
+ if (clauses.length === 1) {
281
+ return { sql: clauses[0], params };
282
+ }
283
+ return {
284
+ sql: `(${clauses.join(' OR ')})`,
285
+ params,
267
286
  };
268
287
  }
269
288
  function parseTagExpression(expression) {
@@ -434,6 +453,32 @@ function compileTagExpression(node, noteAlias, params) {
434
453
  function normalizeTagValue(value) {
435
454
  return value.trim().toLowerCase();
436
455
  }
456
+ function isExplicitEmptyArrayLiteral(value) {
457
+ if (!value.startsWith('[')) {
458
+ return false;
459
+ }
460
+ try {
461
+ const parsed = JSON.parse(value);
462
+ return Array.isArray(parsed) && parsed.length === 0;
463
+ }
464
+ catch {
465
+ return false;
466
+ }
467
+ }
468
+ function buildJsonArrayHasValuesSql(noteAlias, field, itemAlias) {
469
+ return `
470
+ EXISTS (
471
+ SELECT 1
472
+ FROM json_each(
473
+ CASE
474
+ WHEN json_valid(COALESCE(${noteAlias}.${field}, '')) THEN ${noteAlias}.${field}
475
+ ELSE '[]'
476
+ END
477
+ ) ${itemAlias}
478
+ WHERE LENGTH(TRIM(CAST(${itemAlias}.value AS TEXT))) > 0
479
+ )
480
+ `;
481
+ }
437
482
  export async function getNote(db, id) {
438
483
  return db.getOptional(`
439
484
  SELECT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dinoxx/dinox-cli",
3
- "version": "1.0.11",
3
+ "version": "1.0.12",
4
4
  "description": "Dinox CLI",
5
5
  "main": "dist/dinox.js",
6
6
  "scripts": {