wizards 0.2.17 → 0.2.18
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/apps/runtime/dist/agents/project-assistant.js +20 -2
- package/apps/runtime/dist/authoring/guide.js +6 -3
- package/apps/runtime/dist/db/migrations/tenant/0009_knowledge.sql +104 -0
- package/apps/runtime/dist/db/migrations/tenant/meta/0009_snapshot.json +2837 -0
- package/apps/runtime/dist/db/migrations/tenant/meta/_journal.json +7 -0
- package/apps/runtime/dist/db/schema.js +160 -8
- package/apps/runtime/dist/engine/steps.js +3 -11
- package/apps/runtime/dist/env.js +2 -0
- package/apps/runtime/dist/files/storage.js +15 -0
- package/apps/runtime/dist/models.js +38 -0
- package/apps/runtime/dist/plugins/loader.js +2 -0
- package/apps/runtime/dist/routes/studio.js +147 -6
- package/apps/runtime/dist/services/cloud.js +59 -1
- package/apps/runtime/dist/services/knowledge-model.js +558 -0
- package/apps/runtime/dist/services/knowledge.js +721 -0
- package/apps/runtime/dist/services/project-files.js +114 -6
- package/apps/runtime/dist/services/project-index.js +632 -94
- package/apps/runtime/dist/services/relevance.js +90 -0
- package/apps/runtime/dist/services/space-categories.js +882 -0
- package/apps/runtime/dist/services/space-data.js +466 -38
- package/apps/runtime/dist/services/space-origin.js +164 -0
- package/apps/runtime/dist/services/spaces.js +155 -1
- package/apps/runtime/dist/tools/pages.js +27 -22
- package/apps/runtime/dist/tools/project.js +130 -40
- package/apps/web/dist/assets/{Account-LVXhagaS.js → Account-Cp1cgqjV.js} +1 -1
- package/apps/web/dist/assets/{ChangelogDialog-DM0KAoy6.js → ChangelogDialog-Chke1qUe.js} +1 -1
- package/apps/web/dist/assets/{ChatPanel-CxOQmtv-.js → ChatPanel-DWrwewr6.js} +1 -1
- package/apps/web/dist/assets/{CreditsDialog-DkW3YT44.js → CreditsDialog-Ckema8Ss.js} +1 -1
- package/apps/web/dist/assets/{EditorPage-hbisE1Wf.js → EditorPage-XX6pfHk5.js} +1 -1
- package/apps/web/dist/assets/{FlowPreview-v7or29ku.js → FlowPreview-DS545n78.js} +1 -1
- package/apps/web/dist/assets/{Harness-RZF-bBmb.js → Harness-H3yXP-2L.js} +1 -1
- package/apps/web/dist/assets/{HomePage-mxG-a_KU.js → HomePage-Bds84ShK.js} +1 -1
- package/apps/web/dist/assets/{ImportWizard-CiPyUt3E.js → ImportWizard-0_NkA8aS.js} +1 -1
- package/apps/web/dist/assets/{LocalRuntime-Dqp9wkN3.js → LocalRuntime-DnABzTOe.js} +1 -1
- package/apps/web/dist/assets/{NewWizardPage-DHwRsi0_.js → NewWizardPage-BqP75V9K.js} +2 -2
- package/apps/web/dist/assets/ProjectPage-DbOvwOdZ.js +5 -0
- package/apps/web/dist/assets/{PublicRunner-TGkOwbAP.js → PublicRunner-CtZ7vxl_.js} +1 -1
- package/apps/web/dist/assets/{RunnerView-DzHGshZj.js → RunnerView-xV5bWclu.js} +1 -1
- package/apps/web/dist/assets/{SettingsPage-CG3Xtfej.js → SettingsPage-DKt55zFh.js} +1 -1
- package/apps/web/dist/assets/{SetupPage-O1GfPlqX.js → SetupPage-c6mMD9zN.js} +1 -1
- package/apps/web/dist/assets/{SharePage-CCNUhtry.js → SharePage-DTKIbMj-.js} +1 -1
- package/apps/web/dist/assets/{api-DB22_B7G.js → api-CVG-xDEn.js} +1 -1
- package/apps/web/dist/assets/changelog-CXXT0zsp.js +1 -0
- package/apps/web/dist/assets/{esm-3TT9i_rv.js → esm-DLM3jdbm.js} +1 -1
- package/apps/web/dist/assets/{index-CLe9Wrpu.js → index-1gmaxDSk.js} +3 -3
- package/apps/web/dist/assets/index-Bwyehu0k.css +1 -0
- package/apps/web/dist/assets/{meta-CEsBCPfA.js → meta-m947x6uG.js} +1 -1
- package/apps/web/dist/assets/{settings-menu-pOcjznGJ.js → settings-menu-DUY6__C3.js} +1 -1
- package/apps/web/dist/assets/ui-BPY2pIh0.js +1 -0
- package/apps/web/dist/index.html +4 -4
- package/apps/web/dist/mcp/flow.html +2 -2
- package/node_modules/@engenty-wizards/plugin-sdk/dist/index.d.ts +164 -0
- package/node_modules/@engenty-wizards/shared/dist/knowledge.d.ts +199 -0
- package/node_modules/@engenty-wizards/shared/dist/knowledge.js +68 -0
- package/node_modules/@engenty-wizards/shared/dist/projects.d.ts +1 -16
- package/node_modules/@engenty-wizards/shared/dist/projects.js +1 -1
- package/node_modules/@engenty-wizards/shared/dist/run.d.ts +8 -0
- package/node_modules/@engenty-wizards/shared/dist/run.js +5 -0
- package/node_modules/@engenty-wizards/shared/dist/space-data.d.ts +20 -0
- package/node_modules/@engenty-wizards/shared/dist/space-data.js +2 -1
- package/package.json +1 -1
- package/apps/web/dist/assets/ProjectPage-Bb7jN_6n.js +0 -4
- package/apps/web/dist/assets/changelog-BkIJdZ7M.js +0 -1
- package/apps/web/dist/assets/index-BPpKz65x.css +0 -1
- package/apps/web/dist/assets/ui-I7QYMX0F.js +0 -1
|
@@ -0,0 +1,882 @@
|
|
|
1
|
+
import { CATEGORY_TYPES, dayOf, } from "@engenty-wizards/shared/knowledge";
|
|
2
|
+
import { and, asc, eq, inArray, isNotNull, or, sql } from "drizzle-orm";
|
|
3
|
+
import { nanoid } from "nanoid";
|
|
4
|
+
import { db, schema } from "../db/client.js";
|
|
5
|
+
import { currentTenant } from "../tenants/tenant.js";
|
|
6
|
+
import { requireWritableProject } from "./access.js";
|
|
7
|
+
import { notFound, ServiceError } from "./errors.js";
|
|
8
|
+
import { queueIndex } from "./project-index.js";
|
|
9
|
+
export function itemKey(ref) {
|
|
10
|
+
if ("pageId" in ref) {
|
|
11
|
+
return `p:${ref.pageId}`;
|
|
12
|
+
}
|
|
13
|
+
if ("rowId" in ref) {
|
|
14
|
+
return `r:${ref.rowId}`;
|
|
15
|
+
}
|
|
16
|
+
if ("tableId" in ref) {
|
|
17
|
+
return `t:${ref.tableId}`;
|
|
18
|
+
}
|
|
19
|
+
return `f:${ref.fileId}`;
|
|
20
|
+
}
|
|
21
|
+
function keyOfRow(r) {
|
|
22
|
+
return r.pageId
|
|
23
|
+
? `p:${r.pageId}`
|
|
24
|
+
: r.rowId
|
|
25
|
+
? `r:${r.rowId}`
|
|
26
|
+
: r.tableId
|
|
27
|
+
? `t:${r.tableId}`
|
|
28
|
+
: `f:${r.fileId}`;
|
|
29
|
+
}
|
|
30
|
+
function refWhere(ref) {
|
|
31
|
+
const t = schema.spaceItemCategory;
|
|
32
|
+
if ("pageId" in ref) {
|
|
33
|
+
return eq(t.pageId, ref.pageId);
|
|
34
|
+
}
|
|
35
|
+
if ("rowId" in ref) {
|
|
36
|
+
return eq(t.rowId, ref.rowId);
|
|
37
|
+
}
|
|
38
|
+
if ("tableId" in ref) {
|
|
39
|
+
return eq(t.tableId, ref.tableId);
|
|
40
|
+
}
|
|
41
|
+
return eq(t.fileId, ref.fileId);
|
|
42
|
+
}
|
|
43
|
+
// --- typed values ---------------------------------------------------------------
|
|
44
|
+
/** A number as people write it: `38 h`, `20.000 €`, `1,5`, `1.234,50`. */
|
|
45
|
+
export function numberOf(value) {
|
|
46
|
+
if (typeof value === "number") {
|
|
47
|
+
return Number.isFinite(value) ? value : null;
|
|
48
|
+
}
|
|
49
|
+
if (typeof value !== "string") {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
let s = value.replace(/[^\d,.-]/g, "");
|
|
53
|
+
if (!/\d/.test(s)) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
if (s.includes(",") && s.includes(".")) {
|
|
57
|
+
s =
|
|
58
|
+
s.lastIndexOf(",") > s.lastIndexOf(".")
|
|
59
|
+
? s.replace(/\./g, "").replace(",", ".")
|
|
60
|
+
: s.replace(/,/g, "");
|
|
61
|
+
}
|
|
62
|
+
else if (s.includes(",")) {
|
|
63
|
+
s = /^-?\d{1,3}(,\d{3}){2,}$/.test(s) ? s.replace(/,/g, "") : s.replace(",", ".");
|
|
64
|
+
}
|
|
65
|
+
else if (/^-?\d{1,3}(\.\d{3})+$/.test(s)) {
|
|
66
|
+
s = s.replace(/\./g, "");
|
|
67
|
+
}
|
|
68
|
+
const n = Number(s);
|
|
69
|
+
return Number.isFinite(n) ? n : null;
|
|
70
|
+
}
|
|
71
|
+
/** A day, or a day and a time, as milliseconds (UTC, as written: no time zone is applied). */
|
|
72
|
+
export function dateOf(value) {
|
|
73
|
+
if (typeof value === "number") {
|
|
74
|
+
return Number.isFinite(value) ? value : null;
|
|
75
|
+
}
|
|
76
|
+
const day = dayOf(value);
|
|
77
|
+
if (!day) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
const [y, m, d] = day.split("-").map(Number);
|
|
81
|
+
const time = typeof value === "string" ? /[T\s](\d{1,2}):(\d{2})/.exec(value) : null;
|
|
82
|
+
const at = Date.UTC(y, m - 1, d, time ? Number(time[1]) : 0, time ? Number(time[2]) : 0);
|
|
83
|
+
return Number.isNaN(at) ? null : at;
|
|
84
|
+
}
|
|
85
|
+
/** A date as it is shown and given to steps: `2026-03-14`, or `2026-03-14 07:30`. */
|
|
86
|
+
export function dateText(at) {
|
|
87
|
+
const iso = new Date(at).toISOString();
|
|
88
|
+
return iso.slice(11, 16) === "00:00"
|
|
89
|
+
? iso.slice(0, 10)
|
|
90
|
+
: `${iso.slice(0, 10)} ${iso.slice(11, 16)}`;
|
|
91
|
+
}
|
|
92
|
+
function boolOf(value) {
|
|
93
|
+
if (typeof value === "boolean") {
|
|
94
|
+
return value;
|
|
95
|
+
}
|
|
96
|
+
const s = String(value ?? "")
|
|
97
|
+
.trim()
|
|
98
|
+
.toLowerCase();
|
|
99
|
+
if (["ja", "yes", "true", "x", "1", "wahr", "y", "j"].includes(s)) {
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
if (["nein", "no", "false", "0", "falsch", "n", "-"].includes(s)) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
/** A value checked against its Kategorie's type; null when it is not one. */
|
|
108
|
+
function typed(category, value) {
|
|
109
|
+
if (value === null || value === undefined || value === "") {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
switch (category.type) {
|
|
113
|
+
case "choice": {
|
|
114
|
+
const s = String(value).replace(/\s+/g, " ").trim().slice(0, 200);
|
|
115
|
+
return s ? { choice: s } : null;
|
|
116
|
+
}
|
|
117
|
+
case "text": {
|
|
118
|
+
const s = String(value).replace(/\s+/g, " ").trim().slice(0, 300);
|
|
119
|
+
return s ? { text: s } : null;
|
|
120
|
+
}
|
|
121
|
+
case "number": {
|
|
122
|
+
const n = numberOf(value);
|
|
123
|
+
return n === null ? null : { num: n };
|
|
124
|
+
}
|
|
125
|
+
case "date": {
|
|
126
|
+
const at = dateOf(value);
|
|
127
|
+
return at === null ? null : { at };
|
|
128
|
+
}
|
|
129
|
+
case "boolean": {
|
|
130
|
+
const b = boolOf(value);
|
|
131
|
+
return b === null ? null : { bool: b };
|
|
132
|
+
}
|
|
133
|
+
default:
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function cellOf(row, values) {
|
|
138
|
+
if (row.valueId) {
|
|
139
|
+
return values.get(row.valueId) ?? null;
|
|
140
|
+
}
|
|
141
|
+
if (row.at !== null) {
|
|
142
|
+
return dateText(row.at);
|
|
143
|
+
}
|
|
144
|
+
if (row.num !== null) {
|
|
145
|
+
return row.num;
|
|
146
|
+
}
|
|
147
|
+
if (row.bool !== null) {
|
|
148
|
+
return row.bool;
|
|
149
|
+
}
|
|
150
|
+
return row.text;
|
|
151
|
+
}
|
|
152
|
+
// --- the Kategorien of a space ---------------------------------------------------
|
|
153
|
+
export async function categoryRows(projectId) {
|
|
154
|
+
return db.query.spaceCategory.findMany({
|
|
155
|
+
where: eq(schema.spaceCategory.projectId, projectId),
|
|
156
|
+
orderBy: [asc(schema.spaceCategory.position), asc(schema.spaceCategory.name)],
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
/** A Kategorie by its name, as a step or a plugin writes it: case and spaces do not matter. */
|
|
160
|
+
function byName(rows, name) {
|
|
161
|
+
const want = name.replace(/\s+/g, " ").trim().toLowerCase();
|
|
162
|
+
return rows.find((c) => c.name.toLowerCase() === want);
|
|
163
|
+
}
|
|
164
|
+
const norm = (v) => v
|
|
165
|
+
.toLowerCase()
|
|
166
|
+
.normalize("NFKD")
|
|
167
|
+
.replace(/[̀-ͯ]/g, "")
|
|
168
|
+
.replace(/[^a-z0-9]/g, "");
|
|
169
|
+
/** Whether two values look like one written two ways: "Graz-Süd" and "Graz Süd". */
|
|
170
|
+
function alike(a, b) {
|
|
171
|
+
const x = norm(a);
|
|
172
|
+
const y = norm(b);
|
|
173
|
+
if (!(x && y)) {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
if (x === y) {
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
if (Math.min(x.length, y.length) < 6 || Math.abs(x.length - y.length) > 1) {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
// One letter apart.
|
|
183
|
+
let i = 0;
|
|
184
|
+
while (i < x.length && x[i] === y[i]) {
|
|
185
|
+
i++;
|
|
186
|
+
}
|
|
187
|
+
return (x.slice(i + 1) === y.slice(i + 1) ||
|
|
188
|
+
x.slice(i) === y.slice(i + 1) ||
|
|
189
|
+
x.slice(i + 1) === y.slice(i));
|
|
190
|
+
}
|
|
191
|
+
/** The space's Kategorien with their values, how many items have each, and their ranges. */
|
|
192
|
+
export async function listCategories(projectId) {
|
|
193
|
+
const rows = await categoryRows(projectId);
|
|
194
|
+
if (!rows.length) {
|
|
195
|
+
return [];
|
|
196
|
+
}
|
|
197
|
+
const ids = rows.map((r) => r.id);
|
|
198
|
+
const t = schema.spaceItemCategory;
|
|
199
|
+
const [values, counts, ranges] = await Promise.all([
|
|
200
|
+
db.all(sql `
|
|
201
|
+
SELECT v.id, v.category_id, v.value, v.position, v.summary IS NOT NULL AS summary,
|
|
202
|
+
(SELECT count(*) FROM space_item_category i WHERE i.value_id = v.id) AS n
|
|
203
|
+
FROM space_category_value v
|
|
204
|
+
WHERE v.category_id IN (${sql.join(ids.map((id) => sql `${id}`), sql `, `)})
|
|
205
|
+
ORDER BY v.position, v.value`),
|
|
206
|
+
db
|
|
207
|
+
.select({
|
|
208
|
+
categoryId: t.categoryId,
|
|
209
|
+
n: sql `count(DISTINCT coalesce(${t.pageId}, ${t.rowId}, ${t.tableId}, ${t.fileId}))`,
|
|
210
|
+
})
|
|
211
|
+
.from(t)
|
|
212
|
+
.where(inArray(t.categoryId, ids))
|
|
213
|
+
.groupBy(t.categoryId),
|
|
214
|
+
db
|
|
215
|
+
.select({
|
|
216
|
+
categoryId: t.categoryId,
|
|
217
|
+
minNum: sql `min(${t.num})`,
|
|
218
|
+
maxNum: sql `max(${t.num})`,
|
|
219
|
+
minAt: sql `min(${t.at})`,
|
|
220
|
+
maxAt: sql `max(${t.at})`,
|
|
221
|
+
})
|
|
222
|
+
.from(t)
|
|
223
|
+
.where(inArray(t.categoryId, ids))
|
|
224
|
+
.groupBy(t.categoryId),
|
|
225
|
+
]);
|
|
226
|
+
return rows.map((c) => {
|
|
227
|
+
const own = values.filter((v) => v.category_id === c.id);
|
|
228
|
+
const range = ranges.find((r) => r.categoryId === c.id);
|
|
229
|
+
const category = {
|
|
230
|
+
id: c.id,
|
|
231
|
+
name: c.name,
|
|
232
|
+
type: c.type,
|
|
233
|
+
unit: c.unit,
|
|
234
|
+
multiple: c.multiple,
|
|
235
|
+
ordered: c.ordered,
|
|
236
|
+
hint: c.hint,
|
|
237
|
+
origin: c.origin,
|
|
238
|
+
proposed: c.proposed,
|
|
239
|
+
tableId: c.tableId,
|
|
240
|
+
columnId: c.columnId,
|
|
241
|
+
count: counts.find((x) => x.categoryId === c.id)?.n ?? 0,
|
|
242
|
+
values: own.map((v) => ({
|
|
243
|
+
id: v.id,
|
|
244
|
+
value: v.value,
|
|
245
|
+
count: v.n,
|
|
246
|
+
summary: Boolean(v.summary),
|
|
247
|
+
})),
|
|
248
|
+
range: c.type === "number" && range?.minNum !== null && range?.minNum !== undefined
|
|
249
|
+
? { min: range.minNum, max: range.maxNum ?? range.minNum }
|
|
250
|
+
: c.type === "date" && range?.minAt !== null && range?.minAt !== undefined
|
|
251
|
+
? { min: dateText(range.minAt), max: dateText(range.maxAt ?? range.minAt) }
|
|
252
|
+
: null,
|
|
253
|
+
alike: [],
|
|
254
|
+
};
|
|
255
|
+
category.alike = alikeValues(category.values);
|
|
256
|
+
return category;
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
/** Pairs of a choice's values that look like one value written two ways, to offer merging. */
|
|
260
|
+
function alikeValues(list) {
|
|
261
|
+
const pairs = [];
|
|
262
|
+
for (let i = 0; i < list.length; i++) {
|
|
263
|
+
for (let j = i + 1; j < list.length; j++) {
|
|
264
|
+
if (alike(list[i].value, list[j].value)) {
|
|
265
|
+
pairs.push([list[i].id, list[j].id]);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return pairs.slice(0, 20);
|
|
270
|
+
}
|
|
271
|
+
const MAX_CATEGORIES = 60;
|
|
272
|
+
function checkDraft(draft) {
|
|
273
|
+
const name = draft.name.replace(/\s+/g, " ").trim().slice(0, 80);
|
|
274
|
+
if (!name) {
|
|
275
|
+
throw new ServiceError("invalid", "Eine Kategorie braucht einen Namen.");
|
|
276
|
+
}
|
|
277
|
+
if (!CATEGORY_TYPES.includes(draft.type)) {
|
|
278
|
+
throw new ServiceError("invalid", `Unbekannter Typ „${draft.type}“.`);
|
|
279
|
+
}
|
|
280
|
+
return name;
|
|
281
|
+
}
|
|
282
|
+
async function addValues(categoryId, values) {
|
|
283
|
+
const existing = await db.query.spaceCategoryValue.findMany({
|
|
284
|
+
where: eq(schema.spaceCategoryValue.categoryId, categoryId),
|
|
285
|
+
columns: { id: true, value: true, position: true },
|
|
286
|
+
});
|
|
287
|
+
let position = existing.reduce((max, v) => Math.max(max, v.position + 1), 0);
|
|
288
|
+
const ids = new Map(existing.map((v) => [v.value, v.id]));
|
|
289
|
+
for (const raw of values) {
|
|
290
|
+
const value = raw.replace(/\s+/g, " ").trim().slice(0, 200);
|
|
291
|
+
if (!value || ids.has(value)) {
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
// Differently cased, it is the same value.
|
|
295
|
+
const same = [...ids.keys()].find((v) => v.toLowerCase() === value.toLowerCase());
|
|
296
|
+
if (same) {
|
|
297
|
+
ids.set(value, ids.get(same));
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
const id = nanoid(12);
|
|
301
|
+
await db
|
|
302
|
+
.insert(schema.spaceCategoryValue)
|
|
303
|
+
.values({ id, categoryId, value, position: position++ })
|
|
304
|
+
.onConflictDoNothing();
|
|
305
|
+
ids.set(value, id);
|
|
306
|
+
}
|
|
307
|
+
return ids;
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Adds a Kategorie, or, by a name the space has already, leaves that one as it is apart from
|
|
311
|
+
* values it does not know yet. `proposed`: shown to be confirmed, never filled before.
|
|
312
|
+
*/
|
|
313
|
+
export async function putCategory(projectId, draft, opts = {}) {
|
|
314
|
+
const name = checkDraft(draft);
|
|
315
|
+
const rows = await categoryRows(projectId);
|
|
316
|
+
const known = byName(rows, name);
|
|
317
|
+
if (known) {
|
|
318
|
+
if (draft.values?.length && known.type === "choice") {
|
|
319
|
+
await addValues(known.id, draft.values);
|
|
320
|
+
}
|
|
321
|
+
// A proposal taken again by its origin, or made by a person, is taken.
|
|
322
|
+
if (known.proposed && !opts.proposed) {
|
|
323
|
+
await db
|
|
324
|
+
.update(schema.spaceCategory)
|
|
325
|
+
.set({ proposed: false, updatedAt: new Date() })
|
|
326
|
+
.where(eq(schema.spaceCategory.id, known.id));
|
|
327
|
+
return { ...known, proposed: false };
|
|
328
|
+
}
|
|
329
|
+
return known;
|
|
330
|
+
}
|
|
331
|
+
if (rows.length >= MAX_CATEGORIES) {
|
|
332
|
+
throw new ServiceError("refused", `Ein Space hat höchstens ${MAX_CATEGORIES} Kategorien.`);
|
|
333
|
+
}
|
|
334
|
+
const id = nanoid(12);
|
|
335
|
+
const [row] = await db
|
|
336
|
+
.insert(schema.spaceCategory)
|
|
337
|
+
.values({
|
|
338
|
+
id,
|
|
339
|
+
tenantId: currentTenant(),
|
|
340
|
+
projectId,
|
|
341
|
+
name,
|
|
342
|
+
type: draft.type,
|
|
343
|
+
unit: draft.type === "number" ? (draft.unit?.trim().slice(0, 20) ?? null) : null,
|
|
344
|
+
multiple: draft.type === "choice" && Boolean(draft.multiple),
|
|
345
|
+
ordered: draft.type === "choice" && Boolean(draft.ordered),
|
|
346
|
+
hint: draft.hint?.trim().slice(0, 120) || null,
|
|
347
|
+
origin: opts.origin ?? null,
|
|
348
|
+
proposed: Boolean(opts.proposed),
|
|
349
|
+
tableId: draft.tableId ?? null,
|
|
350
|
+
columnId: draft.tableId ? (draft.columnId ?? null) : null,
|
|
351
|
+
position: rows.reduce((max, r) => Math.max(max, r.position + 1), 0),
|
|
352
|
+
})
|
|
353
|
+
.returning();
|
|
354
|
+
if (draft.type === "choice" && draft.values?.length) {
|
|
355
|
+
await addValues(id, draft.values);
|
|
356
|
+
}
|
|
357
|
+
if (row.tableId && row.columnId && !row.proposed) {
|
|
358
|
+
await syncColumnCategory(row);
|
|
359
|
+
}
|
|
360
|
+
return row;
|
|
361
|
+
}
|
|
362
|
+
export async function categoryRow(categoryId) {
|
|
363
|
+
const row = await db.query.spaceCategory.findFirst({
|
|
364
|
+
where: and(eq(schema.spaceCategory.id, categoryId), eq(schema.spaceCategory.tenantId, currentTenant())),
|
|
365
|
+
});
|
|
366
|
+
if (!row) {
|
|
367
|
+
throw notFound();
|
|
368
|
+
}
|
|
369
|
+
return row;
|
|
370
|
+
}
|
|
371
|
+
/** Every item that has the Kategorie: their text in the index starts with it. */
|
|
372
|
+
async function reindexHolders(categoryId) {
|
|
373
|
+
const rows = await db
|
|
374
|
+
.selectDistinct({
|
|
375
|
+
pageId: schema.spaceItemCategory.pageId,
|
|
376
|
+
rowId: schema.spaceItemCategory.rowId,
|
|
377
|
+
tableId: schema.spaceItemCategory.tableId,
|
|
378
|
+
fileId: schema.spaceItemCategory.fileId,
|
|
379
|
+
})
|
|
380
|
+
.from(schema.spaceItemCategory)
|
|
381
|
+
.where(eq(schema.spaceItemCategory.categoryId, categoryId));
|
|
382
|
+
const tables = new Set();
|
|
383
|
+
for (const r of rows) {
|
|
384
|
+
if (r.rowId) {
|
|
385
|
+
const row = await db.query.spaceTableRow.findFirst({
|
|
386
|
+
where: eq(schema.spaceTableRow.id, r.rowId),
|
|
387
|
+
columns: { tableId: true },
|
|
388
|
+
});
|
|
389
|
+
if (row) {
|
|
390
|
+
tables.add(row.tableId);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
else if (r.tableId) {
|
|
394
|
+
tables.add(r.tableId);
|
|
395
|
+
}
|
|
396
|
+
else {
|
|
397
|
+
queueIndex(keyOfRow(r));
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
for (const id of tables) {
|
|
401
|
+
queueIndex(`t:${id}`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
export async function updateCategory(categoryId, patch) {
|
|
405
|
+
const row = await categoryRow(categoryId);
|
|
406
|
+
await requireWritableProject(row.projectId);
|
|
407
|
+
const name = patch.name !== undefined ? checkDraft({ name: patch.name, type: row.type }) : row.name;
|
|
408
|
+
if (name !== row.name) {
|
|
409
|
+
const clash = byName(await categoryRows(row.projectId), name);
|
|
410
|
+
if (clash && clash.id !== row.id) {
|
|
411
|
+
throw new ServiceError("invalid", `Die Kategorie „${name}“ gibt es schon.`);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
await db
|
|
415
|
+
.update(schema.spaceCategory)
|
|
416
|
+
.set({
|
|
417
|
+
name,
|
|
418
|
+
...(patch.unit !== undefined && row.type === "number"
|
|
419
|
+
? { unit: patch.unit?.trim().slice(0, 20) || null }
|
|
420
|
+
: {}),
|
|
421
|
+
...(patch.multiple !== undefined && row.type === "choice"
|
|
422
|
+
? { multiple: patch.multiple }
|
|
423
|
+
: {}),
|
|
424
|
+
...(patch.ordered !== undefined && row.type === "choice" ? { ordered: patch.ordered } : {}),
|
|
425
|
+
...(patch.hint !== undefined ? { hint: patch.hint?.trim().slice(0, 120) || null } : {}),
|
|
426
|
+
...(patch.proposed === false ? { proposed: false } : {}),
|
|
427
|
+
...(patch.position !== undefined ? { position: patch.position } : {}),
|
|
428
|
+
updatedAt: new Date(),
|
|
429
|
+
})
|
|
430
|
+
.where(eq(schema.spaceCategory.id, row.id));
|
|
431
|
+
if (patch.proposed === false && row.tableId && row.columnId) {
|
|
432
|
+
await syncColumnCategory({ ...row, proposed: false });
|
|
433
|
+
}
|
|
434
|
+
if (name !== row.name || patch.unit !== undefined) {
|
|
435
|
+
await reindexHolders(row.id);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
export async function deleteCategory(categoryId) {
|
|
439
|
+
const row = await categoryRow(categoryId);
|
|
440
|
+
await requireWritableProject(row.projectId);
|
|
441
|
+
await reindexHolders(row.id);
|
|
442
|
+
await db.delete(schema.spaceCategory).where(eq(schema.spaceCategory.id, row.id));
|
|
443
|
+
}
|
|
444
|
+
async function valueRow(valueId) {
|
|
445
|
+
const value = await db.query.spaceCategoryValue.findFirst({
|
|
446
|
+
where: eq(schema.spaceCategoryValue.id, valueId),
|
|
447
|
+
});
|
|
448
|
+
if (!value) {
|
|
449
|
+
throw notFound();
|
|
450
|
+
}
|
|
451
|
+
const category = await categoryRow(value.categoryId);
|
|
452
|
+
return { value, category };
|
|
453
|
+
}
|
|
454
|
+
export async function categoryValue(valueId) {
|
|
455
|
+
return valueRow(valueId);
|
|
456
|
+
}
|
|
457
|
+
export async function addCategoryValue(categoryId, value) {
|
|
458
|
+
const category = await categoryRow(categoryId);
|
|
459
|
+
await requireWritableProject(category.projectId);
|
|
460
|
+
if (category.type !== "choice") {
|
|
461
|
+
throw new ServiceError("invalid", "Nur eine Auswahl hat Werte.");
|
|
462
|
+
}
|
|
463
|
+
const ids = await addValues(category.id, [value]);
|
|
464
|
+
return { id: [...ids.values()].at(-1) };
|
|
465
|
+
}
|
|
466
|
+
export async function renameCategoryValue(valueId, value) {
|
|
467
|
+
const { value: row, category } = await valueRow(valueId);
|
|
468
|
+
await requireWritableProject(category.projectId);
|
|
469
|
+
const next = value.replace(/\s+/g, " ").trim().slice(0, 200);
|
|
470
|
+
if (!next || next === row.value) {
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
const clash = await db.query.spaceCategoryValue.findFirst({
|
|
474
|
+
where: and(eq(schema.spaceCategoryValue.categoryId, category.id), eq(schema.spaceCategoryValue.value, next)),
|
|
475
|
+
});
|
|
476
|
+
if (clash) {
|
|
477
|
+
await mergeCategoryValues(row.id, clash.id);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
await db
|
|
481
|
+
.update(schema.spaceCategoryValue)
|
|
482
|
+
.set({ value: next })
|
|
483
|
+
.where(eq(schema.spaceCategoryValue.id, row.id));
|
|
484
|
+
await reindexHolders(category.id);
|
|
485
|
+
}
|
|
486
|
+
/** Puts the items of one value under another and drops the first: two spellings become one. */
|
|
487
|
+
export async function mergeCategoryValues(fromId, intoId) {
|
|
488
|
+
const from = await valueRow(fromId);
|
|
489
|
+
const into = await valueRow(intoId);
|
|
490
|
+
if (from.category.id !== into.category.id || fromId === intoId) {
|
|
491
|
+
throw new ServiceError("invalid", "Nur Werte einer Kategorie lassen sich zusammenführen.");
|
|
492
|
+
}
|
|
493
|
+
await requireWritableProject(from.category.projectId);
|
|
494
|
+
const t = schema.spaceItemCategory;
|
|
495
|
+
const moving = await db.query.spaceItemCategory.findMany({ where: eq(t.valueId, fromId) });
|
|
496
|
+
const having = new Set((await db.query.spaceItemCategory.findMany({ where: eq(t.valueId, intoId) })).map(keyOfRow));
|
|
497
|
+
for (const row of moving) {
|
|
498
|
+
if (having.has(keyOfRow(row))) {
|
|
499
|
+
await db.delete(t).where(eq(t.id, row.id));
|
|
500
|
+
}
|
|
501
|
+
else {
|
|
502
|
+
await db.update(t).set({ valueId: intoId }).where(eq(t.id, row.id));
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
await db.delete(schema.spaceCategoryValue).where(eq(schema.spaceCategoryValue.id, fromId));
|
|
506
|
+
// The Übersicht of the value that grew is no longer all of it.
|
|
507
|
+
await db
|
|
508
|
+
.update(schema.spaceCategoryValue)
|
|
509
|
+
.set({ summaryAt: null })
|
|
510
|
+
.where(eq(schema.spaceCategoryValue.id, intoId));
|
|
511
|
+
await reindexHolders(from.category.id);
|
|
512
|
+
}
|
|
513
|
+
export async function deleteCategoryValue(valueId) {
|
|
514
|
+
const { value, category } = await valueRow(valueId);
|
|
515
|
+
await requireWritableProject(category.projectId);
|
|
516
|
+
await reindexHolders(category.id);
|
|
517
|
+
await db.delete(schema.spaceCategoryValue).where(eq(schema.spaceCategoryValue.id, value.id));
|
|
518
|
+
}
|
|
519
|
+
export async function orderCategoryValues(categoryId, ids) {
|
|
520
|
+
const category = await categoryRow(categoryId);
|
|
521
|
+
await requireWritableProject(category.projectId);
|
|
522
|
+
for (const [position, id] of ids.entries()) {
|
|
523
|
+
await db
|
|
524
|
+
.update(schema.spaceCategoryValue)
|
|
525
|
+
.set({ position })
|
|
526
|
+
.where(and(eq(schema.spaceCategoryValue.id, id), eq(schema.spaceCategoryValue.categoryId, category.id)));
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
// --- on items ---------------------------------------------------------------------
|
|
530
|
+
/**
|
|
531
|
+
* Sets Kategorien on an item, by their names. A value null takes the Kategorie off. What a
|
|
532
|
+
* person set stays when an origin or a model writes again. Names the space has no taken
|
|
533
|
+
* Kategorie for are skipped and returned. True in `changed` when anything changed.
|
|
534
|
+
*/
|
|
535
|
+
export async function setItemCategories(projectId, ref, input, by) {
|
|
536
|
+
const rows = (await categoryRows(projectId)).filter((c) => !c.proposed);
|
|
537
|
+
const t = schema.spaceItemCategory;
|
|
538
|
+
const current = await db.query.spaceItemCategory.findMany({ where: refWhere(ref) });
|
|
539
|
+
const unknown = [];
|
|
540
|
+
let changed = false;
|
|
541
|
+
for (const [name, raw] of Object.entries(input)) {
|
|
542
|
+
const category = byName(rows, name);
|
|
543
|
+
if (!category) {
|
|
544
|
+
unknown.push(name);
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
const mine = current.filter((r) => r.categoryId === category.id);
|
|
548
|
+
if (by !== "person" && mine.some((r) => r.by === "person")) {
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
const list = (Array.isArray(raw) ? raw : [raw])
|
|
552
|
+
.map((v) => typed(category, v))
|
|
553
|
+
.filter((v) => v !== null);
|
|
554
|
+
const values = category.type === "choice" && category.multiple ? list : list.slice(0, 1);
|
|
555
|
+
const ids = category.type === "choice"
|
|
556
|
+
? await addValues(category.id, values.map((v) => v.choice))
|
|
557
|
+
: null;
|
|
558
|
+
const next = values.map((v) => ({
|
|
559
|
+
valueId: v.choice !== undefined ? (ids?.get(v.choice) ?? null) : null,
|
|
560
|
+
text: v.text ?? null,
|
|
561
|
+
num: v.num ?? null,
|
|
562
|
+
at: v.at ?? null,
|
|
563
|
+
bool: v.bool ?? null,
|
|
564
|
+
}));
|
|
565
|
+
const same = mine.length === next.length &&
|
|
566
|
+
next.every((n) => mine.some((m) => m.valueId === n.valueId &&
|
|
567
|
+
m.text === n.text &&
|
|
568
|
+
m.num === n.num &&
|
|
569
|
+
m.at === n.at &&
|
|
570
|
+
m.bool === n.bool));
|
|
571
|
+
if (same && mine.every((m) => m.by === by)) {
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
changed = true;
|
|
575
|
+
if (mine.length) {
|
|
576
|
+
await db.delete(t).where(inArray(t.id, mine.map((m) => m.id)));
|
|
577
|
+
}
|
|
578
|
+
if (next.length) {
|
|
579
|
+
await db.insert(t).values(next.map((n) => ({
|
|
580
|
+
projectId,
|
|
581
|
+
categoryId: category.id,
|
|
582
|
+
...("pageId" in ref ? { pageId: ref.pageId } : {}),
|
|
583
|
+
...("rowId" in ref ? { rowId: ref.rowId } : {}),
|
|
584
|
+
...("tableId" in ref ? { tableId: ref.tableId } : {}),
|
|
585
|
+
...("fileId" in ref ? { fileId: ref.fileId } : {}),
|
|
586
|
+
...n,
|
|
587
|
+
by,
|
|
588
|
+
})));
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
return { changed, unknown };
|
|
592
|
+
}
|
|
593
|
+
/** Whether an item is of the space's own Wissen. */
|
|
594
|
+
async function itemInProject(projectId, ref) {
|
|
595
|
+
if ("pageId" in ref) {
|
|
596
|
+
const page = await db.query.spacePage.findFirst({
|
|
597
|
+
where: eq(schema.spacePage.id, ref.pageId),
|
|
598
|
+
columns: { projectId: true, wizardId: true },
|
|
599
|
+
});
|
|
600
|
+
return page?.projectId === projectId && !page.wizardId;
|
|
601
|
+
}
|
|
602
|
+
if ("fileId" in ref) {
|
|
603
|
+
const file = await db.query.projectFile.findFirst({
|
|
604
|
+
where: eq(schema.projectFile.id, ref.fileId),
|
|
605
|
+
columns: { projectId: true },
|
|
606
|
+
});
|
|
607
|
+
return file?.projectId === projectId;
|
|
608
|
+
}
|
|
609
|
+
const tableId = "tableId" in ref
|
|
610
|
+
? ref.tableId
|
|
611
|
+
: (await db.query.spaceTableRow.findFirst({
|
|
612
|
+
where: eq(schema.spaceTableRow.id, ref.rowId),
|
|
613
|
+
columns: { tableId: true },
|
|
614
|
+
}))?.tableId;
|
|
615
|
+
const table = tableId
|
|
616
|
+
? await db.query.spaceTable.findFirst({
|
|
617
|
+
where: eq(schema.spaceTable.id, tableId),
|
|
618
|
+
columns: { projectId: true, wizardId: true },
|
|
619
|
+
})
|
|
620
|
+
: undefined;
|
|
621
|
+
return table?.projectId === projectId && !table.wizardId;
|
|
622
|
+
}
|
|
623
|
+
/** What a person set from the studio: by the Kategorie's id, a list of values or none. */
|
|
624
|
+
export async function setItemCategory(projectId, ref, categoryId, values) {
|
|
625
|
+
const category = await categoryRow(categoryId);
|
|
626
|
+
if (category.projectId !== projectId || !(await itemInProject(projectId, ref))) {
|
|
627
|
+
throw notFound();
|
|
628
|
+
}
|
|
629
|
+
const { changed } = await setItemCategories(projectId, ref, { [category.name]: values.length ? values : null }, "person");
|
|
630
|
+
return changed;
|
|
631
|
+
}
|
|
632
|
+
/** The Kategorien of items, by `itemKey`. */
|
|
633
|
+
export async function itemCategoriesOf(refs) {
|
|
634
|
+
const out = new Map();
|
|
635
|
+
if (!refs.length) {
|
|
636
|
+
return out;
|
|
637
|
+
}
|
|
638
|
+
const t = schema.spaceItemCategory;
|
|
639
|
+
const ids = (k) => refs
|
|
640
|
+
.filter((r) => k in r)
|
|
641
|
+
.map((r) => r[k]);
|
|
642
|
+
const conditions = [];
|
|
643
|
+
for (const [k, column] of [
|
|
644
|
+
["pageId", t.pageId],
|
|
645
|
+
["rowId", t.rowId],
|
|
646
|
+
["tableId", t.tableId],
|
|
647
|
+
["fileId", t.fileId],
|
|
648
|
+
]) {
|
|
649
|
+
const list = ids(k);
|
|
650
|
+
for (let i = 0; i < list.length; i += 500) {
|
|
651
|
+
conditions.push(inArray(column, list.slice(i, i + 500)));
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
const rows = await db.query.spaceItemCategory.findMany({ where: or(...conditions) });
|
|
655
|
+
const valueIds = [...new Set(rows.map((r) => r.valueId).filter((v) => Boolean(v)))];
|
|
656
|
+
const values = new Map();
|
|
657
|
+
for (let i = 0; i < valueIds.length; i += 500) {
|
|
658
|
+
for (const v of await db.query.spaceCategoryValue.findMany({
|
|
659
|
+
where: inArray(schema.spaceCategoryValue.id, valueIds.slice(i, i + 500)),
|
|
660
|
+
columns: { id: true, value: true },
|
|
661
|
+
})) {
|
|
662
|
+
values.set(v.id, v.value);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
for (const row of rows) {
|
|
666
|
+
const key = keyOfRow(row);
|
|
667
|
+
const list = out.get(key) ?? [];
|
|
668
|
+
const cell = cellOf(row, values);
|
|
669
|
+
if (cell === null) {
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
const known = list.find((c) => c.categoryId === row.categoryId);
|
|
673
|
+
if (known) {
|
|
674
|
+
known.values.push(cell);
|
|
675
|
+
}
|
|
676
|
+
else {
|
|
677
|
+
list.push({ categoryId: row.categoryId, values: [cell], by: row.by });
|
|
678
|
+
}
|
|
679
|
+
out.set(key, list);
|
|
680
|
+
}
|
|
681
|
+
return out;
|
|
682
|
+
}
|
|
683
|
+
/** `Name: Wert` for each Kategorie an item has, in the order of the space's Kategorien. */
|
|
684
|
+
export function categoryLines(categories, has) {
|
|
685
|
+
if (!has?.length) {
|
|
686
|
+
return [];
|
|
687
|
+
}
|
|
688
|
+
return categories.flatMap((c) => {
|
|
689
|
+
const mine = has.find((h) => h.categoryId === c.id);
|
|
690
|
+
if (!mine) {
|
|
691
|
+
return [];
|
|
692
|
+
}
|
|
693
|
+
const shown = mine.values
|
|
694
|
+
.map((v) => typeof v === "boolean"
|
|
695
|
+
? v
|
|
696
|
+
? "ja"
|
|
697
|
+
: "nein"
|
|
698
|
+
: `${v}${c.unit && typeof v === "number" ? ` ${c.unit}` : ""}`)
|
|
699
|
+
.join(", ");
|
|
700
|
+
return [`${c.name}: ${shown}`];
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
// --- a column as a Kategorie -------------------------------------------------------------
|
|
704
|
+
/** Writes the Kategorie of a column onto the table's rows: their cells are its values. */
|
|
705
|
+
export async function syncColumnCategory(category, rowIds) {
|
|
706
|
+
if (!(category.tableId && category.columnId) || category.proposed) {
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
const rows = await db.query.spaceTableRow.findMany({
|
|
710
|
+
where: rowIds
|
|
711
|
+
? and(eq(schema.spaceTableRow.tableId, category.tableId), inArray(schema.spaceTableRow.id, rowIds))
|
|
712
|
+
: eq(schema.spaceTableRow.tableId, category.tableId),
|
|
713
|
+
columns: { id: true, cells: true },
|
|
714
|
+
});
|
|
715
|
+
for (const row of rows) {
|
|
716
|
+
await setItemCategories(category.projectId, { rowId: row.id }, {
|
|
717
|
+
[category.name]: row.cells[category.columnId] ?? null,
|
|
718
|
+
}, "origin");
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
/** After rows of a table changed: the Kategorien its columns are. */
|
|
722
|
+
export async function syncTableRowCategories(tableId, rowIds) {
|
|
723
|
+
const categories = await db.query.spaceCategory.findMany({
|
|
724
|
+
where: and(eq(schema.spaceCategory.tableId, tableId), isNotNull(schema.spaceCategory.columnId)),
|
|
725
|
+
});
|
|
726
|
+
for (const category of categories) {
|
|
727
|
+
await syncColumnCategory(category, rowIds);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
function condition(category, w, valueIds) {
|
|
731
|
+
const t = schema.spaceItemCategory;
|
|
732
|
+
const list = Array.isArray(w) ? w : null;
|
|
733
|
+
const bad = () => new ServiceError("invalid", `„${category.name}“ is a ${category.type}: ${{
|
|
734
|
+
choice: "give a value, a list of values or { atLeast }",
|
|
735
|
+
text: "give a text, a list or { contains }",
|
|
736
|
+
number: "give a number, a list or { gte, lte }",
|
|
737
|
+
date: 'give a day ("YYYY-MM-DD"), a list or { after, before }',
|
|
738
|
+
boolean: "give true or false",
|
|
739
|
+
}[category.type]}.`);
|
|
740
|
+
switch (category.type) {
|
|
741
|
+
case "choice": {
|
|
742
|
+
if (typeof w === "object" && w !== null && !Array.isArray(w)) {
|
|
743
|
+
const floor = w.atLeast ? valueIds.get(w.atLeast.toLowerCase()) : undefined;
|
|
744
|
+
if (!(floor && category.ordered)) {
|
|
745
|
+
throw bad();
|
|
746
|
+
}
|
|
747
|
+
return sql `${t.valueId} IN (SELECT id FROM space_category_value WHERE category_id = ${category.id}
|
|
748
|
+
AND position >= (SELECT position FROM space_category_value WHERE id = ${floor}))`;
|
|
749
|
+
}
|
|
750
|
+
const wanted = (list ?? [w]).map((v) => valueIds.get(String(v).toLowerCase()) ?? "-");
|
|
751
|
+
return inArray(t.valueId, wanted);
|
|
752
|
+
}
|
|
753
|
+
case "text": {
|
|
754
|
+
if (typeof w === "object" && w !== null && !Array.isArray(w)) {
|
|
755
|
+
if (!w.contains) {
|
|
756
|
+
throw bad();
|
|
757
|
+
}
|
|
758
|
+
return sql `lower(${t.text}) LIKE ${`%${w.contains.toLowerCase()}%`}`;
|
|
759
|
+
}
|
|
760
|
+
return inArray(sql `lower(${t.text})`, (list ?? [w]).map((v) => String(v).toLowerCase()));
|
|
761
|
+
}
|
|
762
|
+
case "number": {
|
|
763
|
+
if (typeof w === "object" && w !== null && !Array.isArray(w)) {
|
|
764
|
+
if (w.gte === undefined && w.lte === undefined) {
|
|
765
|
+
throw bad();
|
|
766
|
+
}
|
|
767
|
+
return and(w.gte !== undefined ? sql `${t.num} >= ${w.gte}` : undefined, w.lte !== undefined ? sql `${t.num} <= ${w.lte}` : undefined);
|
|
768
|
+
}
|
|
769
|
+
const nums = (list ?? [w]).map(numberOf).filter((n) => n !== null);
|
|
770
|
+
if (!nums.length) {
|
|
771
|
+
throw bad();
|
|
772
|
+
}
|
|
773
|
+
return inArray(t.num, nums);
|
|
774
|
+
}
|
|
775
|
+
case "date": {
|
|
776
|
+
const DAY = 86_400_000;
|
|
777
|
+
if (typeof w === "object" && w !== null && !Array.isArray(w)) {
|
|
778
|
+
const after = w.after ? dateOf(w.after) : null;
|
|
779
|
+
const before = w.before ? dateOf(w.before) : null;
|
|
780
|
+
if (after === null && before === null) {
|
|
781
|
+
throw bad();
|
|
782
|
+
}
|
|
783
|
+
return and(after !== null ? sql `${t.at} >= ${after}` : undefined, before !== null ? sql `${t.at} < ${before + DAY}` : undefined);
|
|
784
|
+
}
|
|
785
|
+
const days = (list ?? [w]).map(dateOf).filter((n) => n !== null);
|
|
786
|
+
if (!days.length) {
|
|
787
|
+
throw bad();
|
|
788
|
+
}
|
|
789
|
+
return or(...days.map((d) => sql `(${t.at} >= ${d} AND ${t.at} < ${d + DAY})`));
|
|
790
|
+
}
|
|
791
|
+
case "boolean": {
|
|
792
|
+
const b = boolOf(list ? list[0] : w);
|
|
793
|
+
if (b === null) {
|
|
794
|
+
throw bad();
|
|
795
|
+
}
|
|
796
|
+
return eq(t.bool, b);
|
|
797
|
+
}
|
|
798
|
+
default:
|
|
799
|
+
throw bad();
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
/** Every page under the given ones, the given ones included. */
|
|
803
|
+
async function withSubPages(pageIds) {
|
|
804
|
+
if (!pageIds.length) {
|
|
805
|
+
return [];
|
|
806
|
+
}
|
|
807
|
+
const rows = await db.all(sql `
|
|
808
|
+
WITH RECURSIVE tree(id) AS (
|
|
809
|
+
SELECT id FROM space_page WHERE id IN (${sql.join(pageIds.map((id) => sql `${id}`), sql `, `)})
|
|
810
|
+
UNION SELECT p.id FROM space_page p JOIN tree ON p.parent_id = tree.id
|
|
811
|
+
) SELECT id FROM tree`);
|
|
812
|
+
return rows.map((r) => r.id);
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* The items of the space a filter leaves: an item matches when it, or the page or table it
|
|
816
|
+
* belongs to, has every Kategorie as asked. Null for no filter. A name the space has no
|
|
817
|
+
* Kategorie for is an error that lists the names there are.
|
|
818
|
+
*/
|
|
819
|
+
export async function filterItems(projectId, where) {
|
|
820
|
+
const entries = Object.entries(where ?? {});
|
|
821
|
+
if (!entries.length) {
|
|
822
|
+
return null;
|
|
823
|
+
}
|
|
824
|
+
const rows = (await categoryRows(projectId)).filter((c) => !c.proposed);
|
|
825
|
+
let result = null;
|
|
826
|
+
for (const [name, w] of entries) {
|
|
827
|
+
const category = byName(rows, name);
|
|
828
|
+
if (!category) {
|
|
829
|
+
throw new ServiceError("invalid", `No Kategorie "${name}". There are: ${rows.map((c) => `${c.name} (${c.type})`).join(", ") || "none"}.`);
|
|
830
|
+
}
|
|
831
|
+
const valueIds = new Map((await db.query.spaceCategoryValue.findMany({
|
|
832
|
+
where: eq(schema.spaceCategoryValue.categoryId, category.id),
|
|
833
|
+
columns: { id: true, value: true },
|
|
834
|
+
})).map((v) => [v.value.toLowerCase(), v.id]));
|
|
835
|
+
const t = schema.spaceItemCategory;
|
|
836
|
+
const matched = await db
|
|
837
|
+
.select({ pageId: t.pageId, rowId: t.rowId, tableId: t.tableId, fileId: t.fileId })
|
|
838
|
+
.from(t)
|
|
839
|
+
.where(and(eq(t.categoryId, category.id), condition(category, w, valueIds)));
|
|
840
|
+
const keys = new Set();
|
|
841
|
+
const pages = await withSubPages(matched.flatMap((m) => (m.pageId ? [m.pageId] : [])));
|
|
842
|
+
for (const id of pages) {
|
|
843
|
+
keys.add(`p:${id}`);
|
|
844
|
+
}
|
|
845
|
+
const tables = matched.flatMap((m) => (m.tableId ? [m.tableId] : []));
|
|
846
|
+
for (const id of tables) {
|
|
847
|
+
keys.add(`t:${id}`);
|
|
848
|
+
}
|
|
849
|
+
for (let i = 0; i < tables.length; i += 500) {
|
|
850
|
+
for (const r of await db.query.spaceTableRow.findMany({
|
|
851
|
+
where: inArray(schema.spaceTableRow.tableId, tables.slice(i, i + 500)),
|
|
852
|
+
columns: { id: true },
|
|
853
|
+
})) {
|
|
854
|
+
keys.add(`r:${r.id}`);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
for (const m of matched) {
|
|
858
|
+
if (m.rowId) {
|
|
859
|
+
keys.add(`r:${m.rowId}`);
|
|
860
|
+
}
|
|
861
|
+
if (m.fileId) {
|
|
862
|
+
keys.add(`f:${m.fileId}`);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
result = result ? new Set([...result].filter((k) => keys.has(k))) : keys;
|
|
866
|
+
}
|
|
867
|
+
const out = {
|
|
868
|
+
pages: new Set(),
|
|
869
|
+
rows: new Set(),
|
|
870
|
+
tables: new Set(),
|
|
871
|
+
files: new Set(),
|
|
872
|
+
size: 0,
|
|
873
|
+
};
|
|
874
|
+
for (const key of result ?? []) {
|
|
875
|
+
const id = key.slice(2);
|
|
876
|
+
({ p: out.pages, r: out.rows, t: out.tables, f: out.files })[key[0]].add(id);
|
|
877
|
+
}
|
|
878
|
+
// A table counts once, however many of its rows are left.
|
|
879
|
+
out.size =
|
|
880
|
+
out.pages.size + out.files.size + out.tables.size + (out.tables.size ? 0 : out.rows.size);
|
|
881
|
+
return out;
|
|
882
|
+
}
|