@dinoxx/dinox-cli 2.1.1 → 2.3.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 (68) hide show
  1. package/dist/cli.js +4 -0
  2. package/dist/commands/domain/options.d.ts +10 -0
  3. package/dist/commands/domain/options.js +75 -0
  4. package/dist/commands/domain/run.d.ts +24 -0
  5. package/dist/commands/domain/run.js +137 -0
  6. package/dist/commands/notes/actions.js +72 -20
  7. package/dist/commands/notes/createFromTemplate.d.ts +35 -0
  8. package/dist/commands/notes/createFromTemplate.js +58 -0
  9. package/dist/commands/notes/descriptors.js +12 -5
  10. package/dist/commands/notes/dryRun.d.ts +1 -0
  11. package/dist/commands/notes/dryRun.js +8 -2
  12. package/dist/commands/notes/repo/read.js +4 -23
  13. package/dist/commands/notes/repo/shared.d.ts +2 -0
  14. package/dist/commands/notes/repo/write.d.ts +3 -0
  15. package/dist/commands/notes/repo/write.js +16 -5
  16. package/dist/commands/properties/index.d.ts +16 -0
  17. package/dist/commands/properties/index.js +640 -0
  18. package/dist/commands/properties/repo.d.ts +150 -0
  19. package/dist/commands/properties/repo.js +272 -0
  20. package/dist/commands/savedViews/fields.d.ts +28 -0
  21. package/dist/commands/savedViews/fields.js +87 -0
  22. package/dist/commands/savedViews/index.js +203 -164
  23. package/dist/commands/savedViews/model.d.ts +27 -69
  24. package/dist/commands/savedViews/model.js +63 -279
  25. package/dist/commands/savedViews/repo.d.ts +64 -62
  26. package/dist/commands/savedViews/repo.js +309 -428
  27. package/dist/commands/schema/renderSkills.js +29 -5
  28. package/dist/commands/schema/spec.js +4 -0
  29. package/dist/commands/templates/index.d.ts +4 -0
  30. package/dist/commands/templates/index.js +290 -0
  31. package/dist/commands/templates/model.d.ts +12 -34
  32. package/dist/commands/templates/model.js +39 -83
  33. package/dist/commands/templates/repo.d.ts +53 -0
  34. package/dist/commands/templates/repo.js +78 -0
  35. package/dist/skills/registry.js +7 -0
  36. package/dist/utils/errorCodes.d.ts +212 -0
  37. package/dist/utils/errorCodes.js +53 -0
  38. package/dist/vendor/electron/domain/note-templates.d.ts +3 -0
  39. package/dist/vendor/electron/domain/note-templates.js +232 -0
  40. package/dist/vendor/electron/domain/properties.d.ts +10 -0
  41. package/dist/vendor/electron/domain/properties.js +1538 -0
  42. package/dist/vendor/electron/domain/saved-view-analytics.d.ts +45 -0
  43. package/dist/vendor/electron/domain/saved-view-analytics.js +365 -0
  44. package/dist/vendor/electron/domain/saved-view-relative-date.d.ts +21 -0
  45. package/dist/vendor/electron/domain/saved-view-relative-date.js +153 -0
  46. package/dist/vendor/electron/domain/saved-views.d.ts +61 -0
  47. package/dist/vendor/electron/domain/saved-views.js +1117 -0
  48. package/dist/vendor/electron/rpc.d.ts +22 -0
  49. package/dist/vendor/electron/rpc.js +27 -0
  50. package/dist/vendor/electron/shared/ai-errors.d.ts +11 -0
  51. package/dist/vendor/electron/shared/ai-errors.js +107 -0
  52. package/dist/vendor/electron/shared/external-types.d.ts +2 -0
  53. package/dist/vendor/electron/shared/external-types.js +1 -0
  54. package/dist/vendor/electron/shared/index.d.ts +9 -0
  55. package/dist/vendor/electron/shared/index.js +7 -0
  56. package/dist/vendor/electron/shared/note-properties.d.ts +312 -0
  57. package/dist/vendor/electron/shared/note-properties.js +982 -0
  58. package/dist/vendor/electron/shared/note-templates.d.ts +82 -0
  59. package/dist/vendor/electron/shared/note-templates.js +84 -0
  60. package/dist/vendor/electron/shared/powersync-read-models.d.ts +1710 -0
  61. package/dist/vendor/electron/shared/powersync-read-models.js +1 -0
  62. package/dist/vendor/electron/shared/saved-views.d.ts +394 -0
  63. package/dist/vendor/electron/shared/saved-views.js +981 -0
  64. package/dist/vendor/electron/sql-utils.d.ts +5 -0
  65. package/dist/vendor/electron/sql-utils.js +32 -0
  66. package/package.json +6 -3
  67. package/dist/commands/savedViews/semantics.d.ts +0 -46
  68. package/dist/commands/savedViews/semantics.js +0 -738
@@ -0,0 +1,1117 @@
1
+ import { bindDetailSavedView, isPropertyType, isSavedViewDisplayOnlyField, isSavedViewDynamicDateFilterOperator, MAX_SAVED_VIEW_RELATIVE_DAYS, normalizeDatePropertyValue, parsePropertyDefConfigEnvelope, parsePropertyDefOptionsEnvelope, PROPERTY_DEF_CONFIG_KEY_PREFIX, resolvePropertyDefConfig, resolveStandaloneSavedViewFilter, SAVED_VIEW_LAYOUTS, SAVED_VIEW_PRIMARY_RESOURCE_TYPES, savedViewAnalyticsInputSchema, savedViewCatalogInputSchema, savedViewConfigSchema, savedViewCountInputSchema, savedViewCreateInputSchema, savedViewFilterSchema, savedViewFilterUsesSelf, savedViewNoteDetailInputSchema, savedViewPageInputSchema, savedViewSoftDeleteInputSchema, savedViewUpdateInputSchema, } from "../shared/index.js";
2
+ import { uuidv7 } from "uuidv7";
3
+ import { rowsFromResult } from "../rpc.js";
4
+ import { defineCommand, defineReadModel, } from "../rpc.js";
5
+ import { normalizeLimit } from "../sql-utils.js";
6
+ import { fetchSavedViewAnalytics } from "./saved-view-analytics.js";
7
+ import { resolveSavedViewRelativeDateRange, resolveSavedViewTimeZone, } from "./saved-view-relative-date.js";
8
+ const SAVED_VIEW_TABLE = "c_saved_view";
9
+ const ACTIVE_SAVED_VIEW_TYPE_SQL = `view_type IN (${SAVED_VIEW_LAYOUTS.map((layout) => `'${layout}'`).join(", ")})`;
10
+ /** Same order as the phone sidebar: higher sort_rank (newer views) first. */
11
+ const SAVED_VIEW_SIDEBAR_ORDER_SQL = "sort_rank DESC, updated_at DESC, id DESC";
12
+ const NOTE_TABLE = "c_note";
13
+ const PROPERTY_DEF_TABLE = "c_property_def";
14
+ const CONFIG_PROPERTY_TABLE = "c_config_property";
15
+ const ZETTEL_BOX_TABLE = "c_zettel_box";
16
+ const SYSTEM_FIELD_SQL = {
17
+ "sys.id": "n.id",
18
+ "sys.title": "n.title",
19
+ "sys.created_at": "n.created_at",
20
+ "sys.updated_at": "n.updated_at",
21
+ "sys.tags": "n.tags",
22
+ "sys.zettel_boxes": "n.zettel_boxes",
23
+ "sys.source": "n.source",
24
+ "sys.type": "n.type",
25
+ "sys.starred_at": "n.starred_at",
26
+ "sys.images": "n.images",
27
+ "sys.primary_resource_type": notePrimaryResourceTypeSql("n"),
28
+ };
29
+ const SYSTEM_FIELD_SEMANTICS = {
30
+ "sys.title": { type: "text" },
31
+ "sys.created_at": { type: "date", dateMode: "datetime" },
32
+ "sys.updated_at": { type: "date", dateMode: "datetime" },
33
+ "sys.tags": { type: "list", propertyType: "multi_select" },
34
+ "sys.zettel_boxes": { type: "list", propertyType: "relation" },
35
+ "sys.source": { type: "text" },
36
+ "sys.type": { type: "text" },
37
+ "sys.starred_at": { type: "date", dateMode: "datetime" },
38
+ "sys.images": { type: "text" },
39
+ "sys.primary_resource_type": { type: "select" },
40
+ "sys.cover": { type: "text", displayOnly: true },
41
+ "sys.audio": { type: "text", displayOnly: true },
42
+ "sys.video": { type: "text", displayOnly: true },
43
+ "sys.file": { type: "text", displayOnly: true },
44
+ };
45
+ function notePrimaryResourceTypeSql(noteTable) {
46
+ const detail = `CASE WHEN json_valid(${noteTable}.audio_detail) THEN ${noteTable}.audio_detail ELSE '{}' END`;
47
+ const legacyType = `json_extract(${detail}, '$.type')`;
48
+ return `NULLIF(COALESCE((SELECT CASE
49
+ WHEN lower(primary_resource.mime_type) LIKE 'audio/%' THEN 'audio'
50
+ WHEN lower(primary_resource.mime_type) LIKE 'video/%' THEN 'video'
51
+ WHEN lower(primary_resource.mime_type) LIKE 'image/%' THEN 'image'
52
+ WHEN primary_resource.category = 'audios' THEN 'audio'
53
+ WHEN primary_resource.category = 'videos' THEN 'video'
54
+ WHEN primary_resource.category = 'images' THEN 'image'
55
+ WHEN primary_resource.category IN ('documents', 'files') THEN 'file'
56
+ WHEN lower(primary_resource.mime_type) LIKE 'application/%' OR lower(primary_resource.mime_type) LIKE 'text/%' THEN 'file'
57
+ ELSE '' END FROM c_resource AS primary_resource
58
+ WHERE primary_resource.id = ${noteTable}.resource_id AND primary_resource.is_del = 0),
59
+ CASE WHEN ${legacyType} IN ('audio', 'video', 'file', 'image') THEN ${legacyType} END), '')`;
60
+ }
61
+ const TEXT_FILTER_OPERATORS = new Set([
62
+ "equals",
63
+ "notEquals",
64
+ "contains",
65
+ "notContains",
66
+ "isEmpty",
67
+ "isNotEmpty",
68
+ ]);
69
+ const LIST_FILTER_OPERATORS = new Set([
70
+ "contains",
71
+ "notContains",
72
+ "isEmpty",
73
+ "isNotEmpty",
74
+ ]);
75
+ const SELECT_FILTER_OPERATORS = new Set([
76
+ "equals",
77
+ "notEquals",
78
+ "isEmpty",
79
+ "isNotEmpty",
80
+ ]);
81
+ const ORDERABLE_FILTER_OPERATORS = new Set([
82
+ "equals",
83
+ "notEquals",
84
+ "greaterThan",
85
+ "greaterThanOrEqual",
86
+ "lessThan",
87
+ "lessThanOrEqual",
88
+ "isEmpty",
89
+ "isNotEmpty",
90
+ ]);
91
+ const DATE_FILTER_OPERATORS = new Set([
92
+ ...ORDERABLE_FILTER_OPERATORS,
93
+ "withinLastDays",
94
+ "isToday",
95
+ "isThisWeek",
96
+ "isThisMonth",
97
+ ]);
98
+ const BOOLEAN_FILTER_OPERATORS = new Set([
99
+ "equals",
100
+ "notEquals",
101
+ "isEmpty",
102
+ "isNotEmpty",
103
+ ]);
104
+ export function createSavedViewReadModels() {
105
+ return [
106
+ defineReadModel({
107
+ name: "savedViews.snapshot",
108
+ inputSchema: savedViewPageInputSchema,
109
+ tables: () => [SAVED_VIEW_TABLE, NOTE_TABLE, PROPERTY_DEF_TABLE, CONFIG_PROPERTY_TABLE, ZETTEL_BOX_TABLE],
110
+ fetch: (db, input) => db.readTransaction(async (tx) => {
111
+ const scope = await resolveSavedViewQueryScope(tx, input);
112
+ const rows = await fetchSavedViewScopedPage(tx, input, scope);
113
+ const total_count = await countSavedViewScopedRows(tx, input, scope);
114
+ return [{ rows, total_count }];
115
+ }),
116
+ }),
117
+ defineReadModel({
118
+ name: "savedViews.analytics",
119
+ inputSchema: savedViewAnalyticsInputSchema,
120
+ tables: () => [SAVED_VIEW_TABLE, NOTE_TABLE, PROPERTY_DEF_TABLE, CONFIG_PROPERTY_TABLE, ZETTEL_BOX_TABLE],
121
+ fetch: (db, input) => db.readTransaction(async (tx) => {
122
+ const scope = await resolveSavedViewQueryScope(tx, input);
123
+ return [await fetchSavedViewAnalytics(tx, input, scope)];
124
+ }),
125
+ }),
126
+ defineReadModel({
127
+ name: "savedViews.noteDetail",
128
+ inputSchema: savedViewNoteDetailInputSchema,
129
+ tables: () => [
130
+ SAVED_VIEW_TABLE,
131
+ NOTE_TABLE,
132
+ PROPERTY_DEF_TABLE,
133
+ CONFIG_PROPERTY_TABLE,
134
+ ZETTEL_BOX_TABLE,
135
+ ],
136
+ fetch: (db, input) => db.readTransaction((tx) => fetchSavedViewNoteDetail(tx, input)),
137
+ }),
138
+ defineReadModel({
139
+ name: "savedViews.catalog",
140
+ inputSchema: savedViewCatalogInputSchema,
141
+ tables: () => [SAVED_VIEW_TABLE],
142
+ fetch: fetchSavedViewCatalog,
143
+ }),
144
+ defineReadModel({
145
+ name: "savedViews.page",
146
+ inputSchema: savedViewPageInputSchema,
147
+ tables: () => [
148
+ SAVED_VIEW_TABLE,
149
+ NOTE_TABLE,
150
+ PROPERTY_DEF_TABLE,
151
+ CONFIG_PROPERTY_TABLE,
152
+ ZETTEL_BOX_TABLE,
153
+ ],
154
+ fetch: fetchSavedViewPage,
155
+ }),
156
+ defineReadModel({
157
+ name: "savedViews.count",
158
+ inputSchema: savedViewCountInputSchema,
159
+ tables: () => [
160
+ SAVED_VIEW_TABLE,
161
+ NOTE_TABLE,
162
+ PROPERTY_DEF_TABLE,
163
+ CONFIG_PROPERTY_TABLE,
164
+ ],
165
+ fetch: fetchSavedViewCount,
166
+ }),
167
+ ];
168
+ }
169
+ export function createSavedViewCommands() {
170
+ return [
171
+ defineCommand({
172
+ name: "savedViews.create",
173
+ inputSchema: savedViewCreateInputSchema,
174
+ run: async (db, input) => ({
175
+ result: await createSavedView(db, input),
176
+ affectedTables: [SAVED_VIEW_TABLE],
177
+ }),
178
+ }),
179
+ defineCommand({
180
+ name: "savedViews.update",
181
+ inputSchema: savedViewUpdateInputSchema,
182
+ run: async (db, input) => {
183
+ const changed = await updateSavedView(db, input);
184
+ return {
185
+ result: changed,
186
+ affectedTables: changed ? [SAVED_VIEW_TABLE] : [],
187
+ };
188
+ },
189
+ }),
190
+ defineCommand({
191
+ name: "savedViews.softDelete",
192
+ inputSchema: savedViewSoftDeleteInputSchema,
193
+ run: async (db, input) => {
194
+ const changed = await softDeleteSavedView(db, input);
195
+ return {
196
+ result: changed,
197
+ affectedTables: changed ? [SAVED_VIEW_TABLE] : [],
198
+ };
199
+ },
200
+ }),
201
+ ];
202
+ }
203
+ async function fetchSavedViewCatalog(db, input) {
204
+ const limit = normalizeLimit(input.limit, 200, 5000);
205
+ if (limit <= 0)
206
+ return [];
207
+ return rowsFromResult(await db.getAll(`
208
+ SELECT
209
+ id,
210
+ user_id,
211
+ name,
212
+ emoji,
213
+ filter_json,
214
+ view_type,
215
+ config_json,
216
+ template_id,
217
+ group_id,
218
+ sort_rank,
219
+ pinned_at,
220
+ created_at,
221
+ updated_at,
222
+ is_del
223
+ FROM c_saved_view
224
+ WHERE user_id = ?
225
+ AND ${ACTIVE_SAVED_VIEW_TYPE_SQL}
226
+ AND (is_del IS NULL OR is_del = 0)
227
+ ORDER BY ${SAVED_VIEW_SIDEBAR_ORDER_SQL}
228
+ LIMIT ?
229
+ `, [input.userId, limit]));
230
+ }
231
+ async function fetchSavedViewDefinition(executor, input) {
232
+ return await executor.getOptional(`
233
+ SELECT id, name, emoji, filter_json, view_type, config_json, updated_at
234
+ FROM c_saved_view
235
+ WHERE id = ?
236
+ AND user_id = ?
237
+ AND ${ACTIVE_SAVED_VIEW_TYPE_SQL}
238
+ AND (is_del IS NULL OR is_del = 0)
239
+ LIMIT 1
240
+ `, [input.viewId, input.userId]);
241
+ }
242
+ function parseStoredJson(raw, schema, label) {
243
+ let parsed;
244
+ try {
245
+ parsed = JSON.parse(raw ?? "");
246
+ }
247
+ catch {
248
+ throw new Error(`SAVED_VIEW_INVALID_${label}`);
249
+ }
250
+ const result = schema.safeParse(parsed);
251
+ if (!result.success) {
252
+ throw new Error(`SAVED_VIEW_INVALID_${label}`);
253
+ }
254
+ return result.data;
255
+ }
256
+ export async function resolveSavedViewQueryScope(executor, input) {
257
+ const definition = await fetchSavedViewDefinition(executor, input);
258
+ if (!definition) {
259
+ throw new Error("SAVED_VIEW_NOT_FOUND");
260
+ }
261
+ const storedFilter = parseStoredJson(definition.filter_json, savedViewFilterSchema, "FILTER");
262
+ const config = parseStoredJson(definition.config_json, savedViewConfigSchema, "CONFIG");
263
+ const filter = resolveStandaloneSavedViewFilter(config, storedFilter);
264
+ return compileResolvedSavedViewScope(executor, input, definition, filter, config);
265
+ }
266
+ const NOTE_DETAIL_VIEW_CAP = 20;
267
+ const NOTE_DETAIL_CATALOG_LIMIT = 200;
268
+ async function compileResolvedSavedViewScope(executor, input, definition, filter, config) {
269
+ const unboundSelf = savedViewFilterUsesSelf(filter);
270
+ const semantics = await validateSavedViewSemantics(executor, input.userId, unboundSelf ? { ...filter, root: null } : filter, config);
271
+ const timeZone = resolveSavedViewTimeZone(input.timeZone);
272
+ return {
273
+ definition,
274
+ filter,
275
+ config,
276
+ semantics,
277
+ where: unboundSelf
278
+ ? { sql: "0", params: [] }
279
+ : compileFilter(filter, semantics, { timeZone }),
280
+ order: compileOrder(config, semantics),
281
+ };
282
+ }
283
+ export async function fetchSavedViewNoteDetail(executor, input) {
284
+ const hostRow = await executor.getOptional(`
285
+ SELECT
286
+ id, title, source, type, created_at, updated_at, starred_at,
287
+ tags, zettel_boxes, properties
288
+ FROM c_note
289
+ WHERE id = ?
290
+ AND user_no = ?
291
+ AND (is_del IS NULL OR is_del = 0)
292
+ LIMIT 1
293
+ `, [input.noteId, input.userId]);
294
+ if (!hostRow?.id)
295
+ return [];
296
+ const host = savedViewSelfHostFromNote(hostRow);
297
+ const views = await executor.getAll(`
298
+ SELECT id, name, emoji, filter_json, view_type, config_json, updated_at
299
+ FROM c_saved_view
300
+ WHERE user_id = ?
301
+ AND ${ACTIVE_SAVED_VIEW_TYPE_SQL}
302
+ AND (is_del IS NULL OR is_del = 0)
303
+ ORDER BY ${SAVED_VIEW_SIDEBAR_ORDER_SQL}
304
+ LIMIT ?
305
+ `, [input.userId, NOTE_DETAIL_CATALOG_LIMIT]);
306
+ const limit = normalizeLimit(input.limit, 6, 100);
307
+ const sections = [];
308
+ for (const view of views) {
309
+ if (sections.length >= NOTE_DETAIL_VIEW_CAP)
310
+ break;
311
+ const section = await loadNoteDetailSection(executor, input, host, view, limit);
312
+ if (section)
313
+ sections.push(section);
314
+ }
315
+ return sections;
316
+ }
317
+ async function loadNoteDetailSection(executor, input, host, view, limit) {
318
+ let storedFilter;
319
+ let config;
320
+ try {
321
+ storedFilter = parseStoredJson(view.filter_json, savedViewFilterSchema, "FILTER");
322
+ config = parseStoredJson(view.config_json, savedViewConfigSchema, "CONFIG");
323
+ }
324
+ catch {
325
+ return null;
326
+ }
327
+ const filter = bindDetailSavedView({ config, filter: storedFilter }, host);
328
+ if (!filter)
329
+ return null;
330
+ let scope;
331
+ try {
332
+ scope = await compileResolvedSavedViewScope(executor, input, view, filter, config);
333
+ }
334
+ catch {
335
+ return null;
336
+ }
337
+ const pageInput = {
338
+ viewId: view.id,
339
+ userId: input.userId,
340
+ timeZone: input.timeZone,
341
+ limit,
342
+ offset: 0,
343
+ };
344
+ const rows = await fetchSavedViewScopedPage(executor, pageInput, scope);
345
+ const total_count = await countSavedViewScopedRows(executor, pageInput, scope);
346
+ return {
347
+ view_id: view.id,
348
+ name: view.name?.trim() || "",
349
+ emoji: view.emoji?.trim() || null,
350
+ total_count,
351
+ rows,
352
+ };
353
+ }
354
+ function savedViewSelfHostFromNote(row) {
355
+ return {
356
+ id: row.id,
357
+ title: row.title,
358
+ source: row.source,
359
+ type: row.type,
360
+ createdAt: row.created_at,
361
+ updatedAt: row.updated_at,
362
+ starredAt: row.starred_at,
363
+ tags: parseSavedViewStringList(row.tags),
364
+ zettelBoxes: parseSavedViewStringList(row.zettel_boxes),
365
+ properties: parseSavedViewSelfProperties(row.properties),
366
+ };
367
+ }
368
+ function parseSavedViewStringList(raw) {
369
+ if (!raw)
370
+ return null;
371
+ try {
372
+ const parsed = JSON.parse(raw);
373
+ if (!Array.isArray(parsed))
374
+ return null;
375
+ return parsed.filter((item) => typeof item === "string");
376
+ }
377
+ catch {
378
+ return null;
379
+ }
380
+ }
381
+ function parseSavedViewSelfProperties(raw) {
382
+ if (!raw)
383
+ return null;
384
+ try {
385
+ const parsed = JSON.parse(raw);
386
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
387
+ return null;
388
+ const properties = {};
389
+ for (const [key, value] of Object.entries(parsed)) {
390
+ const normalized = normalizeSavedViewSelfProperty(value);
391
+ if (normalized !== undefined)
392
+ properties[key] = normalized;
393
+ }
394
+ return properties;
395
+ }
396
+ catch {
397
+ return null;
398
+ }
399
+ }
400
+ function normalizeSavedViewSelfProperty(value) {
401
+ if (typeof value === "string" ||
402
+ typeof value === "number" ||
403
+ typeof value === "boolean") {
404
+ return value;
405
+ }
406
+ if (!Array.isArray(value))
407
+ return undefined;
408
+ const scalars = value.filter((item) => typeof item === "string" ||
409
+ typeof item === "number" ||
410
+ typeof item === "boolean");
411
+ return scalars.length === value.length ? scalars : undefined;
412
+ }
413
+ export async function fetchSavedViewScopedPage(executor, input, scope) {
414
+ const resolvedScope = scope ?? (await resolveSavedViewQueryScope(executor, input));
415
+ const limit = normalizeLimit(input.limit, 100, 500);
416
+ const offset = Math.max(0, Math.trunc(input.offset ?? 0));
417
+ // Resolve box labels only for the selected page, not rows considered by the sort.
418
+ return rowsFromResult(await executor.getAll(`
419
+ WITH page AS MATERIALIZED (
420
+ SELECT n.id
421
+ FROM c_note AS n
422
+ WHERE n.user_no = ?
423
+ AND (n.is_del IS NULL OR n.is_del = 0)
424
+ AND (${resolvedScope.where.sql})
425
+ ORDER BY ${resolvedScope.order.sql}
426
+ LIMIT ? OFFSET ?
427
+ )
428
+ SELECT
429
+ n.id,
430
+ n.title,
431
+ n.created_at,
432
+ n.updated_at,
433
+ n.tags,
434
+ n.zettel_boxes,
435
+ (
436
+ SELECT json_group_array(
437
+ json_object(
438
+ 'fullPath', saved_view_box.display_path,
439
+ 'color', saved_view_box.color
440
+ )
441
+ )
442
+ FROM (
443
+ SELECT
444
+ COALESCE(
445
+ NULLIF(trim(box.path), ''),
446
+ NULLIF(trim(box.name), '')
447
+ ) AS display_path,
448
+ NULLIF(trim(box.color), '') AS color
449
+ FROM json_each(
450
+ CASE
451
+ WHEN json_valid(n.zettel_boxes) THEN n.zettel_boxes
452
+ ELSE '[]'
453
+ END
454
+ ) AS box_ref
455
+ JOIN c_zettel_box AS box
456
+ ON box.id = CAST(box_ref.value AS TEXT)
457
+ WHERE (box.is_del IS NULL OR box.is_del = 0)
458
+ AND COALESCE(
459
+ NULLIF(trim(box.path), ''),
460
+ NULLIF(trim(box.name), '')
461
+ ) IS NOT NULL
462
+ ORDER BY CAST(box_ref.key AS INTEGER)
463
+ ) AS saved_view_box
464
+ ) AS zettel_box_details,
465
+ n.source,
466
+ n.type,
467
+ n.starred_at,
468
+ n.properties,
469
+ n.images,
470
+ ${notePrimaryResourceTypeSql("n")} AS primary_resource_type
471
+ FROM page JOIN c_note AS n ON n.id = page.id
472
+ ORDER BY ${resolvedScope.order.sql}
473
+ `, [
474
+ input.userId,
475
+ ...resolvedScope.where.params,
476
+ ...resolvedScope.order.params,
477
+ limit,
478
+ offset,
479
+ ...resolvedScope.order.params,
480
+ ]));
481
+ }
482
+ export async function countSavedViewScopedRows(executor, input, scope) {
483
+ const resolvedScope = scope ?? (await resolveSavedViewQueryScope(executor, input));
484
+ const rows = rowsFromResult(await executor.getAll(`
485
+ SELECT COUNT(*) AS total_count
486
+ FROM c_note AS n
487
+ WHERE n.user_no = ?
488
+ AND (n.is_del IS NULL OR n.is_del = 0)
489
+ AND (${resolvedScope.where.sql})
490
+ `, [input.userId, ...resolvedScope.where.params]));
491
+ const total = Number(rows[0]?.total_count ?? 0);
492
+ return Number.isFinite(total) ? Math.max(0, Math.trunc(total)) : 0;
493
+ }
494
+ export async function filterSavedViewNoteIds(executor, input, scope) {
495
+ const noteIds = Array.from(new Set(input.noteIds.map((id) => id.trim()).filter(Boolean))).slice(0, 200);
496
+ if (noteIds.length === 0)
497
+ return [];
498
+ const resolvedScope = scope ?? (await resolveSavedViewQueryScope(executor, input));
499
+ const placeholders = noteIds.map(() => "?").join(", ");
500
+ const rows = rowsFromResult(await executor.getAll(`
501
+ SELECT n.id
502
+ FROM c_note AS n
503
+ WHERE n.user_no = ?
504
+ AND (n.is_del IS NULL OR n.is_del = 0)
505
+ AND (${resolvedScope.where.sql})
506
+ AND n.id IN (${placeholders})
507
+ `, [input.userId, ...resolvedScope.where.params, ...noteIds]));
508
+ const allowed = new Set(rows.map((row) => row.id?.trim()).filter(Boolean));
509
+ return noteIds.filter((id) => allowed.has(id));
510
+ }
511
+ async function fetchSavedViewPage(db, input) {
512
+ return fetchSavedViewScopedPage(db, input);
513
+ }
514
+ async function fetchSavedViewCount(db, input) {
515
+ return [{ total_count: await countSavedViewScopedRows(db, input) }];
516
+ }
517
+ function propertyJsonPath(field) {
518
+ const key = field.slice("prop.".length);
519
+ const escaped = key.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
520
+ return `$."${escaped}"`;
521
+ }
522
+ function compileField(field) {
523
+ const systemSql = SYSTEM_FIELD_SQL[field];
524
+ if (systemSql) {
525
+ return { sql: systemSql, params: [] };
526
+ }
527
+ if (!field.startsWith("prop.") || field.length <= "prop.".length) {
528
+ throw new Error("SAVED_VIEW_INVALID_FIELD");
529
+ }
530
+ return {
531
+ sql: "json_extract(CASE WHEN json_valid(n.properties) THEN n.properties ELSE '{}' END, ?)",
532
+ params: [propertyJsonPath(field)],
533
+ };
534
+ }
535
+ function requireConditionValue(condition) {
536
+ if (condition.value === undefined || condition.value === null) {
537
+ throw new Error("SAVED_VIEW_FILTER_VALUE_REQUIRED");
538
+ }
539
+ return condition.value;
540
+ }
541
+ function normalizeSqlParameter(value) {
542
+ if (typeof value === "boolean")
543
+ return value ? 1 : 0;
544
+ return value;
545
+ }
546
+ function compileCondition(condition, semantics, options = {}) {
547
+ const field = compileField(condition.field);
548
+ const value = condition.value;
549
+ const semantic = semantics?.get(condition.field) ?? SYSTEM_FIELD_SEMANTICS[condition.field];
550
+ if (semantic?.propertyType === "relation" &&
551
+ (condition.operator === "equals" || condition.operator === "notEquals") &&
552
+ value !== undefined &&
553
+ value !== null) {
554
+ return compileContains(field, value, condition.operator === "notEquals", semantic);
555
+ }
556
+ if (isSavedViewDynamicDateFilterOperator(condition.operator)) {
557
+ return compileDynamicDateCondition(field, condition.operator, condition.value, semantic, options);
558
+ }
559
+ switch (condition.operator) {
560
+ case "equals":
561
+ if (value === undefined || value === null) {
562
+ return compileEmptyCheck(field, true);
563
+ }
564
+ return comparison(field, "=", value, semantic, 0);
565
+ case "notEquals":
566
+ if (value === undefined || value === null) {
567
+ return compileEmptyCheck(field, false);
568
+ }
569
+ return comparison(field, "!=", value, semantic, 1);
570
+ case "contains":
571
+ return compileContains(field, requireConditionValue(condition), false, semantic);
572
+ case "notContains":
573
+ return compileContains(field, requireConditionValue(condition), true, semantic);
574
+ case "greaterThan":
575
+ return comparison(field, ">", requireConditionValue(condition), semantic);
576
+ case "greaterThanOrEqual":
577
+ return comparison(field, ">=", requireConditionValue(condition), semantic);
578
+ case "lessThan":
579
+ return comparison(field, "<", requireConditionValue(condition), semantic);
580
+ case "lessThanOrEqual":
581
+ return comparison(field, "<=", requireConditionValue(condition), semantic);
582
+ case "isEmpty":
583
+ return compileEmptyCheck(field, true);
584
+ case "isNotEmpty":
585
+ return compileEmptyCheck(field, false);
586
+ default:
587
+ throw new Error("SAVED_VIEW_INVALID_OPERATOR");
588
+ }
589
+ }
590
+ function compileDynamicDateCondition(field, operator, value, semantic, options) {
591
+ if (semantic?.type !== "date") {
592
+ throw new Error("SAVED_VIEW_OPERATOR_TYPE_MISMATCH");
593
+ }
594
+ const range = resolveSavedViewRelativeDateRange({
595
+ operator,
596
+ value,
597
+ now: options.now,
598
+ timeZone: options.timeZone,
599
+ });
600
+ const dateTime = semantic.dateMode === "datetime";
601
+ const sqlFunction = dateTime ? "julianday" : "date";
602
+ const start = dateTime ? range.startInstant : range.startDate;
603
+ const endExclusive = dateTime
604
+ ? range.endInstantExclusive
605
+ : range.endDateExclusive;
606
+ return {
607
+ sql: `COALESCE((${sqlFunction}(${field.sql}) >= ${sqlFunction}(?) AND ${sqlFunction}(${field.sql}) < ${sqlFunction}(?)), 0)`,
608
+ params: [
609
+ ...field.params,
610
+ start,
611
+ ...field.params,
612
+ endExclusive,
613
+ ],
614
+ };
615
+ }
616
+ function compileContains(field, value, negated, semantic) {
617
+ const normalizedValue = normalizeSqlParameter(value);
618
+ if (semantic?.type !== "list") {
619
+ const predicate = `COALESCE(
620
+ instr(lower(CAST(${field.sql} AS TEXT)), lower(CAST(? AS TEXT))) > 0,
621
+ 0
622
+ )`;
623
+ return {
624
+ sql: negated ? `(NOT (${predicate}))` : predicate,
625
+ params: [...field.params, normalizedValue],
626
+ };
627
+ }
628
+ const predicate = `EXISTS (
629
+ SELECT 1
630
+ FROM (SELECT ${field.sql} AS field_value) AS saved_view_field
631
+ WHERE json_valid(CAST(saved_view_field.field_value AS TEXT))
632
+ AND json_type(CAST(saved_view_field.field_value AS TEXT)) = 'array'
633
+ AND EXISTS (
634
+ SELECT 1
635
+ FROM json_each(CAST(saved_view_field.field_value AS TEXT)) AS saved_view_item
636
+ WHERE CAST(saved_view_item.value AS TEXT) = CAST(? AS TEXT)
637
+ )
638
+ )`;
639
+ return {
640
+ sql: negated ? `(NOT (${predicate}))` : predicate,
641
+ params: [...field.params, normalizedValue],
642
+ };
643
+ }
644
+ function comparison(field, operator, value, semantic, nullResult = 0) {
645
+ const isDate = semantic?.type === "date";
646
+ const dateFunction = semantic?.dateMode === "datetime" || (operator !== "=" && operator !== "!=")
647
+ ? "julianday"
648
+ : "date";
649
+ const left = isDate
650
+ ? `${dateFunction}(${field.sql})`
651
+ : semantic?.type === "number"
652
+ ? `CAST(${field.sql} AS REAL)`
653
+ : field.sql;
654
+ const right = isDate
655
+ ? `${dateFunction}(?)`
656
+ : semantic?.type === "number"
657
+ ? "CAST(? AS REAL)"
658
+ : "?";
659
+ return {
660
+ sql: `COALESCE((${left} ${operator} ${right}), ${nullResult})`,
661
+ params: [...field.params, normalizeSqlParameter(value)],
662
+ };
663
+ }
664
+ function compileEmptyCheck(field, empty) {
665
+ const emptySql = `(${field.sql} IS NULL OR trim(CAST(${field.sql} AS TEXT)) = '' OR CAST(${field.sql} AS TEXT) = '[]')`;
666
+ return {
667
+ sql: empty ? emptySql : `(NOT ${emptySql})`,
668
+ params: [...field.params, ...field.params, ...field.params],
669
+ };
670
+ }
671
+ function compileFilterNode(node, budget, depth, semantics, options = {}) {
672
+ budget.nodes += 1;
673
+ if (depth > 8 || budget.nodes > 100) {
674
+ throw new Error("SAVED_VIEW_FILTER_TOO_COMPLEX");
675
+ }
676
+ if (node.kind === "condition") {
677
+ return compileCondition(node, semantics, options);
678
+ }
679
+ if (node.kind === "not") {
680
+ const child = compileFilterNode(node.child, budget, depth + 1, semantics, options);
681
+ return { sql: `(NOT (${child.sql}))`, params: child.params };
682
+ }
683
+ const children = node.children.map((child) => compileFilterNode(child, budget, depth + 1, semantics, options));
684
+ if (children.length === 0) {
685
+ return { sql: node.operator === "and" ? "1" : "0", params: [] };
686
+ }
687
+ return {
688
+ sql: `(${children.map((child) => `(${child.sql})`).join(node.operator === "and" ? " AND " : " OR ")})`,
689
+ params: children.flatMap((child) => child.params),
690
+ };
691
+ }
692
+ export function compileFilter(filter, semantics, options = {}) {
693
+ return filter.root
694
+ ? compileFilterNode(filter.root, { nodes: 0 }, 1, semantics, options)
695
+ : { sql: "1", params: [] };
696
+ }
697
+ export function compileOrder(config, semantics) {
698
+ const clauses = [];
699
+ const params = [];
700
+ for (const sort of config.sort ?? []) {
701
+ const field = compileField(sort.field);
702
+ const direction = sort.direction === "asc" ? "ASC" : "DESC";
703
+ const semantic = semantics?.get(sort.field) ?? SYSTEM_FIELD_SEMANTICS[sort.field];
704
+ if (semantic?.type === "list" ||
705
+ semantic?.displayOnly ||
706
+ sort.field === "sys.images" ||
707
+ sort.field === "sys.primary_resource_type") {
708
+ throw new Error("SAVED_VIEW_SORT_TYPE_UNSUPPORTED");
709
+ }
710
+ clauses.push(`CASE WHEN ${field.sql} IS NULL OR trim(CAST(${field.sql} AS TEXT)) = '' THEN 1 ELSE 0 END ASC`);
711
+ params.push(...field.params, ...field.params);
712
+ if (semantic?.type === "select" && (semantic.options?.length ?? 0) > 0) {
713
+ const options = [...(semantic.options ?? [])].sort((left, right) => left.sortRank - right.sortRank || left.id.localeCompare(right.id));
714
+ clauses.push(`CASE WHEN ${field.sql} IN (${options.map(() => "?").join(", ")}) THEN 0 ELSE 1 END ASC`, `CASE ${field.sql} ${options.map(() => "WHEN ? THEN ?").join(" ")} ELSE NULL END ${direction}`, `${field.sql} COLLATE NOCASE ${direction}`);
715
+ params.push(...field.params, ...options.map((option) => option.id), ...field.params, ...options.flatMap((option) => [option.id, option.sortRank]), ...field.params);
716
+ continue;
717
+ }
718
+ const orderExpression = semantic?.type === "date"
719
+ ? `julianday(${field.sql})`
720
+ : semantic?.type === "number"
721
+ ? `CAST(${field.sql} AS REAL)`
722
+ : `${field.sql} COLLATE NOCASE`;
723
+ clauses.push(`${orderExpression} ${direction}`);
724
+ params.push(...field.params);
725
+ }
726
+ if (clauses.length === 0) {
727
+ clauses.push("COALESCE(n.updated_at, n.created_at, n.id) DESC");
728
+ }
729
+ clauses.push("n.id DESC");
730
+ return { sql: clauses.join(", "), params };
731
+ }
732
+ function collectFilterConditions(node, conditions) {
733
+ if (!node)
734
+ return;
735
+ if (node.kind === "condition") {
736
+ conditions.push(node);
737
+ return;
738
+ }
739
+ if (node.kind === "not") {
740
+ collectFilterConditions(node.child, conditions);
741
+ return;
742
+ }
743
+ for (const child of node.children) {
744
+ collectFilterConditions(child, conditions);
745
+ }
746
+ }
747
+ function propertyDefToSemantic(type, optionsJson, configJson) {
748
+ let options;
749
+ if (type === "select" || type === "multi_select" || type === "status") {
750
+ const parsedOptions = parsePropertyDefOptionsEnvelope(optionsJson);
751
+ if (parsedOptions.status === "invalid" || parsedOptions.status === "unsupported") {
752
+ throw new Error("SAVED_VIEW_PROPERTY_OPTIONS_INVALID");
753
+ }
754
+ if (parsedOptions.status === "empty" || parsedOptions.status === "legacy") {
755
+ throw new Error("SAVED_VIEW_PROPERTY_OPTIONS_MIGRATION_REQUIRED");
756
+ }
757
+ options = parsedOptions.options;
758
+ }
759
+ const parsedConfig = parsePropertyDefConfigEnvelope(configJson);
760
+ const config = parsedConfig.status === "empty"
761
+ ? resolvePropertyDefConfig(type, null)
762
+ : parsedConfig.status === "valid" && parsedConfig.config.type === type
763
+ ? parsedConfig.config
764
+ : undefined;
765
+ if (parsedConfig.status !== "empty" && !config) {
766
+ throw new Error("SAVED_VIEW_PROPERTY_CONFIG_INVALID");
767
+ }
768
+ switch (type) {
769
+ case "number":
770
+ return { type: "number", propertyType: type };
771
+ case "date":
772
+ return {
773
+ type: "date",
774
+ propertyType: type,
775
+ dateMode: config?.type === "date" ? config.mode : "date",
776
+ };
777
+ case "select":
778
+ case "status":
779
+ return {
780
+ type: "select",
781
+ propertyType: type,
782
+ options,
783
+ };
784
+ case "unique_id":
785
+ return { type: "number", propertyType: type };
786
+ case "checkbox":
787
+ return { type: "boolean", propertyType: type };
788
+ case "multi_select": {
789
+ return {
790
+ type: "list",
791
+ propertyType: type,
792
+ options,
793
+ };
794
+ }
795
+ case "relation":
796
+ return { type: "list", propertyType: type };
797
+ default:
798
+ return { type: "text", propertyType: type };
799
+ }
800
+ }
801
+ function assertConditionSemantics(condition, semantic) {
802
+ const allowedOperators = semantic.displayOnly
803
+ ? new Set()
804
+ : condition.field === "sys.images"
805
+ ? new Set(["isEmpty", "isNotEmpty"])
806
+ : condition.field === "sys.primary_resource_type"
807
+ ? new Set(["equals", "notEquals", "isEmpty", "isNotEmpty"])
808
+ : semantic.propertyType === "relation"
809
+ ? new Set([...SELECT_FILTER_OPERATORS, ...LIST_FILTER_OPERATORS])
810
+ : semantic.type === "text"
811
+ ? TEXT_FILTER_OPERATORS
812
+ : semantic.type === "select"
813
+ ? SELECT_FILTER_OPERATORS
814
+ : semantic.type === "list"
815
+ ? LIST_FILTER_OPERATORS
816
+ : semantic.type === "boolean"
817
+ ? BOOLEAN_FILTER_OPERATORS
818
+ : semantic.type === "date"
819
+ ? DATE_FILTER_OPERATORS
820
+ : ORDERABLE_FILTER_OPERATORS;
821
+ if (!allowedOperators.has(condition.operator)) {
822
+ throw new Error("SAVED_VIEW_OPERATOR_TYPE_MISMATCH");
823
+ }
824
+ if (condition.field === "sys.primary_resource_type" &&
825
+ (condition.operator === "equals" || condition.operator === "notEquals") &&
826
+ !SAVED_VIEW_PRIMARY_RESOURCE_TYPES.some((option) => option === condition.value)) {
827
+ throw new Error("SAVED_VIEW_VALUE_TYPE_MISMATCH");
828
+ }
829
+ const value = condition.value;
830
+ if (isSavedViewDynamicDateFilterOperator(condition.operator)) {
831
+ if (condition.operator === "withinLastDays" &&
832
+ (typeof value !== "number" ||
833
+ !Number.isInteger(value) ||
834
+ value < 1 ||
835
+ value > MAX_SAVED_VIEW_RELATIVE_DAYS)) {
836
+ throw new Error("SAVED_VIEW_INVALID_RELATIVE_DAYS");
837
+ }
838
+ if (condition.operator !== "withinLastDays" &&
839
+ value !== undefined &&
840
+ value !== null) {
841
+ throw new Error("SAVED_VIEW_VALUE_TYPE_MISMATCH");
842
+ }
843
+ return;
844
+ }
845
+ if (value === undefined || value === null)
846
+ return;
847
+ if (semantic.type === "number" && typeof value !== "number") {
848
+ throw new Error("SAVED_VIEW_VALUE_TYPE_MISMATCH");
849
+ }
850
+ if (semantic.type === "boolean" && typeof value !== "boolean") {
851
+ throw new Error("SAVED_VIEW_VALUE_TYPE_MISMATCH");
852
+ }
853
+ if ((semantic.type === "text" ||
854
+ semantic.type === "select" ||
855
+ semantic.type === "list" ||
856
+ semantic.type === "date") &&
857
+ typeof value !== "string") {
858
+ throw new Error("SAVED_VIEW_VALUE_TYPE_MISMATCH");
859
+ }
860
+ if (semantic.type === "date" &&
861
+ typeof value === "string" &&
862
+ normalizeDatePropertyValue(value, semantic.dateMode ?? "date") === null) {
863
+ throw new Error("SAVED_VIEW_INVALID_DATE_VALUE");
864
+ }
865
+ if ((semantic.propertyType === "select" ||
866
+ semantic.propertyType === "multi_select") &&
867
+ typeof value === "string" &&
868
+ !(semantic.options ?? []).some((option) => option.id === value)) {
869
+ throw new Error("SAVED_VIEW_OPTION_NOT_FOUND");
870
+ }
871
+ }
872
+ async function validateSavedViewSemantics(executor, userId, filter, config) {
873
+ const conditions = [];
874
+ collectFilterConditions(filter?.root ?? null, conditions);
875
+ const fields = new Set();
876
+ for (const condition of conditions)
877
+ fields.add(condition.field);
878
+ for (const column of config?.columns ?? [])
879
+ fields.add(column.field);
880
+ for (const sort of config?.sort ?? [])
881
+ fields.add(sort.field);
882
+ const propertyKeys = Array.from(new Set([...fields]
883
+ .filter((field) => field.startsWith("prop."))
884
+ .map((field) => field.slice("prop.".length))));
885
+ const propertySemantics = new Map();
886
+ if (propertyKeys.length > 0) {
887
+ const placeholders = propertyKeys.map(() => "?").join(", ");
888
+ const rows = await executor.getAll(`
889
+ SELECT
890
+ ranked.id,
891
+ ranked.key,
892
+ ranked.type,
893
+ ranked.options,
894
+ (
895
+ SELECT cp.c_value
896
+ FROM ${CONFIG_PROPERTY_TABLE} AS cp
897
+ WHERE cp.user_id = ranked.user_id
898
+ AND cp.c_key = '${PROPERTY_DEF_CONFIG_KEY_PREFIX}' || ranked.id
899
+ AND (cp.is_del IS NULL OR cp.is_del = 0)
900
+ ORDER BY COALESCE(cp.updated_at, cp.created_at, cp.id) DESC, cp.id DESC
901
+ LIMIT 1
902
+ ) AS config_json
903
+ FROM (
904
+ SELECT
905
+ id,
906
+ user_id,
907
+ key,
908
+ type,
909
+ options,
910
+ ROW_NUMBER() OVER (
911
+ PARTITION BY key
912
+ ORDER BY
913
+ COALESCE(NULLIF(updated_at, ''), NULLIF(created_at, ''), id) DESC,
914
+ id DESC
915
+ ) AS key_rank
916
+ FROM ${PROPERTY_DEF_TABLE}
917
+ WHERE user_id = ?
918
+ AND key IN (${placeholders})
919
+ AND (is_del IS NULL OR is_del = 0)
920
+ ) AS ranked
921
+ WHERE ranked.key_rank = 1
922
+ `, [userId, ...propertyKeys]);
923
+ for (const row of rows) {
924
+ if (row.key && isPropertyType(row.type)) {
925
+ propertySemantics.set(row.key, propertyDefToSemantic(row.type, row.options, row.config_json));
926
+ }
927
+ }
928
+ for (const key of propertyKeys) {
929
+ if (!propertySemantics.has(key)) {
930
+ throw new Error("SAVED_VIEW_PROPERTY_NOT_FOUND");
931
+ }
932
+ }
933
+ }
934
+ const semantics = new Map();
935
+ for (const field of fields) {
936
+ const semantic = field.startsWith("prop.")
937
+ ? propertySemantics.get(field.slice("prop.".length))
938
+ : SYSTEM_FIELD_SEMANTICS[field];
939
+ if (!semantic) {
940
+ throw new Error("SAVED_VIEW_INVALID_FIELD");
941
+ }
942
+ semantics.set(field, semantic);
943
+ }
944
+ for (const condition of conditions) {
945
+ assertConditionSemantics(condition, semantics.get(condition.field));
946
+ }
947
+ for (const sort of config?.sort ?? []) {
948
+ const semantic = semantics.get(sort.field);
949
+ if (semantic?.type === "list" ||
950
+ semantic?.displayOnly ||
951
+ isSavedViewDisplayOnlyField(sort.field) ||
952
+ sort.field === "sys.images" ||
953
+ sort.field === "sys.primary_resource_type") {
954
+ throw new Error("SAVED_VIEW_SORT_TYPE_UNSUPPORTED");
955
+ }
956
+ }
957
+ return semantics;
958
+ }
959
+ function normalizeSavedViewDateValues(filter, semantics) {
960
+ const normalizeNode = (node) => {
961
+ if (node.kind === "condition") {
962
+ if (semantics.get(node.field)?.type === "date" &&
963
+ typeof node.value === "string") {
964
+ const semantic = semantics.get(node.field);
965
+ const normalized = normalizeDatePropertyValue(node.value, semantic.dateMode ?? "date");
966
+ if (!normalized)
967
+ throw new Error("SAVED_VIEW_INVALID_DATE_VALUE");
968
+ return { ...node, value: normalized };
969
+ }
970
+ return node;
971
+ }
972
+ if (node.kind === "not") {
973
+ return { ...node, child: normalizeNode(node.child) };
974
+ }
975
+ return { ...node, children: node.children.map(normalizeNode) };
976
+ };
977
+ return {
978
+ ...filter,
979
+ root: filter.root ? normalizeNode(filter.root) : null,
980
+ };
981
+ }
982
+ async function createSavedView(db, input) {
983
+ return await db.writeTransaction(async (tx) => {
984
+ const semanticTypes = await validateSavedViewSemantics(tx, input.userId, input.filter, input.config);
985
+ const normalizedFilter = normalizeSavedViewDateValues(input.filter, semanticTypes);
986
+ const id = uuidv7();
987
+ const rank = input.sortRank ??
988
+ (await tx.getOptional(`
989
+ SELECT COALESCE(MAX(sort_rank), -1) + 1 AS next_rank
990
+ FROM c_saved_view
991
+ WHERE user_id = ?
992
+ AND (is_del IS NULL OR is_del = 0)
993
+ `, [input.userId]))?.next_rank ??
994
+ 0;
995
+ await tx.execute(`
996
+ INSERT INTO c_saved_view (
997
+ id,
998
+ user_id,
999
+ name,
1000
+ emoji,
1001
+ filter_json,
1002
+ view_type,
1003
+ config_json,
1004
+ template_id,
1005
+ group_id,
1006
+ sort_rank,
1007
+ pinned_at,
1008
+ created_at,
1009
+ updated_at,
1010
+ is_del
1011
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
1012
+ `, [
1013
+ id,
1014
+ input.userId,
1015
+ input.name,
1016
+ input.emoji ?? null,
1017
+ JSON.stringify(normalizedFilter),
1018
+ input.config.layout,
1019
+ JSON.stringify(input.config),
1020
+ input.templateId ?? null,
1021
+ input.groupId ?? null,
1022
+ rank,
1023
+ input.pinnedAt ?? null,
1024
+ input.updatedAt,
1025
+ input.updatedAt,
1026
+ ]);
1027
+ return id;
1028
+ });
1029
+ }
1030
+ async function findOwnedSavedView(executor, input) {
1031
+ return await executor.getOptional(`
1032
+ SELECT id
1033
+ FROM c_saved_view
1034
+ WHERE id = ?
1035
+ AND user_id = ?
1036
+ AND ${ACTIVE_SAVED_VIEW_TYPE_SQL}
1037
+ AND (is_del IS NULL OR is_del = 0)
1038
+ LIMIT 1
1039
+ `, [input.id, input.userId]);
1040
+ }
1041
+ async function updateSavedView(db, input) {
1042
+ return await db.writeTransaction(async (tx) => {
1043
+ const target = await findOwnedSavedView(tx, input);
1044
+ if (!target)
1045
+ return false;
1046
+ let normalizedFilter = input.filter;
1047
+ if (input.filter !== undefined || input.config !== undefined) {
1048
+ const semanticTypes = await validateSavedViewSemantics(tx, input.userId, input.filter, input.config);
1049
+ if (input.filter) {
1050
+ normalizedFilter = normalizeSavedViewDateValues(input.filter, semanticTypes);
1051
+ }
1052
+ }
1053
+ const assignments = [];
1054
+ const params = [];
1055
+ const set = (column, value) => {
1056
+ assignments.push(`${column} = ?`);
1057
+ params.push(value);
1058
+ };
1059
+ if (input.name !== undefined)
1060
+ set("name", input.name);
1061
+ if (input.emoji !== undefined)
1062
+ set("emoji", input.emoji);
1063
+ if (normalizedFilter !== undefined) {
1064
+ set("filter_json", JSON.stringify(normalizedFilter));
1065
+ }
1066
+ if (input.config !== undefined) {
1067
+ set("config_json", JSON.stringify(input.config));
1068
+ set("view_type", input.config.layout);
1069
+ }
1070
+ if (input.templateId !== undefined)
1071
+ set("template_id", input.templateId);
1072
+ if (input.groupId !== undefined)
1073
+ set("group_id", input.groupId);
1074
+ if (input.sortRank !== undefined)
1075
+ set("sort_rank", input.sortRank);
1076
+ if (input.pinnedAt !== undefined)
1077
+ set("pinned_at", input.pinnedAt);
1078
+ set("updated_at", input.updatedAt);
1079
+ set("is_del", 0);
1080
+ await tx.execute(`UPDATE c_saved_view SET ${assignments.join(", ")} WHERE id = ? AND user_id = ?`, [...params, input.id, input.userId]);
1081
+ return true;
1082
+ });
1083
+ }
1084
+ async function softDeleteSavedView(db, input) {
1085
+ return await db.writeTransaction(async (tx) => {
1086
+ const target = await findOwnedSavedView(tx, input);
1087
+ if (!target)
1088
+ return false;
1089
+ await tx.execute(`
1090
+ UPDATE c_saved_view
1091
+ SET is_del = 1,
1092
+ updated_at = ?
1093
+ WHERE id = ?
1094
+ AND user_id = ?
1095
+ `, [input.updatedAt, input.id, input.userId]);
1096
+ return true;
1097
+ });
1098
+ }
1099
+ /**
1100
+ * Read-modify-write of one view's config inside a single write transaction, for writers
1101
+ * that only touch the analytics layer (Agent tools). `apply` receives the stored config and
1102
+ * returns the next one; filter, columns and sort stay as stored.
1103
+ */
1104
+ export async function updateSavedViewConfig(db, input, apply) {
1105
+ return await db.writeTransaction(async (tx) => {
1106
+ const definition = await fetchSavedViewDefinition(tx, {
1107
+ viewId: input.viewId,
1108
+ userId: input.userId,
1109
+ });
1110
+ if (!definition)
1111
+ throw new Error("SAVED_VIEW_NOT_FOUND");
1112
+ const previous = parseStoredJson(definition.config_json, savedViewConfigSchema, "CONFIG");
1113
+ const next = savedViewConfigSchema.parse(apply(previous));
1114
+ await tx.execute("UPDATE c_saved_view SET config_json = ?, updated_at = ? WHERE id = ? AND user_id = ?", [JSON.stringify(next), input.updatedAt, input.viewId, input.userId]);
1115
+ return { previous, next };
1116
+ });
1117
+ }