@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.
- package/dist/cli.js +4 -0
- package/dist/commands/domain/options.d.ts +10 -0
- package/dist/commands/domain/options.js +75 -0
- package/dist/commands/domain/run.d.ts +24 -0
- package/dist/commands/domain/run.js +137 -0
- package/dist/commands/notes/actions.js +72 -20
- package/dist/commands/notes/createFromTemplate.d.ts +35 -0
- package/dist/commands/notes/createFromTemplate.js +58 -0
- package/dist/commands/notes/descriptors.js +12 -5
- package/dist/commands/notes/dryRun.d.ts +1 -0
- package/dist/commands/notes/dryRun.js +8 -2
- package/dist/commands/notes/repo/read.js +4 -23
- package/dist/commands/notes/repo/shared.d.ts +2 -0
- package/dist/commands/notes/repo/write.d.ts +3 -0
- package/dist/commands/notes/repo/write.js +16 -5
- package/dist/commands/properties/index.d.ts +16 -0
- package/dist/commands/properties/index.js +640 -0
- package/dist/commands/properties/repo.d.ts +150 -0
- package/dist/commands/properties/repo.js +272 -0
- package/dist/commands/savedViews/fields.d.ts +28 -0
- package/dist/commands/savedViews/fields.js +87 -0
- package/dist/commands/savedViews/index.js +203 -164
- package/dist/commands/savedViews/model.d.ts +27 -69
- package/dist/commands/savedViews/model.js +63 -279
- package/dist/commands/savedViews/repo.d.ts +64 -62
- package/dist/commands/savedViews/repo.js +309 -428
- package/dist/commands/schema/renderSkills.js +29 -5
- package/dist/commands/schema/spec.js +4 -0
- package/dist/commands/templates/index.d.ts +4 -0
- package/dist/commands/templates/index.js +290 -0
- package/dist/commands/templates/model.d.ts +12 -34
- package/dist/commands/templates/model.js +39 -83
- package/dist/commands/templates/repo.d.ts +53 -0
- package/dist/commands/templates/repo.js +78 -0
- package/dist/skills/registry.js +7 -0
- package/dist/utils/errorCodes.d.ts +212 -0
- package/dist/utils/errorCodes.js +53 -0
- package/dist/vendor/electron/domain/note-templates.d.ts +3 -0
- package/dist/vendor/electron/domain/note-templates.js +232 -0
- package/dist/vendor/electron/domain/properties.d.ts +10 -0
- package/dist/vendor/electron/domain/properties.js +1538 -0
- package/dist/vendor/electron/domain/saved-view-analytics.d.ts +45 -0
- package/dist/vendor/electron/domain/saved-view-analytics.js +365 -0
- package/dist/vendor/electron/domain/saved-view-relative-date.d.ts +21 -0
- package/dist/vendor/electron/domain/saved-view-relative-date.js +153 -0
- package/dist/vendor/electron/domain/saved-views.d.ts +61 -0
- package/dist/vendor/electron/domain/saved-views.js +1117 -0
- package/dist/vendor/electron/rpc.d.ts +22 -0
- package/dist/vendor/electron/rpc.js +27 -0
- package/dist/vendor/electron/shared/ai-errors.d.ts +11 -0
- package/dist/vendor/electron/shared/ai-errors.js +107 -0
- package/dist/vendor/electron/shared/external-types.d.ts +2 -0
- package/dist/vendor/electron/shared/external-types.js +1 -0
- package/dist/vendor/electron/shared/index.d.ts +9 -0
- package/dist/vendor/electron/shared/index.js +7 -0
- package/dist/vendor/electron/shared/note-properties.d.ts +312 -0
- package/dist/vendor/electron/shared/note-properties.js +982 -0
- package/dist/vendor/electron/shared/note-templates.d.ts +82 -0
- package/dist/vendor/electron/shared/note-templates.js +84 -0
- package/dist/vendor/electron/shared/powersync-read-models.d.ts +1710 -0
- package/dist/vendor/electron/shared/powersync-read-models.js +1 -0
- package/dist/vendor/electron/shared/saved-views.d.ts +394 -0
- package/dist/vendor/electron/shared/saved-views.js +981 -0
- package/dist/vendor/electron/sql-utils.d.ts +5 -0
- package/dist/vendor/electron/sql-utils.js +32 -0
- package/package.json +6 -3
- package/dist/commands/savedViews/semantics.d.ts +0 -46
- 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
|
+
}
|