sogo-db-core 0.1.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/LICENSE +21 -0
- package/dist/index.cjs +834 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +201 -0
- package/dist/index.d.ts +201 -0
- package/dist/index.js +778 -0
- package/dist/index.js.map +1 -0
- package/package.json +49 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,778 @@
|
|
|
1
|
+
// src/types.ts
|
|
2
|
+
var STATUS_GROUPS = {
|
|
3
|
+
"Not started": { label: "Not started", color: "#5e5e5e" },
|
|
4
|
+
"In progress": { label: "In progress", color: "#2e75d0" },
|
|
5
|
+
"Done": { label: "Done", color: "#2d9e6b" }
|
|
6
|
+
};
|
|
7
|
+
var STATUS_OPTIONS = ["Not started", "In progress", "Done"];
|
|
8
|
+
var DEFAULT_OPTION_COLORS = [
|
|
9
|
+
"#6b7280",
|
|
10
|
+
"#8b6b4a",
|
|
11
|
+
"#f59e0b",
|
|
12
|
+
"#10b981",
|
|
13
|
+
"#3b82f6",
|
|
14
|
+
"#a855f7",
|
|
15
|
+
"#ef4444"
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
// src/formula.ts
|
|
19
|
+
function computeFormulaValue(record, field) {
|
|
20
|
+
let expression = field.formula?.expression?.trim();
|
|
21
|
+
if (!expression) return null;
|
|
22
|
+
if (expression.startsWith("=")) expression = expression.slice(1).trim();
|
|
23
|
+
const functionMatch = /^([a-zA-Z_][a-zA-Z0-9_]*)\((.*)\)$/.exec(expression);
|
|
24
|
+
if (functionMatch) {
|
|
25
|
+
const fnName = functionMatch[1].toUpperCase();
|
|
26
|
+
const args = splitFormulaArgs(functionMatch[2]).map((arg) => evaluateFormulaToken(arg, record));
|
|
27
|
+
return applyFormulaFunction(fnName, args, record);
|
|
28
|
+
}
|
|
29
|
+
const arithmetic = tryEvaluateArithmetic(expression, record);
|
|
30
|
+
if (arithmetic !== null && arithmetic !== void 0) return arithmetic;
|
|
31
|
+
const replaced = expression.replace(/\{([^}]+)\}/g, (_full, fieldId) => {
|
|
32
|
+
const value = record[String(fieldId).trim()];
|
|
33
|
+
if (value === null || value === void 0) return "";
|
|
34
|
+
if (Array.isArray(value)) return value.join(", ");
|
|
35
|
+
return String(value);
|
|
36
|
+
});
|
|
37
|
+
const asNumber = Number(replaced);
|
|
38
|
+
return Number.isFinite(asNumber) && replaced !== "" ? asNumber : replaced;
|
|
39
|
+
}
|
|
40
|
+
function splitFormulaArgs(args) {
|
|
41
|
+
const out = [];
|
|
42
|
+
let current = "";
|
|
43
|
+
let depth = 0;
|
|
44
|
+
let quote = null;
|
|
45
|
+
for (let i = 0; i < args.length; i++) {
|
|
46
|
+
const ch = args[i];
|
|
47
|
+
if ((ch === '"' || ch === "'") && (!quote || quote === ch)) {
|
|
48
|
+
quote = quote ? null : ch;
|
|
49
|
+
current += ch;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (!quote) {
|
|
53
|
+
if (ch === "(") {
|
|
54
|
+
depth++;
|
|
55
|
+
current += ch;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (ch === ")") {
|
|
59
|
+
depth = Math.max(0, depth - 1);
|
|
60
|
+
current += ch;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (ch === "," && depth === 0) {
|
|
64
|
+
out.push(current.trim());
|
|
65
|
+
current = "";
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
current += ch;
|
|
70
|
+
}
|
|
71
|
+
if (current.trim()) out.push(current.trim());
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
function evaluateFormulaToken(token, record) {
|
|
75
|
+
const t = token.trim();
|
|
76
|
+
if (!t.length) return null;
|
|
77
|
+
if (t.startsWith('"') && t.endsWith('"') || t.startsWith("'") && t.endsWith("'")) {
|
|
78
|
+
return t.slice(1, -1);
|
|
79
|
+
}
|
|
80
|
+
const num = Number(t);
|
|
81
|
+
if (Number.isFinite(num) && !/[a-zA-Z{}]/.test(t)) return num;
|
|
82
|
+
const fieldRef = /^\{([^}]+)\}$/.exec(t);
|
|
83
|
+
if (fieldRef) {
|
|
84
|
+
const value = record[fieldRef[1].trim()];
|
|
85
|
+
if (Array.isArray(value)) return value.join(", ");
|
|
86
|
+
if (value === null || value === void 0) return null;
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
const bool = t.toLowerCase();
|
|
90
|
+
if (bool === "true") return true;
|
|
91
|
+
if (bool === "false") return false;
|
|
92
|
+
return t;
|
|
93
|
+
}
|
|
94
|
+
function applyFormulaFunction(name, args, record) {
|
|
95
|
+
switch (name) {
|
|
96
|
+
case "SUM":
|
|
97
|
+
return numericArgs(args).reduce((sum, v) => sum + v, 0);
|
|
98
|
+
case "AVG": {
|
|
99
|
+
const nums = numericArgs(args);
|
|
100
|
+
return nums.length ? nums.reduce((sum, v) => sum + v, 0) / nums.length : 0;
|
|
101
|
+
}
|
|
102
|
+
case "MIN": {
|
|
103
|
+
const nums = numericArgs(args);
|
|
104
|
+
return nums.length ? Math.min(...nums) : 0;
|
|
105
|
+
}
|
|
106
|
+
case "MAX": {
|
|
107
|
+
const nums = numericArgs(args);
|
|
108
|
+
return nums.length ? Math.max(...nums) : 0;
|
|
109
|
+
}
|
|
110
|
+
case "ABS":
|
|
111
|
+
return Math.abs(Number(args[0] ?? 0));
|
|
112
|
+
case "ROUND":
|
|
113
|
+
return Math.round(Number(args[0] ?? 0));
|
|
114
|
+
case "LEN":
|
|
115
|
+
return String(args[0] ?? "").length;
|
|
116
|
+
case "UPPER":
|
|
117
|
+
return String(args[0] ?? "").toUpperCase();
|
|
118
|
+
case "LOWER":
|
|
119
|
+
return String(args[0] ?? "").toLowerCase();
|
|
120
|
+
case "CONCAT":
|
|
121
|
+
return args.map((v) => v === null || v === void 0 ? "" : String(v)).join("");
|
|
122
|
+
case "NOW":
|
|
123
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
124
|
+
case "TODAY":
|
|
125
|
+
return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
126
|
+
case "IF": {
|
|
127
|
+
const condition = evaluateFormulaCondition(args[0], record);
|
|
128
|
+
return condition ? args[1] === null || args[1] === void 0 ? "" : args[1] : args[2] === null || args[2] === void 0 ? "" : args[2];
|
|
129
|
+
}
|
|
130
|
+
default:
|
|
131
|
+
return args[0] === null || args[0] === void 0 ? null : String(args[0]);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function evaluateFormulaCondition(raw, record) {
|
|
135
|
+
if (typeof raw === "boolean") return raw;
|
|
136
|
+
if (typeof raw === "number") return raw !== 0;
|
|
137
|
+
if (raw === null || raw === void 0) return false;
|
|
138
|
+
const condition = String(raw).trim();
|
|
139
|
+
const match = /(.+?)(>=|<=|!=|=|>|<)(.+)/.exec(condition);
|
|
140
|
+
if (!match) return Boolean(condition);
|
|
141
|
+
const left = evaluateFormulaToken(match[1].trim(), record);
|
|
142
|
+
const right = evaluateFormulaToken(match[3].trim(), record);
|
|
143
|
+
const op = match[2];
|
|
144
|
+
if (typeof left === "number" || typeof right === "number") {
|
|
145
|
+
const ln = Number(left ?? 0);
|
|
146
|
+
const rn = Number(right ?? 0);
|
|
147
|
+
switch (op) {
|
|
148
|
+
case ">":
|
|
149
|
+
return ln > rn;
|
|
150
|
+
case "<":
|
|
151
|
+
return ln < rn;
|
|
152
|
+
case ">=":
|
|
153
|
+
return ln >= rn;
|
|
154
|
+
case "<=":
|
|
155
|
+
return ln <= rn;
|
|
156
|
+
case "=":
|
|
157
|
+
return ln === rn;
|
|
158
|
+
case "!=":
|
|
159
|
+
return ln !== rn;
|
|
160
|
+
default:
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const ls = String(left ?? "");
|
|
165
|
+
const rs = String(right ?? "");
|
|
166
|
+
switch (op) {
|
|
167
|
+
case "=":
|
|
168
|
+
return ls === rs;
|
|
169
|
+
case "!=":
|
|
170
|
+
return ls !== rs;
|
|
171
|
+
case ">":
|
|
172
|
+
return ls > rs;
|
|
173
|
+
case "<":
|
|
174
|
+
return ls < rs;
|
|
175
|
+
case ">=":
|
|
176
|
+
return ls >= rs;
|
|
177
|
+
case "<=":
|
|
178
|
+
return ls <= rs;
|
|
179
|
+
default:
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function numericArgs(args) {
|
|
184
|
+
return args.map((v) => Number(v)).filter((v) => Number.isFinite(v));
|
|
185
|
+
}
|
|
186
|
+
function tryEvaluateArithmetic(expression, record) {
|
|
187
|
+
const replaced = expression.replace(/\{([^}]+)\}/g, (_full, fieldId) => {
|
|
188
|
+
const value = Number(record[String(fieldId).trim()]);
|
|
189
|
+
return Number.isFinite(value) ? String(value) : "0";
|
|
190
|
+
});
|
|
191
|
+
if (!/^[0-9+\-*/().\s%]+$/.test(replaced)) return null;
|
|
192
|
+
try {
|
|
193
|
+
const value = Function(`"use strict"; return (${replaced});`)();
|
|
194
|
+
return Number.isFinite(value) ? Number(value) : null;
|
|
195
|
+
} catch {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// src/relations.ts
|
|
201
|
+
function getRelationTargetDatabase(db, relationField, resolveDatabase) {
|
|
202
|
+
const targetDatabaseId = relationField.relation?.targetDatabaseId;
|
|
203
|
+
if (targetDatabaseId && resolveDatabase) {
|
|
204
|
+
return resolveDatabase(targetDatabaseId) ?? db;
|
|
205
|
+
}
|
|
206
|
+
return db;
|
|
207
|
+
}
|
|
208
|
+
function inferImplicitRelationTargets(db, databases) {
|
|
209
|
+
const peers = [...databases].filter((candidate) => candidate.id !== db.id);
|
|
210
|
+
let changed = false;
|
|
211
|
+
for (const field of db.schema) {
|
|
212
|
+
if (field.type !== "relation") continue;
|
|
213
|
+
field.relation ??= {};
|
|
214
|
+
if (field.relation.targetDatabaseId) continue;
|
|
215
|
+
const inferred = inferTargetDatabase(field.name, db.name, peers);
|
|
216
|
+
if (!inferred) continue;
|
|
217
|
+
field.relation.targetDatabaseId = inferred.id;
|
|
218
|
+
changed = true;
|
|
219
|
+
if (!field.relation.targetRelationFieldId) {
|
|
220
|
+
const backlink = inferBacklinkField(inferred, db.name);
|
|
221
|
+
if (backlink) field.relation.targetRelationFieldId = backlink.id;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return changed;
|
|
225
|
+
}
|
|
226
|
+
function inferTargetDatabase(relationFieldName, sourceDbName, peers) {
|
|
227
|
+
const relationToken = normalizeToken(relationFieldName);
|
|
228
|
+
const sourceToken = normalizeToken(sourceDbName);
|
|
229
|
+
const candidates = [
|
|
230
|
+
...peers.filter((p) => normalizeToken(p.name) === relationToken),
|
|
231
|
+
...peers.filter((p) => normalizeToken(p.name).includes(relationToken)),
|
|
232
|
+
...peers.filter((p) => relationToken.includes(normalizeToken(p.name)))
|
|
233
|
+
];
|
|
234
|
+
if (candidates.length) return candidates[0];
|
|
235
|
+
if (relationToken.includes("project")) {
|
|
236
|
+
return peers.find((p) => normalizeToken(p.name).includes("project"));
|
|
237
|
+
}
|
|
238
|
+
if (sourceToken.includes("task") && relationToken.includes("project")) {
|
|
239
|
+
return peers.find((p) => normalizeToken(p.name).includes("project"));
|
|
240
|
+
}
|
|
241
|
+
if (sourceToken.includes("project") && relationToken.includes("task")) {
|
|
242
|
+
return peers.find((p) => normalizeToken(p.name).includes("task"));
|
|
243
|
+
}
|
|
244
|
+
return void 0;
|
|
245
|
+
}
|
|
246
|
+
function inferBacklinkField(targetDb, sourceDbName) {
|
|
247
|
+
const sourceToken = normalizeToken(sourceDbName);
|
|
248
|
+
return targetDb.schema.find(
|
|
249
|
+
(field) => field.type === "relation" && normalizeToken(field.name).includes(sourceToken)
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
function normalizeToken(value) {
|
|
253
|
+
return value.toLowerCase().replace(/[^a-z0-9]/g, "").replace(/s$/, "");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// src/rollup.ts
|
|
257
|
+
function computeRollupValue(record, field, db, resolveDatabase) {
|
|
258
|
+
const rollup = field.rollup;
|
|
259
|
+
if (!rollup) return null;
|
|
260
|
+
const relationField = db.schema.find((f) => f.id === rollup.relationFieldId && f.type === "relation");
|
|
261
|
+
if (!relationField) return null;
|
|
262
|
+
const targetDb = getRelationTargetDatabase(db, relationField, resolveDatabase);
|
|
263
|
+
const rawLinked = record[relationField.id];
|
|
264
|
+
const linkedIds = Array.isArray(rawLinked) ? rawLinked.map((id) => String(id)) : [];
|
|
265
|
+
if (!linkedIds.length) {
|
|
266
|
+
return rollup.aggregation === "count" ? 0 : null;
|
|
267
|
+
}
|
|
268
|
+
const linkedRecords = targetDb.records.filter((r) => linkedIds.includes(r.id));
|
|
269
|
+
if (!linkedRecords.length) {
|
|
270
|
+
return rollup.aggregation === "count" ? 0 : null;
|
|
271
|
+
}
|
|
272
|
+
if (rollup.aggregation === "count") return linkedRecords.length;
|
|
273
|
+
if (rollup.aggregation === "count_not_empty") {
|
|
274
|
+
const target = rollup.targetFieldId;
|
|
275
|
+
if (!target) return 0;
|
|
276
|
+
return linkedRecords.filter((r) => {
|
|
277
|
+
const value = r[target];
|
|
278
|
+
return value !== null && value !== void 0 && value !== "" && !(Array.isArray(value) && value.length === 0);
|
|
279
|
+
}).length;
|
|
280
|
+
}
|
|
281
|
+
const targetFieldId = rollup.targetFieldId;
|
|
282
|
+
if (!targetFieldId) return null;
|
|
283
|
+
const nums = linkedRecords.map((r) => Number(r[targetFieldId])).filter((v) => Number.isFinite(v));
|
|
284
|
+
if (!nums.length) return null;
|
|
285
|
+
switch (rollup.aggregation) {
|
|
286
|
+
case "sum":
|
|
287
|
+
return nums.reduce((sum, v) => sum + v, 0);
|
|
288
|
+
case "avg":
|
|
289
|
+
return nums.reduce((sum, v) => sum + v, 0) / nums.length;
|
|
290
|
+
case "min":
|
|
291
|
+
return Math.min(...nums);
|
|
292
|
+
case "max":
|
|
293
|
+
return Math.max(...nums);
|
|
294
|
+
default:
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// src/utils.ts
|
|
300
|
+
function getFieldValue(record, field, db, resolveDatabase) {
|
|
301
|
+
if (field.type === "rollup") {
|
|
302
|
+
return computeRollupValue(record, field, db, resolveDatabase);
|
|
303
|
+
}
|
|
304
|
+
if (field.type === "formula") {
|
|
305
|
+
return computeFormulaValue(record, field);
|
|
306
|
+
}
|
|
307
|
+
return record[field.id];
|
|
308
|
+
}
|
|
309
|
+
function getFieldDisplayValue(record, fieldId, schema, db, resolveDatabase) {
|
|
310
|
+
const field = schema?.find((f) => f.id === fieldId);
|
|
311
|
+
const val = field && db ? getFieldValue(record, field, db, resolveDatabase) : record[fieldId];
|
|
312
|
+
if (val === null || val === void 0) return "";
|
|
313
|
+
if (Array.isArray(val)) return val.join(", ");
|
|
314
|
+
if (typeof val === "boolean") return val ? "\u2713" : "\u2014";
|
|
315
|
+
return String(val);
|
|
316
|
+
}
|
|
317
|
+
function getRecordTitle(record, schema) {
|
|
318
|
+
const titleField = schema.find((f) => f.type === "text");
|
|
319
|
+
if (!titleField) return "Untitled";
|
|
320
|
+
const val = record[titleField.id];
|
|
321
|
+
return val !== null && val !== void 0 && val !== "" ? String(val) : "Untitled";
|
|
322
|
+
}
|
|
323
|
+
function getVisibleFields(schema, view) {
|
|
324
|
+
const order = view.fieldOrder ?? schema.map((f) => f.id);
|
|
325
|
+
const hidden = new Set(view.hiddenFields ?? []);
|
|
326
|
+
return order.map((id) => schema.find((f) => f.id === id)).filter((f) => f !== void 0 && !hidden.has(f.id));
|
|
327
|
+
}
|
|
328
|
+
function getStatusColor(value) {
|
|
329
|
+
return STATUS_GROUPS[value]?.color ?? "#5e5e5e";
|
|
330
|
+
}
|
|
331
|
+
function getFieldOptionColor(field, option) {
|
|
332
|
+
const explicit = field.optionColors?.[option];
|
|
333
|
+
if (explicit) return explicit;
|
|
334
|
+
const options = field.options ?? [];
|
|
335
|
+
const index = options.indexOf(option);
|
|
336
|
+
if (index >= 0) return DEFAULT_OPTION_COLORS[index % DEFAULT_OPTION_COLORS.length];
|
|
337
|
+
return DEFAULT_OPTION_COLORS[0];
|
|
338
|
+
}
|
|
339
|
+
function getReadableTextColor(background) {
|
|
340
|
+
const hex = background.replace("#", "").trim();
|
|
341
|
+
if (!/^[0-9a-fA-F]{6}$/.test(hex)) return "#ffffff";
|
|
342
|
+
const r = parseInt(hex.slice(0, 2), 16);
|
|
343
|
+
const g = parseInt(hex.slice(2, 4), 16);
|
|
344
|
+
const b = parseInt(hex.slice(4, 6), 16);
|
|
345
|
+
const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
|
346
|
+
return luminance > 0.62 ? "#1f2937" : "#ffffff";
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// src/sort-filter.ts
|
|
350
|
+
function applySorts(records, sorts, schema, db, resolveDatabase) {
|
|
351
|
+
if (!sorts.length) return records;
|
|
352
|
+
return [...records].sort((a, b) => {
|
|
353
|
+
for (const s of sorts) {
|
|
354
|
+
const field = schema?.find((f) => f.id === s.fieldId);
|
|
355
|
+
const av = field && db ? getFieldValue(a, field, db, resolveDatabase) : a[s.fieldId] ?? "";
|
|
356
|
+
const bv = field && db ? getFieldValue(b, field, db, resolveDatabase) : b[s.fieldId] ?? "";
|
|
357
|
+
const cmp = compareValues(av, bv);
|
|
358
|
+
if (cmp !== 0) return s.direction === "asc" ? cmp : -cmp;
|
|
359
|
+
}
|
|
360
|
+
return 0;
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
function applyFilters(records, filters, schema, db, resolveDatabase) {
|
|
364
|
+
if (!filters.length) return records;
|
|
365
|
+
return records.filter(
|
|
366
|
+
(record) => filters.every((f) => {
|
|
367
|
+
const field = schema?.find((candidate) => candidate.id === f.fieldId);
|
|
368
|
+
const val = field && db ? getFieldValue(record, field, db, resolveDatabase) : record[f.fieldId];
|
|
369
|
+
const strVal = valueAsFilterString(val);
|
|
370
|
+
const filterVal = f.value.toLowerCase();
|
|
371
|
+
switch (f.op) {
|
|
372
|
+
case "contains":
|
|
373
|
+
return strVal.includes(filterVal);
|
|
374
|
+
case "not_contains":
|
|
375
|
+
return !strVal.includes(filterVal);
|
|
376
|
+
case "equals":
|
|
377
|
+
return strVal === filterVal;
|
|
378
|
+
case "not_equals":
|
|
379
|
+
return strVal !== filterVal;
|
|
380
|
+
case "is_empty":
|
|
381
|
+
return val === null || val === void 0 || val === "" || Array.isArray(val) && val.length === 0;
|
|
382
|
+
case "is_not_empty":
|
|
383
|
+
return val !== null && val !== void 0 && val !== "" && !(Array.isArray(val) && val.length === 0);
|
|
384
|
+
case "gt":
|
|
385
|
+
return Number(val) > Number(f.value);
|
|
386
|
+
case "gte":
|
|
387
|
+
return Number(val) >= Number(f.value);
|
|
388
|
+
case "lt":
|
|
389
|
+
return Number(val) < Number(f.value);
|
|
390
|
+
case "lte":
|
|
391
|
+
return Number(val) <= Number(f.value);
|
|
392
|
+
default:
|
|
393
|
+
return true;
|
|
394
|
+
}
|
|
395
|
+
})
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
function compareValues(a, b) {
|
|
399
|
+
if ((a === null || a === void 0) && (b === null || b === void 0)) return 0;
|
|
400
|
+
if (a === null || a === void 0) return -1;
|
|
401
|
+
if (b === null || b === void 0) return 1;
|
|
402
|
+
if (typeof a === "number" && typeof b === "number") return a - b;
|
|
403
|
+
if (typeof a === "boolean" && typeof b === "boolean") return Number(a) - Number(b);
|
|
404
|
+
return valueAsFilterString(a).localeCompare(valueAsFilterString(b), void 0, { numeric: true, sensitivity: "base" });
|
|
405
|
+
}
|
|
406
|
+
function valueAsFilterString(value) {
|
|
407
|
+
if (value === null || value === void 0) return "";
|
|
408
|
+
if (Array.isArray(value)) return value.map((item) => String(item)).join(", ").toLowerCase();
|
|
409
|
+
if (typeof value === "boolean") return value ? "true" : "false";
|
|
410
|
+
return String(value).toLowerCase();
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// src/io.ts
|
|
414
|
+
import { readFile, writeFile, readdir } from "fs/promises";
|
|
415
|
+
import { join } from "path";
|
|
416
|
+
import { homedir } from "os";
|
|
417
|
+
function getGlobalDatabasePath() {
|
|
418
|
+
return join(homedir(), ".sogo", "globalDatabases");
|
|
419
|
+
}
|
|
420
|
+
async function readDatabaseFile(filePath) {
|
|
421
|
+
const content = await readFile(filePath, "utf-8");
|
|
422
|
+
const db = JSON.parse(content);
|
|
423
|
+
db.schema ??= [];
|
|
424
|
+
db.views ??= [];
|
|
425
|
+
db.records ??= [];
|
|
426
|
+
for (const view of db.views) {
|
|
427
|
+
view.sort ??= [];
|
|
428
|
+
view.filter ??= [];
|
|
429
|
+
view.hiddenFields ??= [];
|
|
430
|
+
}
|
|
431
|
+
return db;
|
|
432
|
+
}
|
|
433
|
+
async function writeDatabaseFile(db, filePath) {
|
|
434
|
+
await writeFile(filePath, JSON.stringify(db, null, " "), "utf-8");
|
|
435
|
+
}
|
|
436
|
+
async function scanDirectory(dirPath, depth = 3) {
|
|
437
|
+
const results = [];
|
|
438
|
+
await scanDirectoryRecursive(dirPath, results, depth);
|
|
439
|
+
return results;
|
|
440
|
+
}
|
|
441
|
+
async function scanDirectoryRecursive(dirPath, results, depth) {
|
|
442
|
+
if (depth <= 0) return;
|
|
443
|
+
try {
|
|
444
|
+
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
445
|
+
for (const entry of entries) {
|
|
446
|
+
const fullPath = join(dirPath, entry.name);
|
|
447
|
+
if (!entry.isDirectory() && entry.name.endsWith(".db.json")) {
|
|
448
|
+
try {
|
|
449
|
+
const db = await readDatabaseFile(fullPath);
|
|
450
|
+
results.push({ db, path: fullPath });
|
|
451
|
+
} catch {
|
|
452
|
+
}
|
|
453
|
+
} else if (entry.isDirectory() && depth > 1 && !entry.name.startsWith(".") && entry.name !== "node_modules") {
|
|
454
|
+
await scanDirectoryRecursive(fullPath, results, depth - 1);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
} catch {
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
async function scanAll(workspacePath, globalPath, scanDepth = 3) {
|
|
461
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
462
|
+
const results = [];
|
|
463
|
+
const resolvedGlobalPath = globalPath ?? getGlobalDatabasePath();
|
|
464
|
+
const globalResults = await scanDirectory(resolvedGlobalPath, 2);
|
|
465
|
+
for (const entry of globalResults) {
|
|
466
|
+
if (!seenIds.has(entry.db.id)) {
|
|
467
|
+
seenIds.add(entry.db.id);
|
|
468
|
+
results.push({ ...entry, scope: "global" });
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
const workspaceResults = await scanDirectory(workspacePath, scanDepth);
|
|
472
|
+
for (const entry of workspaceResults) {
|
|
473
|
+
if (!seenIds.has(entry.db.id)) {
|
|
474
|
+
seenIds.add(entry.db.id);
|
|
475
|
+
results.push({ ...entry, scope: "workspace" });
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return results;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// src/migration.ts
|
|
482
|
+
function migrateSchema(db, newSchema) {
|
|
483
|
+
const oldSchema = db.schema ?? [];
|
|
484
|
+
const newFieldById = new Map(newSchema.map((f) => [f.id, f]));
|
|
485
|
+
const newFieldIds = new Set(newSchema.map((f) => f.id));
|
|
486
|
+
for (const record of db.records) {
|
|
487
|
+
for (const oldField of oldSchema) {
|
|
488
|
+
if (!newFieldIds.has(oldField.id)) {
|
|
489
|
+
delete record[oldField.id];
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
for (const newField of newSchema) {
|
|
493
|
+
const oldField = oldSchema.find((f) => f.id === newField.id);
|
|
494
|
+
const previousValue = record[newField.id];
|
|
495
|
+
if (oldField && oldField.type === newField.type && previousValue !== void 0) continue;
|
|
496
|
+
record[newField.id] = coerceValueForField(newField, previousValue);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
const fallbackGroupBy = newSchema.find((f) => f.type === "status" || f.type === "select")?.id;
|
|
500
|
+
for (const view of db.views) {
|
|
501
|
+
view.sort = (view.sort ?? []).filter((s) => newFieldById.has(s.fieldId));
|
|
502
|
+
view.filter = (view.filter ?? []).filter((f) => newFieldById.has(f.fieldId));
|
|
503
|
+
view.hiddenFields = (view.hiddenFields ?? []).filter((id) => newFieldById.has(id));
|
|
504
|
+
if (view.fieldOrder) {
|
|
505
|
+
const order = view.fieldOrder.filter((id) => newFieldById.has(id));
|
|
506
|
+
for (const field of newSchema) {
|
|
507
|
+
if (!order.includes(field.id)) order.push(field.id);
|
|
508
|
+
}
|
|
509
|
+
view.fieldOrder = order;
|
|
510
|
+
}
|
|
511
|
+
if (view.columnWidths) {
|
|
512
|
+
const kept = {};
|
|
513
|
+
for (const [fieldId, width] of Object.entries(view.columnWidths)) {
|
|
514
|
+
if (newFieldById.has(fieldId)) kept[fieldId] = width;
|
|
515
|
+
}
|
|
516
|
+
view.columnWidths = kept;
|
|
517
|
+
}
|
|
518
|
+
if ((view.type === "kanban" || view.type === "gallery") && (!view.groupBy || !newFieldById.has(view.groupBy))) {
|
|
519
|
+
view.groupBy = fallbackGroupBy;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
db.schema = newSchema.map((f) => ({ ...f, options: f.options ? [...f.options] : void 0 }));
|
|
523
|
+
}
|
|
524
|
+
function coerceValueForField(field, value) {
|
|
525
|
+
if (value === void 0 || value === null || value === "") {
|
|
526
|
+
if (field.type === "multiselect" || field.type === "relation") return [];
|
|
527
|
+
if (field.type === "rollup" || field.type === "formula") return null;
|
|
528
|
+
if (field.type === "createdAt" || field.type === "lastEditedAt") return (/* @__PURE__ */ new Date()).toISOString();
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
531
|
+
switch (field.type) {
|
|
532
|
+
case "number": {
|
|
533
|
+
const num = typeof value === "number" ? value : Number(value);
|
|
534
|
+
return Number.isFinite(num) ? num : null;
|
|
535
|
+
}
|
|
536
|
+
case "checkbox":
|
|
537
|
+
if (typeof value === "boolean") return value;
|
|
538
|
+
if (typeof value === "number") return value !== 0;
|
|
539
|
+
if (typeof value === "string") {
|
|
540
|
+
const normalized = value.trim().toLowerCase();
|
|
541
|
+
return normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on";
|
|
542
|
+
}
|
|
543
|
+
return false;
|
|
544
|
+
case "multiselect":
|
|
545
|
+
if (Array.isArray(value)) return value.map((item) => String(item)).filter(Boolean);
|
|
546
|
+
if (typeof value === "string") return value.split(/[;,]/).map((item) => item.trim()).filter(Boolean);
|
|
547
|
+
return [];
|
|
548
|
+
case "relation":
|
|
549
|
+
if (Array.isArray(value)) return value.map((item) => String(item)).filter(Boolean);
|
|
550
|
+
if (typeof value === "string") return value.split(/[;,]/).map((item) => item.trim()).filter(Boolean);
|
|
551
|
+
return [];
|
|
552
|
+
case "rollup":
|
|
553
|
+
case "formula":
|
|
554
|
+
return null;
|
|
555
|
+
case "select":
|
|
556
|
+
case "status": {
|
|
557
|
+
const normalized = Array.isArray(value) ? value[0] ?? null : value;
|
|
558
|
+
const candidate = normalized === null || normalized === void 0 ? null : String(normalized);
|
|
559
|
+
if (!candidate) return null;
|
|
560
|
+
if (field.options?.length && !field.options.includes(candidate)) return null;
|
|
561
|
+
return candidate;
|
|
562
|
+
}
|
|
563
|
+
case "createdAt":
|
|
564
|
+
case "lastEditedAt":
|
|
565
|
+
case "text":
|
|
566
|
+
case "date":
|
|
567
|
+
case "url":
|
|
568
|
+
case "email":
|
|
569
|
+
case "phone":
|
|
570
|
+
return String(value);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// src/templates.ts
|
|
575
|
+
function createDefaultDatabaseTemplate(name) {
|
|
576
|
+
const titleId = crypto.randomUUID();
|
|
577
|
+
const statusId = crypto.randomUUID();
|
|
578
|
+
return {
|
|
579
|
+
id: crypto.randomUUID(),
|
|
580
|
+
name,
|
|
581
|
+
schema: [
|
|
582
|
+
{ id: titleId, name: "Title", type: "text" },
|
|
583
|
+
{ id: statusId, name: "Status", type: "status", options: [...STATUS_OPTIONS] }
|
|
584
|
+
],
|
|
585
|
+
views: [
|
|
586
|
+
{
|
|
587
|
+
id: crypto.randomUUID(),
|
|
588
|
+
name: "All Items",
|
|
589
|
+
type: "table",
|
|
590
|
+
sort: [],
|
|
591
|
+
filter: [],
|
|
592
|
+
hiddenFields: []
|
|
593
|
+
},
|
|
594
|
+
{
|
|
595
|
+
id: crypto.randomUUID(),
|
|
596
|
+
name: "Kanban",
|
|
597
|
+
type: "kanban",
|
|
598
|
+
groupBy: statusId,
|
|
599
|
+
sort: [],
|
|
600
|
+
filter: [],
|
|
601
|
+
hiddenFields: []
|
|
602
|
+
}
|
|
603
|
+
],
|
|
604
|
+
records: []
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
function createTaskDatabaseTemplate(name) {
|
|
608
|
+
const titleId = crypto.randomUUID();
|
|
609
|
+
const statusId = crypto.randomUUID();
|
|
610
|
+
const priorityId = crypto.randomUUID();
|
|
611
|
+
const dueDateId = crypto.randomUUID();
|
|
612
|
+
const effortId = crypto.randomUUID();
|
|
613
|
+
const blockedId = crypto.randomUUID();
|
|
614
|
+
const projectsId = crypto.randomUUID();
|
|
615
|
+
return {
|
|
616
|
+
id: crypto.randomUUID(),
|
|
617
|
+
name,
|
|
618
|
+
schema: [
|
|
619
|
+
{ id: titleId, name: "Title", type: "text" },
|
|
620
|
+
{ id: statusId, name: "Status", type: "status", options: [...STATUS_OPTIONS] },
|
|
621
|
+
{ id: priorityId, name: "Priority", type: "select", options: ["Low", "Medium", "High"] },
|
|
622
|
+
{ id: dueDateId, name: "Due Date", type: "date" },
|
|
623
|
+
{ id: effortId, name: "Effort", type: "number" },
|
|
624
|
+
{ id: blockedId, name: "Blocked", type: "checkbox" },
|
|
625
|
+
{ id: projectsId, name: "Project", type: "relation", relation: {} }
|
|
626
|
+
],
|
|
627
|
+
views: [
|
|
628
|
+
{
|
|
629
|
+
id: crypto.randomUUID(),
|
|
630
|
+
name: "All Tasks",
|
|
631
|
+
type: "table",
|
|
632
|
+
sort: [{ fieldId: dueDateId, direction: "asc" }],
|
|
633
|
+
filter: [],
|
|
634
|
+
hiddenFields: []
|
|
635
|
+
},
|
|
636
|
+
{
|
|
637
|
+
id: crypto.randomUUID(),
|
|
638
|
+
name: "By Status",
|
|
639
|
+
type: "kanban",
|
|
640
|
+
groupBy: statusId,
|
|
641
|
+
sort: [],
|
|
642
|
+
filter: [],
|
|
643
|
+
hiddenFields: []
|
|
644
|
+
},
|
|
645
|
+
{
|
|
646
|
+
id: crypto.randomUUID(),
|
|
647
|
+
name: "Calendar",
|
|
648
|
+
type: "calendar",
|
|
649
|
+
groupBy: dueDateId,
|
|
650
|
+
sort: [],
|
|
651
|
+
filter: [],
|
|
652
|
+
hiddenFields: []
|
|
653
|
+
}
|
|
654
|
+
],
|
|
655
|
+
records: []
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
function isTasksDatabaseName(name) {
|
|
659
|
+
return /\btask(s)?\b/i.test(name.trim());
|
|
660
|
+
}
|
|
661
|
+
function createDatabase(name) {
|
|
662
|
+
return isTasksDatabaseName(name) ? createTaskDatabaseTemplate(name) : createDefaultDatabaseTemplate(name);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// src/csv.ts
|
|
666
|
+
function exportCsv(db) {
|
|
667
|
+
const { schema } = db;
|
|
668
|
+
const headers = ["id", ...schema.map((f) => f.name)];
|
|
669
|
+
const rows = [headers.map(csvEscape).join(",")];
|
|
670
|
+
for (const record of db.records) {
|
|
671
|
+
const cells = [
|
|
672
|
+
csvEscape(record.id),
|
|
673
|
+
...schema.map((f) => {
|
|
674
|
+
const val = record[f.id];
|
|
675
|
+
if (val === null || val === void 0) return "";
|
|
676
|
+
if (Array.isArray(val)) return csvEscape(val.join(";"));
|
|
677
|
+
return csvEscape(String(val));
|
|
678
|
+
})
|
|
679
|
+
];
|
|
680
|
+
rows.push(cells.join(","));
|
|
681
|
+
}
|
|
682
|
+
return rows.join("\r\n");
|
|
683
|
+
}
|
|
684
|
+
function importCsvRecords(db, csvText, fieldMap) {
|
|
685
|
+
const lines = csvText.split(/\r?\n/).filter((l) => l.trim());
|
|
686
|
+
if (lines.length < 2) return [];
|
|
687
|
+
const headers = parseCsvRow(lines[0]);
|
|
688
|
+
const records = [];
|
|
689
|
+
for (let i = 1; i < lines.length; i++) {
|
|
690
|
+
const cells = parseCsvRow(lines[i]);
|
|
691
|
+
const record = { id: crypto.randomUUID() };
|
|
692
|
+
for (let j = 0; j < headers.length; j++) {
|
|
693
|
+
const csvHeader = headers[j];
|
|
694
|
+
const fieldId = fieldMap[csvHeader];
|
|
695
|
+
if (!fieldId) continue;
|
|
696
|
+
const field = db.schema.find((f) => f.id === fieldId);
|
|
697
|
+
if (!field) continue;
|
|
698
|
+
const raw = cells[j] ?? "";
|
|
699
|
+
record[fieldId] = coerceCsvValue(field, raw);
|
|
700
|
+
}
|
|
701
|
+
records.push(record);
|
|
702
|
+
}
|
|
703
|
+
return records;
|
|
704
|
+
}
|
|
705
|
+
function coerceCsvValue(field, raw) {
|
|
706
|
+
if (field.type === "number") {
|
|
707
|
+
return raw === "" ? null : Number(raw);
|
|
708
|
+
}
|
|
709
|
+
if (field.type === "checkbox") {
|
|
710
|
+
return raw.toLowerCase() === "true" || raw === "1";
|
|
711
|
+
}
|
|
712
|
+
if (field.type === "multiselect") {
|
|
713
|
+
return raw ? raw.split(";").map((s) => s.trim()) : [];
|
|
714
|
+
}
|
|
715
|
+
return raw || null;
|
|
716
|
+
}
|
|
717
|
+
function csvEscape(val) {
|
|
718
|
+
if (val.includes(",") || val.includes('"') || val.includes("\n")) {
|
|
719
|
+
return `"${val.replace(/"/g, '""')}"`;
|
|
720
|
+
}
|
|
721
|
+
return val;
|
|
722
|
+
}
|
|
723
|
+
function parseCsvRow(line) {
|
|
724
|
+
const cells = [];
|
|
725
|
+
let cur = "";
|
|
726
|
+
let inQuotes = false;
|
|
727
|
+
for (let i = 0; i < line.length; i++) {
|
|
728
|
+
const ch = line[i];
|
|
729
|
+
if (ch === '"') {
|
|
730
|
+
if (inQuotes && line[i + 1] === '"') {
|
|
731
|
+
cur += '"';
|
|
732
|
+
i++;
|
|
733
|
+
} else {
|
|
734
|
+
inQuotes = !inQuotes;
|
|
735
|
+
}
|
|
736
|
+
} else if (ch === "," && !inQuotes) {
|
|
737
|
+
cells.push(cur);
|
|
738
|
+
cur = "";
|
|
739
|
+
} else {
|
|
740
|
+
cur += ch;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
cells.push(cur);
|
|
744
|
+
return cells;
|
|
745
|
+
}
|
|
746
|
+
export {
|
|
747
|
+
DEFAULT_OPTION_COLORS,
|
|
748
|
+
STATUS_GROUPS,
|
|
749
|
+
STATUS_OPTIONS,
|
|
750
|
+
applyFilters,
|
|
751
|
+
applySorts,
|
|
752
|
+
coerceValueForField,
|
|
753
|
+
computeFormulaValue,
|
|
754
|
+
computeRollupValue,
|
|
755
|
+
createDatabase,
|
|
756
|
+
createDefaultDatabaseTemplate,
|
|
757
|
+
createTaskDatabaseTemplate,
|
|
758
|
+
evaluateFormulaToken,
|
|
759
|
+
exportCsv,
|
|
760
|
+
getFieldDisplayValue,
|
|
761
|
+
getFieldOptionColor,
|
|
762
|
+
getFieldValue,
|
|
763
|
+
getGlobalDatabasePath,
|
|
764
|
+
getReadableTextColor,
|
|
765
|
+
getRecordTitle,
|
|
766
|
+
getRelationTargetDatabase,
|
|
767
|
+
getStatusColor,
|
|
768
|
+
getVisibleFields,
|
|
769
|
+
importCsvRecords,
|
|
770
|
+
inferImplicitRelationTargets,
|
|
771
|
+
isTasksDatabaseName,
|
|
772
|
+
migrateSchema,
|
|
773
|
+
readDatabaseFile,
|
|
774
|
+
scanAll,
|
|
775
|
+
scanDirectory,
|
|
776
|
+
writeDatabaseFile
|
|
777
|
+
};
|
|
778
|
+
//# sourceMappingURL=index.js.map
|