@dinoxx/dinox-cli 1.0.1 → 1.0.2
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 +232 -6
- package/dist/commands/auth/index.js +7 -1
- package/dist/commands/config/index.d.ts +1 -0
- package/dist/commands/config/index.js +4 -17
- package/dist/commands/notes/index.js +9 -1
- package/dist/commands/notes/repo.d.ts +3 -0
- package/dist/commands/notes/repo.js +64 -4
- package/dist/commands/notes/searchSql.d.ts +11 -0
- package/dist/commands/notes/searchSql.js +599 -0
- package/dist/config/keys.d.ts +3 -3
- package/dist/config/keys.js +1 -4
- package/dist/config/paths.js +2 -1
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -115,6 +115,8 @@ dino auth status
|
|
|
115
115
|
dino note search "AI"
|
|
116
116
|
dino note search "AI" --days 7
|
|
117
117
|
dino note search "AI" --from 2026-02-01 --to 2026-02-28
|
|
118
|
+
dino note search "AI" --box "Inbox"
|
|
119
|
+
dino note search --sql 'type = "crawl" AND zettel_boxes IN ("Inbox","Project")'
|
|
118
120
|
```
|
|
119
121
|
|
|
120
122
|
创建笔记:
|
|
@@ -207,11 +209,6 @@ dino config set sync.timeoutMs 20000
|
|
|
207
209
|
1. macOS: `~/Library/Application Support/dinox/config.json`
|
|
208
210
|
2. Windows: `%APPDATA%\dinox\config.json`
|
|
209
211
|
|
|
210
|
-
本地数据库:
|
|
211
|
-
|
|
212
|
-
1. macOS: `~/Library/Application Support/dinox/powersync.sqlite`
|
|
213
|
-
2. Windows: `%LOCALAPPDATA%\dinox\powersync.sqlite`
|
|
214
|
-
|
|
215
212
|
## 6.2 多行命令写法不同
|
|
216
213
|
|
|
217
214
|
macOS / Linux(bash/zsh)用 `\` 续行:
|
|
@@ -269,7 +266,236 @@ dino box add "你的卡片盒"
|
|
|
269
266
|
|
|
270
267
|
---
|
|
271
268
|
|
|
272
|
-
## 8.
|
|
269
|
+
## 8. 完整命令参数参考
|
|
270
|
+
|
|
271
|
+
## 8.1 全局参数(适用于 CLI)
|
|
272
|
+
|
|
273
|
+
以下参数可放在命令前后使用,例如:`dino --json auth status`。
|
|
274
|
+
|
|
275
|
+
| 参数 | 类型 | 说明 |
|
|
276
|
+
| --- | --- | --- |
|
|
277
|
+
| `--json` | flag | 输出 machine-readable YAML(仅被支持该参数的命令读取)。 |
|
|
278
|
+
| `--offline` | flag | 跳过 connect/sync,仅用本地缓存(仅被支持该参数的命令读取)。 |
|
|
279
|
+
| `--sync-timeout <ms>` | integer | 覆盖连接/同步超时毫秒,必须是正整数。 |
|
|
280
|
+
| `--verbose` | flag | 预留 verbose 开关。 |
|
|
281
|
+
|
|
282
|
+
## 8.2 `auth` 命令
|
|
283
|
+
|
|
284
|
+
### `dino auth login <authorization>`
|
|
285
|
+
|
|
286
|
+
保存授权信息并验证连接。
|
|
287
|
+
|
|
288
|
+
| 参数 | 必填 | 说明 |
|
|
289
|
+
| --- | --- | --- |
|
|
290
|
+
| `<authorization>` | 是 | 完整 Authorization 头值,例如:`"Bearer <token>"` |
|
|
291
|
+
|
|
292
|
+
| 选项 | 必填 | 说明 |
|
|
293
|
+
| --- | --- | --- |
|
|
294
|
+
| `--no-verify` | 否 | 只保存登录信息,不做凭证交换与初始同步验证。 |
|
|
295
|
+
|
|
296
|
+
支持全局参数:`--json`、`--sync-timeout`
|
|
297
|
+
|
|
298
|
+
### `dino auth logout`
|
|
299
|
+
|
|
300
|
+
清理已保存登录信息。
|
|
301
|
+
|
|
302
|
+
| 选项 | 必填 | 说明 |
|
|
303
|
+
| --- | --- | --- |
|
|
304
|
+
| `--clear-local-db` | 否 | 同时删除本地 SQLite 数据库。 |
|
|
305
|
+
|
|
306
|
+
### `dino auth status`
|
|
307
|
+
|
|
308
|
+
查看当前登录与同步状态。
|
|
309
|
+
|
|
310
|
+
支持全局参数:`--json`、`--offline`、`--sync-timeout`
|
|
311
|
+
|
|
312
|
+
## 8.3 `sync` 命令
|
|
313
|
+
|
|
314
|
+
### `dino sync`
|
|
315
|
+
|
|
316
|
+
连接并执行同步。
|
|
317
|
+
|
|
318
|
+
支持全局参数:`--json`、`--sync-timeout`
|
|
319
|
+
|
|
320
|
+
## 8.4 `note` 命令
|
|
321
|
+
|
|
322
|
+
### `dino note search [query]`
|
|
323
|
+
|
|
324
|
+
按关键词/标签/创建时间搜索笔记。
|
|
325
|
+
|
|
326
|
+
| 参数 | 必填 | 说明 |
|
|
327
|
+
| --- | --- | --- |
|
|
328
|
+
| `[query]` | 否 | 搜索关键词。可为空。 |
|
|
329
|
+
|
|
330
|
+
| 选项 | 必填 | 说明 |
|
|
331
|
+
| --- | --- | --- |
|
|
332
|
+
| `--tags <expr>` | 否 | 标签表达式,支持 `AND` / `OR` / `NOT` 与括号。 |
|
|
333
|
+
| `--from <date>` | 否 | 创建时间起点,支持 `YYYY-MM-DD` 或 ISO datetime。 |
|
|
334
|
+
| `--to <date>` | 否 | 创建时间终点,支持 `YYYY-MM-DD` 或 ISO datetime。 |
|
|
335
|
+
| `--days <n>` | 否 | 最近 N 天(按 `created_at`),不能与 `--from/--to` 同时使用。 |
|
|
336
|
+
| `--box <string\|@file>` | 否 | 按卡片盒名称筛选(支持 JSON 数组或逗号/换行分隔);会自动解析为 box id 查询。 |
|
|
337
|
+
| `--sql <expr>` | 否 | SQL 风格条件表达式;仅支持字段 `id/content_md/summary/tags/zettel_boxes/created_at/type`,其中 `zettel_boxes` 按名称自动转 id。只支持只读 WHERE 条件(禁止 `INSERT/UPDATE/DELETE`、注释和多语句)。 |
|
|
338
|
+
| `--include-deleted` | 否 | 包含软删除笔记。 |
|
|
339
|
+
|
|
340
|
+
支持全局参数:`--offline`、`--sync-timeout`
|
|
341
|
+
|
|
342
|
+
### `dino note get <id>`
|
|
343
|
+
|
|
344
|
+
按 ID 获取笔记基础信息。
|
|
345
|
+
|
|
346
|
+
| 参数 | 必填 | 说明 |
|
|
347
|
+
| --- | --- | --- |
|
|
348
|
+
| `<id>` | 是 | 笔记 ID。 |
|
|
349
|
+
|
|
350
|
+
支持全局参数:`--json`、`--offline`、`--sync-timeout`
|
|
351
|
+
|
|
352
|
+
### `dino note detail <id>`
|
|
353
|
+
|
|
354
|
+
按 ID 获取笔记完整详情。
|
|
355
|
+
|
|
356
|
+
| 参数 | 必填 | 说明 |
|
|
357
|
+
| --- | --- | --- |
|
|
358
|
+
| `<id>` | 是 | 笔记 ID。 |
|
|
359
|
+
|
|
360
|
+
支持全局参数:`--offline`、`--sync-timeout`
|
|
361
|
+
|
|
362
|
+
### `dino note create --title <string> --content <string|@file> [options]`
|
|
363
|
+
|
|
364
|
+
创建笔记。
|
|
365
|
+
|
|
366
|
+
| 选项 | 必填 | 说明 |
|
|
367
|
+
| --- | --- | --- |
|
|
368
|
+
| `--title <string>` | 是 | 笔记标题。 |
|
|
369
|
+
| `--content <string|@file>` | 是 | Markdown 内容,可传 `@文件路径`。 |
|
|
370
|
+
| `--type <note\|crawl>` | 否 | 笔记类型,默认 `crawl`。 |
|
|
371
|
+
| `--tags <string\|@file>` | 否 | 标签列表(JSON 数组或逗号/换行分隔)。 |
|
|
372
|
+
| `--zettel_boxes <string\|@file>` | 否 | 卡片盒名称列表(JSON 数组或逗号/换行分隔)。 |
|
|
373
|
+
|
|
374
|
+
支持全局参数:`--json`、`--offline`、`--sync-timeout`
|
|
375
|
+
|
|
376
|
+
### `dino note delete <id>`
|
|
377
|
+
|
|
378
|
+
软删除笔记(`is_del=1`)。
|
|
379
|
+
|
|
380
|
+
| 参数 | 必填 | 说明 |
|
|
381
|
+
| --- | --- | --- |
|
|
382
|
+
| `<id>` | 是 | 笔记 ID。 |
|
|
383
|
+
|
|
384
|
+
支持全局参数:`--json`、`--offline`、`--sync-timeout`
|
|
385
|
+
|
|
386
|
+
## 8.5 `tag` 命令
|
|
387
|
+
|
|
388
|
+
### `dino tag list`
|
|
389
|
+
|
|
390
|
+
列出标签。
|
|
391
|
+
|
|
392
|
+
| 选项 | 必填 | 说明 |
|
|
393
|
+
| --- | --- | --- |
|
|
394
|
+
| `--json` | 否 | 输出 machine-readable YAML。 |
|
|
395
|
+
| `--offline` | 否 | 跳过 connect/sync,仅用本地缓存。 |
|
|
396
|
+
| `--sync-timeout <ms>` | 否 | 覆盖连接/同步超时毫秒。 |
|
|
397
|
+
|
|
398
|
+
### `dino tag add [name] [options]`
|
|
399
|
+
|
|
400
|
+
新增标签。
|
|
401
|
+
|
|
402
|
+
| 参数 | 必填 | 说明 |
|
|
403
|
+
| --- | --- | --- |
|
|
404
|
+
| `[name]` | 否 | 标签名/路径(支持斜杠层级)。 |
|
|
405
|
+
|
|
406
|
+
| 选项 | 必填 | 说明 |
|
|
407
|
+
| --- | --- | --- |
|
|
408
|
+
| `--name <string>` | 否 | 标签名/路径,可替代位置参数 `[name]`。 |
|
|
409
|
+
| `--emoji <string>` | 否 | 标签 emoji。 |
|
|
410
|
+
| `--json` | 否 | 输出 machine-readable YAML。 |
|
|
411
|
+
| `--offline` | 否 | 跳过 connect/sync,仅用本地缓存。 |
|
|
412
|
+
| `--sync-timeout <ms>` | 否 | 覆盖连接/同步超时毫秒。 |
|
|
413
|
+
|
|
414
|
+
说明:`[name]` 与 `--name` 至少提供一个;若两者都提供且值不同会报错。
|
|
415
|
+
|
|
416
|
+
## 8.6 `box` 命令
|
|
417
|
+
|
|
418
|
+
### `dino box list`
|
|
419
|
+
|
|
420
|
+
列出卡片盒。
|
|
421
|
+
|
|
422
|
+
| 选项 | 必填 | 说明 |
|
|
423
|
+
| --- | --- | --- |
|
|
424
|
+
| `--offline` | 否 | 跳过 connect/sync,仅用本地缓存。 |
|
|
425
|
+
| `--sync-timeout <ms>` | 否 | 覆盖连接/同步超时毫秒。 |
|
|
426
|
+
|
|
427
|
+
### `dino box add [name] [options]`
|
|
428
|
+
|
|
429
|
+
新增卡片盒。
|
|
430
|
+
|
|
431
|
+
| 参数 | 必填 | 说明 |
|
|
432
|
+
| --- | --- | --- |
|
|
433
|
+
| `[name]` | 否 | 卡片盒名称。 |
|
|
434
|
+
|
|
435
|
+
| 选项 | 必填 | 说明 |
|
|
436
|
+
| --- | --- | --- |
|
|
437
|
+
| `--name <string>` | 否 | 卡片盒名称,可替代位置参数 `[name]`。 |
|
|
438
|
+
| `--description <string>` | 否 | 用途说明(可帮助 AI 路由笔记)。 |
|
|
439
|
+
| `--color <string>` | 否 | 卡片盒颜色。 |
|
|
440
|
+
| `--json` | 否 | 输出 machine-readable YAML。 |
|
|
441
|
+
| `--offline` | 否 | 跳过 connect/sync,仅用本地缓存。 |
|
|
442
|
+
| `--sync-timeout <ms>` | 否 | 覆盖连接/同步超时毫秒。 |
|
|
443
|
+
|
|
444
|
+
说明:`[name]` 与 `--name` 至少提供一个;若两者都提供且值不同会报错。
|
|
445
|
+
|
|
446
|
+
## 8.7 `prompt` 命令
|
|
447
|
+
|
|
448
|
+
### `dino prompt list`
|
|
449
|
+
|
|
450
|
+
列出 Prompt(`name` 与 `cmd`)。
|
|
451
|
+
|
|
452
|
+
| 选项 | 必填 | 说明 |
|
|
453
|
+
| --- | --- | --- |
|
|
454
|
+
| `--offline` | 否 | 跳过 connect/sync,仅用本地缓存。 |
|
|
455
|
+
| `--sync-timeout <ms>` | 否 | 覆盖连接/同步超时毫秒。 |
|
|
456
|
+
|
|
457
|
+
## 8.8 `config` 命令
|
|
458
|
+
|
|
459
|
+
### `dino config get [key]`
|
|
460
|
+
|
|
461
|
+
读取配置(输出 YAML)。
|
|
462
|
+
|
|
463
|
+
| 参数 | 必填 | 说明 |
|
|
464
|
+
| --- | --- | --- |
|
|
465
|
+
| `[key]` | 否 | 配置键(点路径);不传则返回全部配置。 |
|
|
466
|
+
|
|
467
|
+
可读 key:
|
|
468
|
+
|
|
469
|
+
```text
|
|
470
|
+
sync.timeoutMs
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
### `dino config set <key> <value>`
|
|
474
|
+
|
|
475
|
+
写入配置。
|
|
476
|
+
|
|
477
|
+
| 参数 | 必填 | 说明 |
|
|
478
|
+
| --- | --- | --- |
|
|
479
|
+
| `<key>` | 是 | 配置键(点路径)。 |
|
|
480
|
+
| `<value>` | 是 | 配置值。 |
|
|
481
|
+
|
|
482
|
+
可写 key:
|
|
483
|
+
|
|
484
|
+
```text
|
|
485
|
+
sync.timeoutMs (必须为正整数)
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
## 8.9 `info` 命令
|
|
489
|
+
|
|
490
|
+
### `dino info`
|
|
491
|
+
|
|
492
|
+
显示 CLI 版本信息。
|
|
493
|
+
|
|
494
|
+
支持全局参数:`--json`
|
|
495
|
+
|
|
496
|
+
---
|
|
497
|
+
|
|
498
|
+
## 9. 查看帮助
|
|
273
499
|
|
|
274
500
|
随时可以看帮助:
|
|
275
501
|
|
|
@@ -18,6 +18,12 @@ function parseSyncTimeoutMs(value) {
|
|
|
18
18
|
}
|
|
19
19
|
return Math.trunc(parsed);
|
|
20
20
|
}
|
|
21
|
+
async function removeSqliteArtifacts(dbPath) {
|
|
22
|
+
const artifacts = ['', '-wal', '-shm'];
|
|
23
|
+
for (const suffix of artifacts) {
|
|
24
|
+
await fs.rm(`${dbPath}${suffix}`, { force: true }).catch(() => undefined);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
21
27
|
export function registerAuthCommands(program) {
|
|
22
28
|
const auth = program
|
|
23
29
|
.command('auth')
|
|
@@ -110,7 +116,7 @@ export function registerAuthCommands(program) {
|
|
|
110
116
|
await saveConfig(config);
|
|
111
117
|
if (options.clearLocalDb) {
|
|
112
118
|
const dbPath = getPowerSyncDbPath();
|
|
113
|
-
await
|
|
119
|
+
await removeSqliteArtifacts(dbPath);
|
|
114
120
|
}
|
|
115
121
|
});
|
|
116
122
|
auth
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { isConfigKey, isFixedConfigKey, isReadableConfigKey } from '../../config/keys.js';
|
|
2
|
-
import { getConfigValue,
|
|
3
|
-
import { resolveConfig } from '../../config/resolve.js';
|
|
2
|
+
import { getConfigValue, setConfigValue } from '../../config/store.js';
|
|
4
3
|
import { redactAuthorization } from '../../utils/redact.js';
|
|
5
4
|
import { DinoxError } from '../../utils/errors.js';
|
|
6
5
|
import { printYaml } from '../../utils/output.js';
|
|
7
|
-
function
|
|
6
|
+
export function sanitizeConfigForDisplay(config) {
|
|
8
7
|
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
9
8
|
return config;
|
|
10
9
|
}
|
|
@@ -13,16 +12,9 @@ function redactConfigForDisplay(config) {
|
|
|
13
12
|
if (auth && typeof auth === 'object') {
|
|
14
13
|
auth.authorization = redactAuthorization(auth.authorization);
|
|
15
14
|
}
|
|
15
|
+
delete clone.powersync;
|
|
16
16
|
return clone;
|
|
17
17
|
}
|
|
18
|
-
function getByPath(value, keyPath) {
|
|
19
|
-
return keyPath.split('.').reduce((current, segment) => {
|
|
20
|
-
if (!current || typeof current !== 'object') {
|
|
21
|
-
return undefined;
|
|
22
|
-
}
|
|
23
|
-
return current[segment];
|
|
24
|
-
}, value);
|
|
25
|
-
}
|
|
26
18
|
export function registerConfigCommands(program) {
|
|
27
19
|
const configCmd = program.command('config').description('Read and write Dinox configuration');
|
|
28
20
|
configCmd
|
|
@@ -33,13 +25,8 @@ export function registerConfigCommands(program) {
|
|
|
33
25
|
if (key && !isReadableConfigKey(key)) {
|
|
34
26
|
throw new DinoxError(`Unknown config key: ${key}`);
|
|
35
27
|
}
|
|
36
|
-
if (key && isFixedConfigKey(key)) {
|
|
37
|
-
const resolved = resolveConfig(await loadConfig());
|
|
38
|
-
printYaml(getByPath(resolved, key));
|
|
39
|
-
return;
|
|
40
|
-
}
|
|
41
28
|
const value = await getConfigValue(key);
|
|
42
|
-
const redacted = key ? value :
|
|
29
|
+
const redacted = key ? value : sanitizeConfigForDisplay(value);
|
|
43
30
|
printYaml(redacted);
|
|
44
31
|
});
|
|
45
32
|
configCmd
|
|
@@ -8,7 +8,7 @@ import { printYaml } from '../../utils/output.js';
|
|
|
8
8
|
import { resolveAuthIdentity } from '../../auth/userInfo.js';
|
|
9
9
|
import { connectPowerSync, waitForIdleDataFlow } from '../../powersync/runtime.js';
|
|
10
10
|
import { syncNoteTokenIndex } from '../../powersync/tokenIndex.js';
|
|
11
|
-
import { createNote, getNote, getNoteDetail, resolveZettelBoxIdsForCreate, searchNotes, softDeleteNote, validateTagsForCreate, } from './repo.js';
|
|
11
|
+
import { createNote, getNote, getNoteDetail, resolveZettelBoxIdsForCreate, resolveZettelBoxIdsForSearch, searchNotes, softDeleteNote, validateTagsForCreate, } from './repo.js';
|
|
12
12
|
import { parseCreatedAtRange } from './searchTime.js';
|
|
13
13
|
function parseSyncTimeoutMs(value) {
|
|
14
14
|
if (typeof value !== 'string') {
|
|
@@ -78,6 +78,8 @@ export function registerNoteCommands(program) {
|
|
|
78
78
|
.option('--from <date>', 'created_at start (YYYY-MM-DD or ISO datetime)')
|
|
79
79
|
.option('--to <date>', 'created_at end (YYYY-MM-DD or ISO datetime)')
|
|
80
80
|
.option('--days <n>', 'recent N days by created_at (cannot be used with --from/--to)')
|
|
81
|
+
.option('--box <string|@file>', 'zettel box names (JSON array or comma/newline-separated)')
|
|
82
|
+
.option('--sql <expr>', 'SQL-like expression over id/content_md/summary/tags/zettel_boxes/created_at/type')
|
|
81
83
|
.option('--include-deleted', 'include soft-deleted notes')
|
|
82
84
|
.action(async (query, options, command) => {
|
|
83
85
|
const globals = command.optsWithGlobals?.() ?? {};
|
|
@@ -94,11 +96,17 @@ export function registerNoteCommands(program) {
|
|
|
94
96
|
to: options.to,
|
|
95
97
|
days: options.days,
|
|
96
98
|
});
|
|
99
|
+
const boxInput = typeof options.box === 'string' ? options.box : '';
|
|
100
|
+
const zettelBoxNames = boxInput ? await parseStringListInput(boxInput, '--box') : [];
|
|
101
|
+
const zettelBoxIds = await resolveZettelBoxIdsForSearch(db, zettelBoxNames);
|
|
102
|
+
const sqlExpression = typeof options.sql === 'string' ? options.sql.trim() : '';
|
|
97
103
|
const rows = await searchNotes(db, keyword, {
|
|
98
104
|
includeDeleted: Boolean(options.includeDeleted),
|
|
99
105
|
tagsExpression: tagsExpression || undefined,
|
|
100
106
|
createdAtFrom,
|
|
101
107
|
createdAtTo,
|
|
108
|
+
zettelBoxIds,
|
|
109
|
+
sqlExpression: sqlExpression || undefined,
|
|
102
110
|
});
|
|
103
111
|
printYaml(rows);
|
|
104
112
|
}
|
|
@@ -18,6 +18,8 @@ type SearchNotesOptions = {
|
|
|
18
18
|
tagsExpression?: string;
|
|
19
19
|
createdAtFrom?: string;
|
|
20
20
|
createdAtTo?: string;
|
|
21
|
+
zettelBoxIds?: string[];
|
|
22
|
+
sqlExpression?: string;
|
|
21
23
|
};
|
|
22
24
|
export type SearchedNote = {
|
|
23
25
|
id: string;
|
|
@@ -66,5 +68,6 @@ export declare function createNote(db: AbstractPowerSyncDatabase, input: {
|
|
|
66
68
|
}>;
|
|
67
69
|
export declare function validateTagsForCreate(db: AbstractPowerSyncDatabase, requestedTags: string[]): Promise<string[]>;
|
|
68
70
|
export declare function resolveZettelBoxIdsForCreate(db: AbstractPowerSyncDatabase, requestedNames: string[]): Promise<string[]>;
|
|
71
|
+
export declare function resolveZettelBoxIdsForSearch(db: AbstractPowerSyncDatabase, requestedNames: string[]): Promise<string[]>;
|
|
69
72
|
export declare function softDeleteNote(db: AbstractPowerSyncDatabase, id: string): Promise<void>;
|
|
70
73
|
export {};
|
|
@@ -3,16 +3,23 @@ import { nowIsoUtc } from '../../utils/time.js';
|
|
|
3
3
|
import { tokenizeWithJieba } from '../../utils/tokenize.js';
|
|
4
4
|
import { listBoxes } from '../boxes/repo.js';
|
|
5
5
|
import { listTags } from '../tags/repo.js';
|
|
6
|
+
import { buildNoteSearchSqlFilter } from './searchSql.js';
|
|
6
7
|
export async function searchNotes(db, query, options) {
|
|
7
8
|
const normalizedQuery = query.trim();
|
|
8
9
|
const tagFilter = buildTagFilter(options.tagsExpression, 'n');
|
|
9
10
|
const createdAtFilter = buildCreatedAtFilter(options.createdAtFrom, options.createdAtTo, 'n');
|
|
11
|
+
const zettelBoxFilter = buildZettelBoxFilter(options.zettelBoxIds, 'n');
|
|
12
|
+
const sqlFilter = await buildNoteSearchSqlFilter({
|
|
13
|
+
expression: options.sqlExpression,
|
|
14
|
+
noteAlias: 'n',
|
|
15
|
+
resolveZettelBoxNames: (names) => resolveZettelBoxIdsForSearch(db, names),
|
|
16
|
+
});
|
|
10
17
|
let rows = [];
|
|
11
18
|
if (!options.includeDeleted) {
|
|
12
19
|
const ftsMatch = buildFtsMatchQuery(normalizedQuery);
|
|
13
20
|
if (ftsMatch) {
|
|
14
21
|
try {
|
|
15
|
-
rows = await searchNotesWithFts(db, ftsMatch, tagFilter, createdAtFilter);
|
|
22
|
+
rows = await searchNotesWithFts(db, ftsMatch, tagFilter, createdAtFilter, zettelBoxFilter, sqlFilter);
|
|
16
23
|
return enrichSearchRows(db, rows);
|
|
17
24
|
}
|
|
18
25
|
catch (error) {
|
|
@@ -53,6 +60,14 @@ export async function searchNotes(db, query, options) {
|
|
|
53
60
|
whereClauses.push(createdAtFilter.sql);
|
|
54
61
|
params.push(...createdAtFilter.params);
|
|
55
62
|
}
|
|
63
|
+
if (zettelBoxFilter) {
|
|
64
|
+
whereClauses.push(zettelBoxFilter.sql);
|
|
65
|
+
params.push(...zettelBoxFilter.params);
|
|
66
|
+
}
|
|
67
|
+
if (sqlFilter) {
|
|
68
|
+
whereClauses.push(sqlFilter.sql);
|
|
69
|
+
params.push(...sqlFilter.params);
|
|
70
|
+
}
|
|
56
71
|
rows = await db.getAll(`
|
|
57
72
|
SELECT
|
|
58
73
|
n.id,
|
|
@@ -69,7 +84,7 @@ export async function searchNotes(db, query, options) {
|
|
|
69
84
|
`, params);
|
|
70
85
|
return enrichSearchRows(db, rows);
|
|
71
86
|
}
|
|
72
|
-
async function searchNotesWithFts(db, matchQuery, tagFilter, createdAtFilter) {
|
|
87
|
+
async function searchNotesWithFts(db, matchQuery, tagFilter, createdAtFilter, zettelBoxFilter, sqlFilter) {
|
|
73
88
|
const whereClauses = ['(n.is_del IS NULL OR n.is_del = 0)', 'note_local_fts MATCH ?'];
|
|
74
89
|
const params = [matchQuery];
|
|
75
90
|
if (tagFilter) {
|
|
@@ -80,6 +95,14 @@ async function searchNotesWithFts(db, matchQuery, tagFilter, createdAtFilter) {
|
|
|
80
95
|
whereClauses.push(createdAtFilter.sql);
|
|
81
96
|
params.push(...createdAtFilter.params);
|
|
82
97
|
}
|
|
98
|
+
if (zettelBoxFilter) {
|
|
99
|
+
whereClauses.push(zettelBoxFilter.sql);
|
|
100
|
+
params.push(...zettelBoxFilter.params);
|
|
101
|
+
}
|
|
102
|
+
if (sqlFilter) {
|
|
103
|
+
whereClauses.push(sqlFilter.sql);
|
|
104
|
+
params.push(...sqlFilter.params);
|
|
105
|
+
}
|
|
83
106
|
return db.getAll(`
|
|
84
107
|
SELECT
|
|
85
108
|
n.id,
|
|
@@ -214,6 +237,30 @@ function buildCreatedAtFilter(createdAtFrom, createdAtTo, noteAlias) {
|
|
|
214
237
|
params,
|
|
215
238
|
};
|
|
216
239
|
}
|
|
240
|
+
function buildZettelBoxFilter(zettelBoxIds, noteAlias) {
|
|
241
|
+
const normalizedIds = Array.from(new Set((zettelBoxIds ?? [])
|
|
242
|
+
.map((id) => id.trim())
|
|
243
|
+
.filter((id) => id.length > 0)));
|
|
244
|
+
if (normalizedIds.length === 0) {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
const placeholders = normalizedIds.map(() => '?').join(', ');
|
|
248
|
+
return {
|
|
249
|
+
sql: `
|
|
250
|
+
EXISTS (
|
|
251
|
+
SELECT 1
|
|
252
|
+
FROM json_each(
|
|
253
|
+
CASE
|
|
254
|
+
WHEN json_valid(COALESCE(${noteAlias}.zettel_boxes, '')) THEN ${noteAlias}.zettel_boxes
|
|
255
|
+
ELSE '[]'
|
|
256
|
+
END
|
|
257
|
+
) box_item
|
|
258
|
+
WHERE TRIM(CAST(box_item.value AS TEXT)) IN (${placeholders})
|
|
259
|
+
)
|
|
260
|
+
`,
|
|
261
|
+
params: normalizedIds,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
217
264
|
function parseTagExpression(expression) {
|
|
218
265
|
const tokens = tokenizeTagExpression(expression);
|
|
219
266
|
if (tokens.length === 0) {
|
|
@@ -537,6 +584,13 @@ export async function createNote(db, input) {
|
|
|
537
584
|
function normalizeLookupKey(value) {
|
|
538
585
|
return value.trim().toLowerCase();
|
|
539
586
|
}
|
|
587
|
+
function buildZettelBoxRetryQuestion(intent, type) {
|
|
588
|
+
const command = intent === 'search' ? 'dino note search' : 'dino note create';
|
|
589
|
+
if (type === 'ambiguous') {
|
|
590
|
+
return `Do you want to specify unique box names (or IDs) and retry \`${command}\`?`;
|
|
591
|
+
}
|
|
592
|
+
return `Do you want to add these missing boxes and retry \`${command}\`?`;
|
|
593
|
+
}
|
|
540
594
|
export async function validateTagsForCreate(db, requestedTags) {
|
|
541
595
|
if (requestedTags.length === 0) {
|
|
542
596
|
return [];
|
|
@@ -580,6 +634,12 @@ export async function validateTagsForCreate(db, requestedTags) {
|
|
|
580
634
|
return resolved;
|
|
581
635
|
}
|
|
582
636
|
export async function resolveZettelBoxIdsForCreate(db, requestedNames) {
|
|
637
|
+
return resolveZettelBoxIdsByName(db, requestedNames, 'create');
|
|
638
|
+
}
|
|
639
|
+
export async function resolveZettelBoxIdsForSearch(db, requestedNames) {
|
|
640
|
+
return resolveZettelBoxIdsByName(db, requestedNames, 'search');
|
|
641
|
+
}
|
|
642
|
+
async function resolveZettelBoxIdsByName(db, requestedNames, intent) {
|
|
583
643
|
if (requestedNames.length === 0) {
|
|
584
644
|
return [];
|
|
585
645
|
}
|
|
@@ -642,7 +702,7 @@ export async function resolveZettelBoxIdsForCreate(db, requestedNames) {
|
|
|
642
702
|
type: 'ambiguous_zettel_boxes',
|
|
643
703
|
ambiguous,
|
|
644
704
|
availableNamesSample: availableNames.slice(0, 20),
|
|
645
|
-
question:
|
|
705
|
+
question: buildZettelBoxRetryQuestion(intent, 'ambiguous'),
|
|
646
706
|
},
|
|
647
707
|
});
|
|
648
708
|
}
|
|
@@ -653,7 +713,7 @@ export async function resolveZettelBoxIdsForCreate(db, requestedNames) {
|
|
|
653
713
|
missing,
|
|
654
714
|
availableCount: availableNames.length,
|
|
655
715
|
availableNamesSample: availableNames.slice(0, 20),
|
|
656
|
-
question: '
|
|
716
|
+
question: buildZettelBoxRetryQuestion(intent, 'missing'),
|
|
657
717
|
},
|
|
658
718
|
});
|
|
659
719
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type SqlFilter = {
|
|
2
|
+
sql: string;
|
|
3
|
+
params: string[];
|
|
4
|
+
};
|
|
5
|
+
type BuildNoteSearchSqlFilterOptions = {
|
|
6
|
+
expression?: string;
|
|
7
|
+
noteAlias: string;
|
|
8
|
+
resolveZettelBoxNames: (names: string[]) => Promise<string[]>;
|
|
9
|
+
};
|
|
10
|
+
export declare function buildNoteSearchSqlFilter(options: BuildNoteSearchSqlFilterOptions): Promise<SqlFilter | null>;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
import { DinoxError } from '../../utils/errors.js';
|
|
2
|
+
const SEARCH_FIELDS = new Set([
|
|
3
|
+
'id',
|
|
4
|
+
'content_md',
|
|
5
|
+
'summary',
|
|
6
|
+
'tags',
|
|
7
|
+
'zettel_boxes',
|
|
8
|
+
'created_at',
|
|
9
|
+
'type',
|
|
10
|
+
]);
|
|
11
|
+
const FORBIDDEN_SQL_KEYWORDS = [
|
|
12
|
+
'insert',
|
|
13
|
+
'update',
|
|
14
|
+
'delete',
|
|
15
|
+
'drop',
|
|
16
|
+
'alter',
|
|
17
|
+
'create',
|
|
18
|
+
'replace',
|
|
19
|
+
'truncate',
|
|
20
|
+
'attach',
|
|
21
|
+
'detach',
|
|
22
|
+
'pragma',
|
|
23
|
+
'vacuum',
|
|
24
|
+
'begin',
|
|
25
|
+
'commit',
|
|
26
|
+
'rollback',
|
|
27
|
+
];
|
|
28
|
+
const SCALAR_FIELD_COLUMNS = {
|
|
29
|
+
id: 'id',
|
|
30
|
+
content_md: 'content_md',
|
|
31
|
+
summary: 'summary',
|
|
32
|
+
created_at: 'created_at',
|
|
33
|
+
type: 'type',
|
|
34
|
+
};
|
|
35
|
+
export async function buildNoteSearchSqlFilter(options) {
|
|
36
|
+
const expression = options.expression?.trim() ?? '';
|
|
37
|
+
if (!expression) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
assertReadOnlySearchExpression(expression);
|
|
42
|
+
const ast = parseSearchSqlExpression(expression);
|
|
43
|
+
const zettelBoxIdByName = await buildZettelBoxIdLookup(ast, options.resolveZettelBoxNames);
|
|
44
|
+
const params = [];
|
|
45
|
+
const sql = compileNode(ast, options.noteAlias, params, zettelBoxIdByName);
|
|
46
|
+
return {
|
|
47
|
+
sql: `(${sql})`,
|
|
48
|
+
params,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (error instanceof DinoxError) {
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
56
|
+
throw new DinoxError(`Invalid --sql expression: ${message}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function assertReadOnlySearchExpression(expression) {
|
|
60
|
+
const unquoted = maskQuotedSegments(expression).toLowerCase();
|
|
61
|
+
if (unquoted.includes(';')) {
|
|
62
|
+
throw new DinoxError('Invalid --sql expression: only read-only WHERE-style conditions are allowed (semicolon is not permitted)');
|
|
63
|
+
}
|
|
64
|
+
if (unquoted.includes('--') || unquoted.includes('/*') || unquoted.includes('*/')) {
|
|
65
|
+
throw new DinoxError('Invalid --sql expression: SQL comments are not allowed; only read-only WHERE-style conditions are supported');
|
|
66
|
+
}
|
|
67
|
+
for (const keyword of FORBIDDEN_SQL_KEYWORDS) {
|
|
68
|
+
const pattern = new RegExp(`\\b${keyword}\\b`, 'i');
|
|
69
|
+
if (pattern.test(unquoted)) {
|
|
70
|
+
throw new DinoxError(`Invalid --sql expression: keyword '${keyword.toUpperCase()}' is not allowed (read-only search only)`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function maskQuotedSegments(expression) {
|
|
75
|
+
let result = '';
|
|
76
|
+
let quote = null;
|
|
77
|
+
for (let i = 0; i < expression.length; i += 1) {
|
|
78
|
+
const ch = expression[i];
|
|
79
|
+
if (!quote) {
|
|
80
|
+
if (ch === '"' || ch === "'") {
|
|
81
|
+
quote = ch;
|
|
82
|
+
result += ' ';
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
result += ch;
|
|
86
|
+
}
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
result += ' ';
|
|
90
|
+
if (ch === '\\') {
|
|
91
|
+
if (i + 1 < expression.length) {
|
|
92
|
+
result += ' ';
|
|
93
|
+
i += 1;
|
|
94
|
+
}
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (ch !== quote) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (expression[i + 1] === quote) {
|
|
101
|
+
result += ' ';
|
|
102
|
+
i += 1;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
quote = null;
|
|
106
|
+
}
|
|
107
|
+
return result;
|
|
108
|
+
}
|
|
109
|
+
function parseSearchSqlExpression(expression) {
|
|
110
|
+
const tokens = tokenizeSearchSqlExpression(expression);
|
|
111
|
+
if (tokens.length === 0) {
|
|
112
|
+
throw new Error('Expression is empty');
|
|
113
|
+
}
|
|
114
|
+
let index = 0;
|
|
115
|
+
const peek = () => tokens[index] ?? null;
|
|
116
|
+
const consume = (expected) => {
|
|
117
|
+
const token = peek();
|
|
118
|
+
if (!token) {
|
|
119
|
+
throw new Error('Unexpected end of expression');
|
|
120
|
+
}
|
|
121
|
+
if (expected && token.type !== expected) {
|
|
122
|
+
throw new Error(`Expected ${expected} at position ${token.position + 1}`);
|
|
123
|
+
}
|
|
124
|
+
index += 1;
|
|
125
|
+
return token;
|
|
126
|
+
};
|
|
127
|
+
const match = (type) => {
|
|
128
|
+
const token = peek();
|
|
129
|
+
if (!token || token.type !== type) {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
index += 1;
|
|
133
|
+
return true;
|
|
134
|
+
};
|
|
135
|
+
const parseValue = () => {
|
|
136
|
+
const token = peek();
|
|
137
|
+
if (!token) {
|
|
138
|
+
throw new Error('Unexpected end of expression while reading a value');
|
|
139
|
+
}
|
|
140
|
+
if (token.type !== 'WORD' && token.type !== 'STRING') {
|
|
141
|
+
throw new Error(`Expected value at position ${token.position + 1}`);
|
|
142
|
+
}
|
|
143
|
+
consume(token.type);
|
|
144
|
+
const value = token.value.trim();
|
|
145
|
+
if (!value) {
|
|
146
|
+
throw new Error(`Empty value at position ${token.position + 1}`);
|
|
147
|
+
}
|
|
148
|
+
return value;
|
|
149
|
+
};
|
|
150
|
+
const parseValueList = () => {
|
|
151
|
+
const open = consume('LPAREN');
|
|
152
|
+
const values = [];
|
|
153
|
+
while (true) {
|
|
154
|
+
values.push(parseValue());
|
|
155
|
+
if (match('COMMA')) {
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (match('RPAREN')) {
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
const next = peek();
|
|
162
|
+
const nextPos = next ? next.position + 1 : open.position + 1;
|
|
163
|
+
throw new Error(`Expected ',' or ')' at position ${nextPos}`);
|
|
164
|
+
}
|
|
165
|
+
if (values.length === 0) {
|
|
166
|
+
throw new Error(`IN list cannot be empty at position ${open.position + 1}`);
|
|
167
|
+
}
|
|
168
|
+
return values;
|
|
169
|
+
};
|
|
170
|
+
const parseCondition = () => {
|
|
171
|
+
const fieldToken = peek();
|
|
172
|
+
if (!fieldToken) {
|
|
173
|
+
throw new Error('Unexpected end of expression');
|
|
174
|
+
}
|
|
175
|
+
if (fieldToken.type !== 'WORD') {
|
|
176
|
+
throw new Error(`Expected field name at position ${fieldToken.position + 1}`);
|
|
177
|
+
}
|
|
178
|
+
consume('WORD');
|
|
179
|
+
const field = normalizeSearchField(fieldToken.value);
|
|
180
|
+
if (!field) {
|
|
181
|
+
throw new Error(`Unsupported field '${fieldToken.value}' at position ${fieldToken.position + 1}`);
|
|
182
|
+
}
|
|
183
|
+
if (match('NOT')) {
|
|
184
|
+
if (match('IN')) {
|
|
185
|
+
return {
|
|
186
|
+
kind: 'in',
|
|
187
|
+
field,
|
|
188
|
+
not: true,
|
|
189
|
+
values: parseValueList(),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
if (match('LIKE')) {
|
|
193
|
+
return {
|
|
194
|
+
kind: 'comparison',
|
|
195
|
+
field,
|
|
196
|
+
operator: 'NOT LIKE',
|
|
197
|
+
value: parseValue(),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
const token = peek();
|
|
201
|
+
const pos = token ? token.position + 1 : fieldToken.position + 1;
|
|
202
|
+
throw new Error(`Expected IN or LIKE after NOT at position ${pos}`);
|
|
203
|
+
}
|
|
204
|
+
if (match('IN')) {
|
|
205
|
+
return {
|
|
206
|
+
kind: 'in',
|
|
207
|
+
field,
|
|
208
|
+
not: false,
|
|
209
|
+
values: parseValueList(),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
if (match('LIKE')) {
|
|
213
|
+
return {
|
|
214
|
+
kind: 'comparison',
|
|
215
|
+
field,
|
|
216
|
+
operator: 'LIKE',
|
|
217
|
+
value: parseValue(),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
const operatorToken = peek();
|
|
221
|
+
if (!operatorToken || operatorToken.type !== 'OP') {
|
|
222
|
+
const pos = operatorToken ? operatorToken.position + 1 : fieldToken.position + 1;
|
|
223
|
+
throw new Error(`Expected comparison operator at position ${pos}`);
|
|
224
|
+
}
|
|
225
|
+
consume('OP');
|
|
226
|
+
return {
|
|
227
|
+
kind: 'comparison',
|
|
228
|
+
field,
|
|
229
|
+
operator: normalizeComparisonOperator(operatorToken.value, operatorToken.position),
|
|
230
|
+
value: parseValue(),
|
|
231
|
+
};
|
|
232
|
+
};
|
|
233
|
+
const parsePrimary = () => {
|
|
234
|
+
const token = peek();
|
|
235
|
+
if (!token) {
|
|
236
|
+
throw new Error('Unexpected end of expression');
|
|
237
|
+
}
|
|
238
|
+
if (token.type === 'LPAREN') {
|
|
239
|
+
consume('LPAREN');
|
|
240
|
+
const node = parseOr();
|
|
241
|
+
const close = peek();
|
|
242
|
+
if (!close || close.type !== 'RPAREN') {
|
|
243
|
+
throw new Error(`Missing ')' for '(' at position ${token.position + 1}`);
|
|
244
|
+
}
|
|
245
|
+
consume('RPAREN');
|
|
246
|
+
return node;
|
|
247
|
+
}
|
|
248
|
+
return parseCondition();
|
|
249
|
+
};
|
|
250
|
+
const parseUnary = () => {
|
|
251
|
+
if (match('NOT')) {
|
|
252
|
+
return { kind: 'not', child: parseUnary() };
|
|
253
|
+
}
|
|
254
|
+
return parsePrimary();
|
|
255
|
+
};
|
|
256
|
+
const parseAnd = () => {
|
|
257
|
+
let node = parseUnary();
|
|
258
|
+
while (match('AND')) {
|
|
259
|
+
node = { kind: 'and', left: node, right: parseUnary() };
|
|
260
|
+
}
|
|
261
|
+
return node;
|
|
262
|
+
};
|
|
263
|
+
const parseOr = () => {
|
|
264
|
+
let node = parseAnd();
|
|
265
|
+
while (match('OR')) {
|
|
266
|
+
node = { kind: 'or', left: node, right: parseAnd() };
|
|
267
|
+
}
|
|
268
|
+
return node;
|
|
269
|
+
};
|
|
270
|
+
const ast = parseOr();
|
|
271
|
+
const extra = peek();
|
|
272
|
+
if (extra) {
|
|
273
|
+
throw new Error(`Unexpected token '${extra.value}' at position ${extra.position + 1}`);
|
|
274
|
+
}
|
|
275
|
+
return ast;
|
|
276
|
+
}
|
|
277
|
+
function tokenizeSearchSqlExpression(expression) {
|
|
278
|
+
const tokens = [];
|
|
279
|
+
let i = 0;
|
|
280
|
+
while (i < expression.length) {
|
|
281
|
+
const ch = expression[i];
|
|
282
|
+
if (/\s/.test(ch)) {
|
|
283
|
+
i += 1;
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (ch === '(') {
|
|
287
|
+
tokens.push({ type: 'LPAREN', value: ch, position: i });
|
|
288
|
+
i += 1;
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (ch === ')') {
|
|
292
|
+
tokens.push({ type: 'RPAREN', value: ch, position: i });
|
|
293
|
+
i += 1;
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
if (ch === ',') {
|
|
297
|
+
tokens.push({ type: 'COMMA', value: ch, position: i });
|
|
298
|
+
i += 1;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (ch === '\'' || ch === '"') {
|
|
302
|
+
const quoted = readQuotedValue(expression, i);
|
|
303
|
+
tokens.push({ type: 'STRING', value: quoted.value, position: i });
|
|
304
|
+
i = quoted.next;
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
const op = readOperator(expression, i);
|
|
308
|
+
if (op) {
|
|
309
|
+
tokens.push({ type: 'OP', value: op.value, position: i });
|
|
310
|
+
i = op.next;
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
let j = i;
|
|
314
|
+
while (j < expression.length) {
|
|
315
|
+
const next = expression[j];
|
|
316
|
+
if (/\s/.test(next) || next === '(' || next === ')' || next === ',' || /[<>=!]/.test(next)) {
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
j += 1;
|
|
320
|
+
}
|
|
321
|
+
const raw = expression.slice(i, j);
|
|
322
|
+
if (!raw) {
|
|
323
|
+
throw new Error(`Unsupported token '${ch}' at position ${i + 1}`);
|
|
324
|
+
}
|
|
325
|
+
const upper = raw.toUpperCase();
|
|
326
|
+
if (upper === 'AND' || upper === 'OR' || upper === 'NOT' || upper === 'IN' || upper === 'LIKE') {
|
|
327
|
+
tokens.push({ type: upper, value: raw, position: i });
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
tokens.push({ type: 'WORD', value: raw, position: i });
|
|
331
|
+
}
|
|
332
|
+
i = j;
|
|
333
|
+
}
|
|
334
|
+
return tokens;
|
|
335
|
+
}
|
|
336
|
+
function readOperator(expression, start) {
|
|
337
|
+
const twoChars = expression.slice(start, start + 2);
|
|
338
|
+
if (twoChars === '<=' || twoChars === '>=' || twoChars === '<>' || twoChars === '!=') {
|
|
339
|
+
return {
|
|
340
|
+
value: twoChars,
|
|
341
|
+
next: start + 2,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
const oneChar = expression[start];
|
|
345
|
+
if (oneChar === '=' || oneChar === '<' || oneChar === '>') {
|
|
346
|
+
return {
|
|
347
|
+
value: oneChar,
|
|
348
|
+
next: start + 1,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
function readQuotedValue(expression, start) {
|
|
354
|
+
const quote = expression[start];
|
|
355
|
+
let i = start + 1;
|
|
356
|
+
let value = '';
|
|
357
|
+
while (i < expression.length) {
|
|
358
|
+
const ch = expression[i];
|
|
359
|
+
if (ch === '\\') {
|
|
360
|
+
if (i + 1 >= expression.length) {
|
|
361
|
+
throw new Error(`Invalid escape at position ${i + 1}`);
|
|
362
|
+
}
|
|
363
|
+
value += expression[i + 1];
|
|
364
|
+
i += 2;
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
if (ch === quote) {
|
|
368
|
+
if (expression[i + 1] === quote) {
|
|
369
|
+
value += quote;
|
|
370
|
+
i += 2;
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
return {
|
|
374
|
+
value,
|
|
375
|
+
next: i + 1,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
value += ch;
|
|
379
|
+
i += 1;
|
|
380
|
+
}
|
|
381
|
+
throw new Error(`Unclosed quote at position ${start + 1}`);
|
|
382
|
+
}
|
|
383
|
+
function normalizeSearchField(value) {
|
|
384
|
+
const normalized = value.trim().toLowerCase();
|
|
385
|
+
if (SEARCH_FIELDS.has(normalized)) {
|
|
386
|
+
return normalized;
|
|
387
|
+
}
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
function normalizeComparisonOperator(raw, position) {
|
|
391
|
+
if (raw === '=') {
|
|
392
|
+
return '=';
|
|
393
|
+
}
|
|
394
|
+
if (raw === '!=' || raw === '<>') {
|
|
395
|
+
return '!=';
|
|
396
|
+
}
|
|
397
|
+
if (raw === '>') {
|
|
398
|
+
return '>';
|
|
399
|
+
}
|
|
400
|
+
if (raw === '>=') {
|
|
401
|
+
return '>=';
|
|
402
|
+
}
|
|
403
|
+
if (raw === '<') {
|
|
404
|
+
return '<';
|
|
405
|
+
}
|
|
406
|
+
if (raw === '<=') {
|
|
407
|
+
return '<=';
|
|
408
|
+
}
|
|
409
|
+
throw new Error(`Unsupported operator '${raw}' at position ${position + 1}`);
|
|
410
|
+
}
|
|
411
|
+
function normalizeLookupKey(value) {
|
|
412
|
+
return value.trim().toLowerCase();
|
|
413
|
+
}
|
|
414
|
+
async function buildZettelBoxIdLookup(ast, resolveZettelBoxNames) {
|
|
415
|
+
const rawValues = collectZettelBoxValues(ast)
|
|
416
|
+
.map((value) => value.trim())
|
|
417
|
+
.filter((value) => value.length > 0);
|
|
418
|
+
if (rawValues.length === 0) {
|
|
419
|
+
return new Map();
|
|
420
|
+
}
|
|
421
|
+
const uniqueNames = [];
|
|
422
|
+
const seen = new Set();
|
|
423
|
+
for (const raw of rawValues) {
|
|
424
|
+
const key = normalizeLookupKey(raw);
|
|
425
|
+
if (seen.has(key)) {
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
seen.add(key);
|
|
429
|
+
uniqueNames.push(raw);
|
|
430
|
+
}
|
|
431
|
+
const ids = await resolveZettelBoxNames(uniqueNames);
|
|
432
|
+
if (ids.length !== uniqueNames.length) {
|
|
433
|
+
throw new DinoxError('Failed to resolve zettel box names in --sql expression');
|
|
434
|
+
}
|
|
435
|
+
const lookup = new Map();
|
|
436
|
+
for (let i = 0; i < uniqueNames.length; i += 1) {
|
|
437
|
+
lookup.set(normalizeLookupKey(uniqueNames[i]), ids[i]);
|
|
438
|
+
}
|
|
439
|
+
return lookup;
|
|
440
|
+
}
|
|
441
|
+
function collectZettelBoxValues(node) {
|
|
442
|
+
if (node.kind === 'comparison') {
|
|
443
|
+
if (node.field === 'zettel_boxes') {
|
|
444
|
+
return [node.value];
|
|
445
|
+
}
|
|
446
|
+
return [];
|
|
447
|
+
}
|
|
448
|
+
if (node.kind === 'in') {
|
|
449
|
+
if (node.field === 'zettel_boxes') {
|
|
450
|
+
return node.values;
|
|
451
|
+
}
|
|
452
|
+
return [];
|
|
453
|
+
}
|
|
454
|
+
if (node.kind === 'not') {
|
|
455
|
+
return collectZettelBoxValues(node.child);
|
|
456
|
+
}
|
|
457
|
+
return [...collectZettelBoxValues(node.left), ...collectZettelBoxValues(node.right)];
|
|
458
|
+
}
|
|
459
|
+
function compileNode(node, noteAlias, params, zettelBoxIdByName) {
|
|
460
|
+
if (node.kind === 'comparison') {
|
|
461
|
+
return compileComparison(node, noteAlias, params, zettelBoxIdByName);
|
|
462
|
+
}
|
|
463
|
+
if (node.kind === 'in') {
|
|
464
|
+
return compileInCondition(node, noteAlias, params, zettelBoxIdByName);
|
|
465
|
+
}
|
|
466
|
+
if (node.kind === 'not') {
|
|
467
|
+
return `NOT (${compileNode(node.child, noteAlias, params, zettelBoxIdByName)})`;
|
|
468
|
+
}
|
|
469
|
+
if (node.kind === 'and') {
|
|
470
|
+
return `(${compileNode(node.left, noteAlias, params, zettelBoxIdByName)}) AND (${compileNode(node.right, noteAlias, params, zettelBoxIdByName)})`;
|
|
471
|
+
}
|
|
472
|
+
return `(${compileNode(node.left, noteAlias, params, zettelBoxIdByName)}) OR (${compileNode(node.right, noteAlias, params, zettelBoxIdByName)})`;
|
|
473
|
+
}
|
|
474
|
+
function compileComparison(node, noteAlias, params, zettelBoxIdByName) {
|
|
475
|
+
if (node.field === 'tags') {
|
|
476
|
+
if (node.operator !== '=' && node.operator !== '!=') {
|
|
477
|
+
throw new Error(`Operator '${node.operator}' is not supported for field 'tags'`);
|
|
478
|
+
}
|
|
479
|
+
const value = normalizeLookupKey(node.value);
|
|
480
|
+
if (!value) {
|
|
481
|
+
throw new Error('tags condition value cannot be empty');
|
|
482
|
+
}
|
|
483
|
+
params.push(value);
|
|
484
|
+
const existsSql = `
|
|
485
|
+
EXISTS (
|
|
486
|
+
SELECT 1
|
|
487
|
+
FROM json_each(
|
|
488
|
+
CASE
|
|
489
|
+
WHEN json_valid(COALESCE(${noteAlias}.tags, '')) THEN ${noteAlias}.tags
|
|
490
|
+
ELSE '[]'
|
|
491
|
+
END
|
|
492
|
+
) tag_item
|
|
493
|
+
WHERE LOWER(TRIM(CAST(tag_item.value AS TEXT))) = ?
|
|
494
|
+
)
|
|
495
|
+
`;
|
|
496
|
+
return node.operator === '!=' ? `NOT (${existsSql})` : existsSql;
|
|
497
|
+
}
|
|
498
|
+
if (node.field === 'zettel_boxes') {
|
|
499
|
+
if (node.operator !== '=' && node.operator !== '!=') {
|
|
500
|
+
throw new Error(`Operator '${node.operator}' is not supported for field 'zettel_boxes'`);
|
|
501
|
+
}
|
|
502
|
+
const resolvedId = zettelBoxIdByName.get(normalizeLookupKey(node.value));
|
|
503
|
+
if (!resolvedId) {
|
|
504
|
+
throw new DinoxError(`Unknown zettel box name in --sql expression: ${node.value}`);
|
|
505
|
+
}
|
|
506
|
+
params.push(resolvedId);
|
|
507
|
+
const existsSql = `
|
|
508
|
+
EXISTS (
|
|
509
|
+
SELECT 1
|
|
510
|
+
FROM json_each(
|
|
511
|
+
CASE
|
|
512
|
+
WHEN json_valid(COALESCE(${noteAlias}.zettel_boxes, '')) THEN ${noteAlias}.zettel_boxes
|
|
513
|
+
ELSE '[]'
|
|
514
|
+
END
|
|
515
|
+
) box_item
|
|
516
|
+
WHERE TRIM(CAST(box_item.value AS TEXT)) = ?
|
|
517
|
+
)
|
|
518
|
+
`;
|
|
519
|
+
return node.operator === '!=' ? `NOT (${existsSql})` : existsSql;
|
|
520
|
+
}
|
|
521
|
+
const column = `${noteAlias}.${SCALAR_FIELD_COLUMNS[node.field]}`;
|
|
522
|
+
params.push(node.value);
|
|
523
|
+
if (node.operator === 'LIKE' || node.operator === 'NOT LIKE') {
|
|
524
|
+
return `(${column} IS NOT NULL AND ${column} ${node.operator} ?)`;
|
|
525
|
+
}
|
|
526
|
+
return `(${column} ${node.operator} ?)`;
|
|
527
|
+
}
|
|
528
|
+
function compileInCondition(node, noteAlias, params, zettelBoxIdByName) {
|
|
529
|
+
if (node.values.length === 0) {
|
|
530
|
+
throw new Error('IN list cannot be empty');
|
|
531
|
+
}
|
|
532
|
+
if (node.field === 'tags') {
|
|
533
|
+
const normalized = uniqueNonEmptyValues(node.values.map((value) => normalizeLookupKey(value)));
|
|
534
|
+
if (normalized.length === 0) {
|
|
535
|
+
throw new Error('tags IN list cannot be empty');
|
|
536
|
+
}
|
|
537
|
+
const placeholders = normalized.map(() => '?').join(', ');
|
|
538
|
+
params.push(...normalized);
|
|
539
|
+
const existsSql = `
|
|
540
|
+
EXISTS (
|
|
541
|
+
SELECT 1
|
|
542
|
+
FROM json_each(
|
|
543
|
+
CASE
|
|
544
|
+
WHEN json_valid(COALESCE(${noteAlias}.tags, '')) THEN ${noteAlias}.tags
|
|
545
|
+
ELSE '[]'
|
|
546
|
+
END
|
|
547
|
+
) tag_item
|
|
548
|
+
WHERE LOWER(TRIM(CAST(tag_item.value AS TEXT))) IN (${placeholders})
|
|
549
|
+
)
|
|
550
|
+
`;
|
|
551
|
+
return node.not ? `NOT (${existsSql})` : existsSql;
|
|
552
|
+
}
|
|
553
|
+
if (node.field === 'zettel_boxes') {
|
|
554
|
+
const ids = uniqueNonEmptyValues(node.values
|
|
555
|
+
.map((value) => zettelBoxIdByName.get(normalizeLookupKey(value)) ?? '')
|
|
556
|
+
.filter((value) => value.length > 0));
|
|
557
|
+
if (ids.length === 0) {
|
|
558
|
+
throw new Error('zettel_boxes IN list cannot be empty');
|
|
559
|
+
}
|
|
560
|
+
const placeholders = ids.map(() => '?').join(', ');
|
|
561
|
+
params.push(...ids);
|
|
562
|
+
const existsSql = `
|
|
563
|
+
EXISTS (
|
|
564
|
+
SELECT 1
|
|
565
|
+
FROM json_each(
|
|
566
|
+
CASE
|
|
567
|
+
WHEN json_valid(COALESCE(${noteAlias}.zettel_boxes, '')) THEN ${noteAlias}.zettel_boxes
|
|
568
|
+
ELSE '[]'
|
|
569
|
+
END
|
|
570
|
+
) box_item
|
|
571
|
+
WHERE TRIM(CAST(box_item.value AS TEXT)) IN (${placeholders})
|
|
572
|
+
)
|
|
573
|
+
`;
|
|
574
|
+
return node.not ? `NOT (${existsSql})` : existsSql;
|
|
575
|
+
}
|
|
576
|
+
const values = uniqueNonEmptyValues(node.values.map((value) => value.trim()));
|
|
577
|
+
if (values.length === 0) {
|
|
578
|
+
throw new Error('IN list cannot be empty');
|
|
579
|
+
}
|
|
580
|
+
const column = `${noteAlias}.${SCALAR_FIELD_COLUMNS[node.field]}`;
|
|
581
|
+
const placeholders = values.map(() => '?').join(', ');
|
|
582
|
+
params.push(...values);
|
|
583
|
+
if (node.not) {
|
|
584
|
+
return `(${column} NOT IN (${placeholders}))`;
|
|
585
|
+
}
|
|
586
|
+
return `(${column} IN (${placeholders}))`;
|
|
587
|
+
}
|
|
588
|
+
function uniqueNonEmptyValues(values) {
|
|
589
|
+
const result = [];
|
|
590
|
+
const seen = new Set();
|
|
591
|
+
for (const value of values) {
|
|
592
|
+
if (!value || seen.has(value)) {
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
seen.add(value);
|
|
596
|
+
result.push(value);
|
|
597
|
+
}
|
|
598
|
+
return result;
|
|
599
|
+
}
|
package/dist/config/keys.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
export declare const CONFIG_KEYS: readonly ["
|
|
2
|
-
export declare const FIXED_CONFIG_KEYS: readonly [
|
|
3
|
-
export declare const READABLE_CONFIG_KEYS: readonly ["
|
|
1
|
+
export declare const CONFIG_KEYS: readonly ["sync.timeoutMs"];
|
|
2
|
+
export declare const FIXED_CONFIG_KEYS: readonly [];
|
|
3
|
+
export declare const READABLE_CONFIG_KEYS: readonly ["sync.timeoutMs"];
|
|
4
4
|
export type ConfigKey = (typeof CONFIG_KEYS)[number];
|
|
5
5
|
export type FixedConfigKey = (typeof FIXED_CONFIG_KEYS)[number];
|
|
6
6
|
export type ReadableConfigKey = (typeof READABLE_CONFIG_KEYS)[number];
|
package/dist/config/keys.js
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
export const CONFIG_KEYS = [
|
|
2
|
-
'powersync.tokenEndpoint',
|
|
3
|
-
'powersync.uploadV4Path',
|
|
4
|
-
'powersync.uploadV2Path',
|
|
5
2
|
'sync.timeoutMs',
|
|
6
3
|
];
|
|
7
|
-
export const FIXED_CONFIG_KEYS = [
|
|
4
|
+
export const FIXED_CONFIG_KEYS = [];
|
|
8
5
|
export const READABLE_CONFIG_KEYS = [...CONFIG_KEYS, ...FIXED_CONFIG_KEYS];
|
|
9
6
|
export function isConfigKey(value) {
|
|
10
7
|
return CONFIG_KEYS.includes(value);
|
package/dist/config/paths.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import os from 'node:os';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
const POWERSYNC_DB_FILENAME = 'dinox-cli.sqlite';
|
|
3
4
|
function resolveConfigHome() {
|
|
4
5
|
if (process.env.DINOX_CONFIG_DIR) {
|
|
5
6
|
return process.env.DINOX_CONFIG_DIR;
|
|
@@ -35,5 +36,5 @@ export function getDataDir() {
|
|
|
35
36
|
return path.join(resolveDataHome(), 'dinox');
|
|
36
37
|
}
|
|
37
38
|
export function getPowerSyncDbPath() {
|
|
38
|
-
return path.join(getDataDir(),
|
|
39
|
+
return path.join(getDataDir(), POWERSYNC_DB_FILENAME);
|
|
39
40
|
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dinoxx/dinox-cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "Dinox CLI",
|
|
5
5
|
"main": "dist/dinox.js",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"build": "tsc -p tsconfig.json && node scripts/add-shebang.mjs",
|
|
8
8
|
"dev": "tsx src/dinox.ts",
|
|
9
9
|
"prepublishOnly": "npm run build",
|
|
10
|
+
"publish:npm": "sh scripts/publish-npm.sh",
|
|
11
|
+
"publish:npm:dry-run": "sh scripts/publish-npm.sh --dry-run",
|
|
10
12
|
"start": "node dist/dinox.js",
|
|
11
13
|
"test": "node --test --import tsx test/**/*.test.ts"
|
|
12
14
|
},
|