scenri 0.8.2 → 0.9.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/CHANGELOG.md +107 -0
- package/README.md +2 -0
- package/dist/chunk-4NQKRVO2.js +55 -0
- package/dist/chunk-HLMR33DT.js +246 -0
- package/dist/chunk-OJAG3FRX.js +1940 -0
- package/dist/chunk-PAIAXRAC.js +20 -0
- package/dist/chunk-QDODTMSN.js +62 -0
- package/dist/cli-BVPVS4V5.js +376 -0
- package/dist/index.js +23 -2
- package/dist/install-V6DAA7TT.js +4 -0
- package/dist/{launcher-DIZ4MQRF.js → launcher-IVFIYHPN.js} +6 -2
- package/dist/offer-IVTNHIDJ.js +47 -0
- package/dist/paths-36Y73GGY.js +3 -0
- package/dist/refresh-7HXPUHLQ.js +91 -0
- package/dist/serve.js +434 -2186
- package/dist/src-CSR34EBZ.js +3 -0
- package/launcher/Scenri.icns +0 -0
- package/launcher/launch.mjs +144 -0
- package/launcher/scenri.ico +0 -0
- package/launcher/starting.html +78 -0
- package/package.json +2 -1
- package/studio-dist/assets/index-DSSGRUMi.js +104 -0
- package/studio-dist/assets/{index-DwfWp4jH.css → index-DfAHZRYM.css} +1 -1
- package/studio-dist/index.html +2 -2
- package/studio-dist/assets/index-9Y9YnJq6.js +0 -103
|
@@ -0,0 +1,1940 @@
|
|
|
1
|
+
import { homedir } from 'os';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import Database from 'better-sqlite3';
|
|
4
|
+
import { randomUUID, createHash } from 'crypto';
|
|
5
|
+
import { mkdirSync, existsSync, chmodSync, readFileSync, writeFileSync, readdirSync, rmSync } from 'fs';
|
|
6
|
+
|
|
7
|
+
// ../core/src/index.ts
|
|
8
|
+
|
|
9
|
+
// ../core/src/slug.ts
|
|
10
|
+
var slugify = (s, fallback = "brand") => s.normalize("NFKD").replace(new RegExp("\\p{M}+", "gu"), "").replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase().slice(0, 48).replace(/-+$/g, "") || fallback;
|
|
11
|
+
var slugifyWithId = (s, id, fallback = "brand") => {
|
|
12
|
+
const base = slugify(s, fallback);
|
|
13
|
+
return base === fallback ? `${fallback}-${id.slice(0, 8)}` : base;
|
|
14
|
+
};
|
|
15
|
+
var RESERVED_SLUGS = /* @__PURE__ */ new Set(["api", "assets", "b", "setup"]);
|
|
16
|
+
function firstFree(base, taken) {
|
|
17
|
+
for (let n = 1; ; n++) {
|
|
18
|
+
const candidate = n === 1 ? base : `${base}-${n}`;
|
|
19
|
+
if (!taken(candidate)) return candidate;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ../core/src/db.ts
|
|
24
|
+
var MIGRATIONS = `
|
|
25
|
+
CREATE TABLE IF NOT EXISTS brands (
|
|
26
|
+
id TEXT PRIMARY KEY,
|
|
27
|
+
slug TEXT NOT NULL,
|
|
28
|
+
json TEXT NOT NULL,
|
|
29
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
30
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
31
|
+
);
|
|
32
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
33
|
+
id TEXT PRIMARY KEY,
|
|
34
|
+
brand_id TEXT NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
|
35
|
+
name TEXT NOT NULL,
|
|
36
|
+
slug TEXT,
|
|
37
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
38
|
+
);
|
|
39
|
+
CREATE TABLE IF NOT EXISTS nodes (
|
|
40
|
+
id TEXT PRIMARY KEY,
|
|
41
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
42
|
+
parent_id TEXT REFERENCES nodes(id),
|
|
43
|
+
kind TEXT NOT NULL CHECK (kind IN ('root','generation','edit')),
|
|
44
|
+
prompt TEXT NOT NULL DEFAULT '',
|
|
45
|
+
engine_id TEXT NOT NULL DEFAULT '',
|
|
46
|
+
status TEXT NOT NULL DEFAULT 'running' CHECK (status IN ('running','done','error')),
|
|
47
|
+
images TEXT NOT NULL DEFAULT '[]',
|
|
48
|
+
cost_usd REAL NOT NULL DEFAULT 0,
|
|
49
|
+
kept INTEGER NOT NULL DEFAULT 0,
|
|
50
|
+
error TEXT,
|
|
51
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
52
|
+
);
|
|
53
|
+
CREATE INDEX IF NOT EXISTS idx_nodes_project ON nodes(project_id);
|
|
54
|
+
CREATE TABLE IF NOT EXISTS sets (
|
|
55
|
+
id TEXT PRIMARY KEY,
|
|
56
|
+
brand_id TEXT NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
|
57
|
+
name TEXT NOT NULL,
|
|
58
|
+
slug TEXT NOT NULL,
|
|
59
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
60
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
61
|
+
);
|
|
62
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_sets_slug ON sets(brand_id, slug);
|
|
63
|
+
CREATE TABLE IF NOT EXISTS set_nodes (
|
|
64
|
+
set_id TEXT NOT NULL REFERENCES sets(id) ON DELETE CASCADE,
|
|
65
|
+
node_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
|
|
66
|
+
added_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
67
|
+
PRIMARY KEY (set_id, node_id)
|
|
68
|
+
);
|
|
69
|
+
CREATE INDEX IF NOT EXISTS idx_set_nodes_node ON set_nodes(node_id);
|
|
70
|
+
CREATE TABLE IF NOT EXISTS cost_events (
|
|
71
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
72
|
+
engine_id TEXT NOT NULL,
|
|
73
|
+
node_id TEXT,
|
|
74
|
+
cost_usd REAL NOT NULL,
|
|
75
|
+
ts TEXT NOT NULL DEFAULT (datetime('now'))
|
|
76
|
+
);
|
|
77
|
+
CREATE TABLE IF NOT EXISTS spend_caps (
|
|
78
|
+
engine_id TEXT PRIMARY KEY,
|
|
79
|
+
monthly_cap_usd REAL NOT NULL
|
|
80
|
+
);
|
|
81
|
+
CREATE TABLE IF NOT EXISTS settings (
|
|
82
|
+
key TEXT PRIMARY KEY,
|
|
83
|
+
value TEXT NOT NULL
|
|
84
|
+
);
|
|
85
|
+
CREATE TABLE IF NOT EXISTS catalog_sources (
|
|
86
|
+
id TEXT PRIMARY KEY,
|
|
87
|
+
brand_id TEXT NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
|
88
|
+
url TEXT NOT NULL,
|
|
89
|
+
platform TEXT NOT NULL DEFAULT 'unknown',
|
|
90
|
+
status TEXT NOT NULL DEFAULT 'idle',
|
|
91
|
+
last_import_at TEXT,
|
|
92
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
93
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
94
|
+
UNIQUE(brand_id, url)
|
|
95
|
+
);
|
|
96
|
+
CREATE TABLE IF NOT EXISTS catalog_products (
|
|
97
|
+
id TEXT PRIMARY KEY,
|
|
98
|
+
source_id TEXT NOT NULL REFERENCES catalog_sources(id) ON DELETE CASCADE,
|
|
99
|
+
brand_id TEXT NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
|
100
|
+
external_key TEXT NOT NULL,
|
|
101
|
+
title TEXT NOT NULL,
|
|
102
|
+
description_html TEXT,
|
|
103
|
+
url TEXT NOT NULL,
|
|
104
|
+
handle TEXT,
|
|
105
|
+
vendor TEXT,
|
|
106
|
+
product_type TEXT,
|
|
107
|
+
tags TEXT NOT NULL DEFAULT '[]',
|
|
108
|
+
category TEXT,
|
|
109
|
+
price REAL,
|
|
110
|
+
compare_at_price REAL,
|
|
111
|
+
currency TEXT,
|
|
112
|
+
available INTEGER,
|
|
113
|
+
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','unavailable')),
|
|
114
|
+
raw TEXT,
|
|
115
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
116
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
117
|
+
UNIQUE(source_id, external_key)
|
|
118
|
+
);
|
|
119
|
+
CREATE INDEX IF NOT EXISTS idx_catalog_products_brand ON catalog_products(brand_id);
|
|
120
|
+
CREATE TABLE IF NOT EXISTS catalog_variants (
|
|
121
|
+
id TEXT PRIMARY KEY,
|
|
122
|
+
product_id TEXT NOT NULL REFERENCES catalog_products(id) ON DELETE CASCADE,
|
|
123
|
+
external_key TEXT NOT NULL,
|
|
124
|
+
title TEXT,
|
|
125
|
+
sku TEXT,
|
|
126
|
+
price REAL,
|
|
127
|
+
compare_at_price REAL,
|
|
128
|
+
currency TEXT,
|
|
129
|
+
available INTEGER,
|
|
130
|
+
options TEXT NOT NULL DEFAULT '{}'
|
|
131
|
+
);
|
|
132
|
+
CREATE TABLE IF NOT EXISTS catalog_images (
|
|
133
|
+
id TEXT PRIMARY KEY,
|
|
134
|
+
product_id TEXT NOT NULL REFERENCES catalog_products(id) ON DELETE CASCADE,
|
|
135
|
+
source_url TEXT NOT NULL,
|
|
136
|
+
asset_ref TEXT,
|
|
137
|
+
width INTEGER,
|
|
138
|
+
height INTEGER,
|
|
139
|
+
position INTEGER NOT NULL DEFAULT 0,
|
|
140
|
+
alt TEXT
|
|
141
|
+
);
|
|
142
|
+
CREATE INDEX IF NOT EXISTS idx_catalog_images_product ON catalog_images(product_id);
|
|
143
|
+
CREATE TABLE IF NOT EXISTS catalog_collections (
|
|
144
|
+
id TEXT PRIMARY KEY,
|
|
145
|
+
source_id TEXT NOT NULL REFERENCES catalog_sources(id) ON DELETE CASCADE,
|
|
146
|
+
external_key TEXT NOT NULL,
|
|
147
|
+
title TEXT NOT NULL,
|
|
148
|
+
url TEXT,
|
|
149
|
+
UNIQUE(source_id, external_key)
|
|
150
|
+
);
|
|
151
|
+
CREATE TABLE IF NOT EXISTS catalog_collection_products (
|
|
152
|
+
collection_id TEXT NOT NULL REFERENCES catalog_collections(id) ON DELETE CASCADE,
|
|
153
|
+
product_id TEXT NOT NULL REFERENCES catalog_products(id) ON DELETE CASCADE,
|
|
154
|
+
PRIMARY KEY (collection_id, product_id)
|
|
155
|
+
);
|
|
156
|
+
CREATE TABLE IF NOT EXISTS import_jobs (
|
|
157
|
+
id TEXT PRIMARY KEY,
|
|
158
|
+
brand_id TEXT NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
|
159
|
+
source_id TEXT REFERENCES catalog_sources(id) ON DELETE SET NULL,
|
|
160
|
+
url TEXT NOT NULL,
|
|
161
|
+
platform TEXT NOT NULL DEFAULT 'unknown',
|
|
162
|
+
stage TEXT NOT NULL DEFAULT 'queued',
|
|
163
|
+
discovered INTEGER NOT NULL DEFAULT 0,
|
|
164
|
+
fetched INTEGER NOT NULL DEFAULT 0,
|
|
165
|
+
upserted INTEGER NOT NULL DEFAULT 0,
|
|
166
|
+
images_done INTEGER NOT NULL DEFAULT 0,
|
|
167
|
+
images_total INTEGER NOT NULL DEFAULT 0,
|
|
168
|
+
errors TEXT NOT NULL DEFAULT '[]',
|
|
169
|
+
warnings TEXT NOT NULL DEFAULT '[]',
|
|
170
|
+
message TEXT,
|
|
171
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
172
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
173
|
+
finished_at TEXT
|
|
174
|
+
);
|
|
175
|
+
CREATE INDEX IF NOT EXISTS idx_import_jobs_brand ON import_jobs(brand_id);
|
|
176
|
+
`;
|
|
177
|
+
function widenNodeStatusCheck(db) {
|
|
178
|
+
const row = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='nodes'").get();
|
|
179
|
+
if (!row || row.sql.includes("'cancelled'")) return;
|
|
180
|
+
db.pragma("foreign_keys = OFF");
|
|
181
|
+
db.transaction(() => {
|
|
182
|
+
db.exec(`
|
|
183
|
+
CREATE TABLE nodes_new (
|
|
184
|
+
id TEXT PRIMARY KEY,
|
|
185
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
186
|
+
parent_id TEXT REFERENCES nodes_new(id),
|
|
187
|
+
kind TEXT NOT NULL CHECK (kind IN ('root','generation','edit')),
|
|
188
|
+
prompt TEXT NOT NULL DEFAULT '',
|
|
189
|
+
engine_id TEXT NOT NULL DEFAULT '',
|
|
190
|
+
status TEXT NOT NULL DEFAULT 'running' CHECK (status IN ('running','done','error','cancelled')),
|
|
191
|
+
images TEXT NOT NULL DEFAULT '[]',
|
|
192
|
+
cost_usd REAL NOT NULL DEFAULT 0,
|
|
193
|
+
kept INTEGER NOT NULL DEFAULT 0,
|
|
194
|
+
error TEXT,
|
|
195
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
196
|
+
overlays TEXT NOT NULL DEFAULT '{}',
|
|
197
|
+
brief TEXT,
|
|
198
|
+
archived INTEGER NOT NULL DEFAULT 0,
|
|
199
|
+
duration_ms INTEGER,
|
|
200
|
+
batch_id TEXT,
|
|
201
|
+
batch_index INTEGER NOT NULL DEFAULT 0
|
|
202
|
+
);
|
|
203
|
+
INSERT INTO nodes_new
|
|
204
|
+
SELECT id, project_id, parent_id, kind, prompt, engine_id, status, images, cost_usd, kept, error,
|
|
205
|
+
created_at, overlays, brief, archived, duration_ms, batch_id, batch_index
|
|
206
|
+
FROM nodes;
|
|
207
|
+
DROP TABLE nodes;
|
|
208
|
+
ALTER TABLE nodes_new RENAME TO nodes;
|
|
209
|
+
CREATE INDEX IF NOT EXISTS idx_nodes_project ON nodes(project_id);
|
|
210
|
+
`);
|
|
211
|
+
})();
|
|
212
|
+
db.pragma("foreign_keys = ON");
|
|
213
|
+
}
|
|
214
|
+
var SLUG_CHARS = /^[a-z0-9-]+$/;
|
|
215
|
+
function backfillSlugs(db) {
|
|
216
|
+
const brands = db.prepare("SELECT id, slug, json FROM brands ORDER BY created_at, id").all();
|
|
217
|
+
const setBrand = db.prepare("UPDATE brands SET slug=? WHERE id=?");
|
|
218
|
+
const takenBrand = /* @__PURE__ */ new Set();
|
|
219
|
+
for (const b of brands) {
|
|
220
|
+
const needsRederiving = /^brand(-\d+)?$/.test(b.slug) || !SLUG_CHARS.test(b.slug);
|
|
221
|
+
let name;
|
|
222
|
+
if (needsRederiving) {
|
|
223
|
+
try {
|
|
224
|
+
name = JSON.parse(b.json)?.meta?.name;
|
|
225
|
+
} catch {
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const wanted = needsRederiving && name ? slugifyWithId(name, b.id) : b.slug;
|
|
229
|
+
const slug = firstFree(wanted, (c) => RESERVED_SLUGS.has(c) || takenBrand.has(c));
|
|
230
|
+
takenBrand.add(slug);
|
|
231
|
+
if (slug !== b.slug) setBrand.run(slug, b.id);
|
|
232
|
+
}
|
|
233
|
+
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_brands_slug ON brands(slug)");
|
|
234
|
+
const projects = db.prepare("SELECT id, brand_id, name, slug FROM projects ORDER BY created_at, id").all();
|
|
235
|
+
const setProject = db.prepare("UPDATE projects SET slug=? WHERE id=?");
|
|
236
|
+
const takenProject = /* @__PURE__ */ new Map();
|
|
237
|
+
for (const p of projects) {
|
|
238
|
+
const inBrand = takenProject.get(p.brand_id) ?? /* @__PURE__ */ new Set();
|
|
239
|
+
takenProject.set(p.brand_id, inBrand);
|
|
240
|
+
const current = p.slug && SLUG_CHARS.test(p.slug) ? p.slug : null;
|
|
241
|
+
const slug = firstFree(current || slugifyWithId(p.name, p.id, "project"), (c) => inBrand.has(c));
|
|
242
|
+
inBrand.add(slug);
|
|
243
|
+
if (slug !== p.slug) setProject.run(slug, p.id);
|
|
244
|
+
}
|
|
245
|
+
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_slug ON projects(brand_id, slug)");
|
|
246
|
+
}
|
|
247
|
+
function collapseProjects(db) {
|
|
248
|
+
const brands = db.prepare("SELECT id FROM brands").all();
|
|
249
|
+
const listProjects = db.prepare("SELECT id, name, slug FROM projects WHERE brand_id=? ORDER BY created_at, id");
|
|
250
|
+
const shotsIn = db.prepare("SELECT id FROM nodes WHERE project_id=? AND kind!='root' ORDER BY created_at, id");
|
|
251
|
+
const takenSlug = db.prepare("SELECT slug FROM sets WHERE brand_id=?");
|
|
252
|
+
const addSet = db.prepare("INSERT INTO sets (id, brand_id, name, slug) VALUES (?,?,?,?)");
|
|
253
|
+
const addMember = db.prepare("INSERT OR IGNORE INTO set_nodes (set_id, node_id) VALUES (?,?)");
|
|
254
|
+
for (const brand of brands) {
|
|
255
|
+
const projects = listProjects.all(brand.id);
|
|
256
|
+
if (projects.length <= 1) continue;
|
|
257
|
+
const workspace = projects[0];
|
|
258
|
+
db.transaction(() => {
|
|
259
|
+
const taken = new Set(takenSlug.all(brand.id).map((r) => r.slug));
|
|
260
|
+
for (const p of projects) {
|
|
261
|
+
const shots = shotsIn.all(p.id);
|
|
262
|
+
if (shots.length === 0) continue;
|
|
263
|
+
const setId = randomUUID();
|
|
264
|
+
const current = p.slug && SLUG_CHARS.test(p.slug) ? p.slug : null;
|
|
265
|
+
const slug = firstFree(current || slugifyWithId(p.name, setId, "set"), (c) => taken.has(c));
|
|
266
|
+
taken.add(slug);
|
|
267
|
+
addSet.run(setId, brand.id, p.name, slug);
|
|
268
|
+
for (const s of shots) addMember.run(setId, s.id);
|
|
269
|
+
}
|
|
270
|
+
db.prepare(
|
|
271
|
+
"UPDATE nodes SET project_id=? WHERE project_id IN (SELECT id FROM projects WHERE brand_id=? AND id!=?)"
|
|
272
|
+
).run(workspace.id, brand.id, workspace.id);
|
|
273
|
+
const roots = db.prepare("SELECT id FROM nodes WHERE project_id=? AND kind='root' ORDER BY created_at, id").all(workspace.id);
|
|
274
|
+
const surplus = roots.slice(1).map((r) => r.id);
|
|
275
|
+
if (surplus.length > 0) {
|
|
276
|
+
const holes = surplus.map(() => "?").join(",");
|
|
277
|
+
db.prepare(`UPDATE nodes SET parent_id=NULL WHERE parent_id IN (${holes})`).run(...surplus);
|
|
278
|
+
db.prepare(`DELETE FROM nodes WHERE id IN (${holes})`).run(...surplus);
|
|
279
|
+
}
|
|
280
|
+
db.prepare("DELETE FROM projects WHERE brand_id=? AND id!=?").run(brand.id, workspace.id);
|
|
281
|
+
})();
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
var IMAGES_SPLIT_MARK = "v1";
|
|
285
|
+
function splitMultiImageNodes(db) {
|
|
286
|
+
const done = db.prepare("SELECT value FROM settings WHERE key='images_split'").get()?.value;
|
|
287
|
+
if (done === IMAGES_SPLIT_MARK) return;
|
|
288
|
+
const mark = () => db.prepare(
|
|
289
|
+
"INSERT INTO settings (key, value) VALUES ('images_split', ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value"
|
|
290
|
+
).run(IMAGES_SPLIT_MARK);
|
|
291
|
+
const rows = db.prepare("SELECT * FROM nodes WHERE images LIKE '%,%'").all();
|
|
292
|
+
const multi = rows.filter((r) => {
|
|
293
|
+
try {
|
|
294
|
+
return JSON.parse(r.images).length > 1;
|
|
295
|
+
} catch {
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
if (!multi.length) {
|
|
300
|
+
mark();
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const parse = (s) => {
|
|
304
|
+
if (!s) return null;
|
|
305
|
+
try {
|
|
306
|
+
return JSON.parse(s);
|
|
307
|
+
} catch {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
const stampOf = (iso, minusMs) => {
|
|
312
|
+
const t = (/* @__PURE__ */ new Date(`${iso.replace(" ", "T")}Z`)).getTime() - minusMs;
|
|
313
|
+
const d = new Date(t);
|
|
314
|
+
const p = (n, w = 2) => String(n).padStart(w, "0");
|
|
315
|
+
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}.${p(d.getUTCMilliseconds(), 3)}`;
|
|
316
|
+
};
|
|
317
|
+
const updateOriginal = db.prepare(
|
|
318
|
+
"UPDATE nodes SET images=?, overlays=?, brief=?, batch_id=?, batch_index=0 WHERE id=?"
|
|
319
|
+
);
|
|
320
|
+
const insertSibling = db.prepare(
|
|
321
|
+
`INSERT INTO nodes (id, project_id, parent_id, kind, prompt, engine_id, status, images, cost_usd, kept,
|
|
322
|
+
error, created_at, overlays, brief, archived, duration_ms, batch_id, batch_index)
|
|
323
|
+
VALUES (?,?,?,?,?,?,?,?,0,?,?,?,?,?,?,NULL,?,?)`
|
|
324
|
+
);
|
|
325
|
+
const childrenOf = db.prepare("SELECT id, brief FROM nodes WHERE parent_id=?");
|
|
326
|
+
const repoint = db.prepare("UPDATE nodes SET parent_id=? WHERE id=?");
|
|
327
|
+
const setsOf = db.prepare("SELECT set_id FROM set_nodes WHERE node_id=?");
|
|
328
|
+
const addMember = db.prepare("INSERT OR IGNORE INTO set_nodes (set_id, node_id) VALUES (?,?)");
|
|
329
|
+
db.transaction(() => {
|
|
330
|
+
for (const r of multi) {
|
|
331
|
+
const images = JSON.parse(r.images);
|
|
332
|
+
const overlays = parse(r.overlays) ?? {};
|
|
333
|
+
const brief = parse(r.brief);
|
|
334
|
+
const sizes = Array.isArray(brief?.rendered?.sizes) ? brief.rendered.sizes : null;
|
|
335
|
+
const briefFor = (i) => {
|
|
336
|
+
if (!brief) return null;
|
|
337
|
+
const b = { ...brief, variants: images.length };
|
|
338
|
+
if (brief.rendered) {
|
|
339
|
+
const { requested: _req, variantIndexes: _vi, ...rendered } = brief.rendered;
|
|
340
|
+
b.rendered = { ...rendered, ...sizes ? { sizes: sizes[i] !== void 0 ? [sizes[i]] : [] } : {} };
|
|
341
|
+
}
|
|
342
|
+
return JSON.stringify(b);
|
|
343
|
+
};
|
|
344
|
+
const siblingIds = [r.id];
|
|
345
|
+
updateOriginal.run(
|
|
346
|
+
JSON.stringify([images[0]]),
|
|
347
|
+
JSON.stringify(overlays["0"] !== void 0 ? { "0": overlays["0"] } : {}),
|
|
348
|
+
briefFor(0),
|
|
349
|
+
r.id,
|
|
350
|
+
r.id
|
|
351
|
+
);
|
|
352
|
+
for (let i = 1; i < images.length; i++) {
|
|
353
|
+
const id = randomUUID();
|
|
354
|
+
siblingIds.push(id);
|
|
355
|
+
insertSibling.run(
|
|
356
|
+
id,
|
|
357
|
+
r.project_id,
|
|
358
|
+
r.parent_id,
|
|
359
|
+
r.kind,
|
|
360
|
+
r.prompt,
|
|
361
|
+
r.engine_id,
|
|
362
|
+
r.status,
|
|
363
|
+
JSON.stringify([images[i]]),
|
|
364
|
+
r.kept,
|
|
365
|
+
r.error,
|
|
366
|
+
stampOf(r.created_at, i),
|
|
367
|
+
JSON.stringify(overlays[String(i)] !== void 0 ? { "0": overlays[String(i)] } : {}),
|
|
368
|
+
briefFor(i),
|
|
369
|
+
r.archived,
|
|
370
|
+
r.id,
|
|
371
|
+
i
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
for (const child of childrenOf.all(r.id)) {
|
|
375
|
+
const src = parse(child.brief)?.sourceImage;
|
|
376
|
+
if (typeof src !== "string") continue;
|
|
377
|
+
const at = images.indexOf(src);
|
|
378
|
+
if (at > 0) repoint.run(siblingIds[at], child.id);
|
|
379
|
+
}
|
|
380
|
+
for (const s of setsOf.all(r.id)) {
|
|
381
|
+
for (let i = 1; i < siblingIds.length; i++) addMember.run(s.set_id, siblingIds[i]);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
})();
|
|
385
|
+
mark();
|
|
386
|
+
}
|
|
387
|
+
function ensureIndexes(db) {
|
|
388
|
+
db.exec(`
|
|
389
|
+
CREATE INDEX IF NOT EXISTS idx_nodes_project_created ON nodes(project_id, created_at, id);
|
|
390
|
+
CREATE INDEX IF NOT EXISTS idx_nodes_project_kept ON nodes(project_id, kept, created_at, id);
|
|
391
|
+
CREATE INDEX IF NOT EXISTS idx_nodes_project_cost ON nodes(project_id, cost_usd, created_at, id);
|
|
392
|
+
CREATE INDEX IF NOT EXISTS idx_nodes_project_state ON nodes(project_id, kind, archived, kept);
|
|
393
|
+
DROP INDEX IF EXISTS idx_nodes_parent;
|
|
394
|
+
CREATE INDEX IF NOT EXISTS idx_nodes_parent_created ON nodes(parent_id, created_at, id);
|
|
395
|
+
CREATE INDEX IF NOT EXISTS idx_nodes_status ON nodes(status);
|
|
396
|
+
CREATE INDEX IF NOT EXISTS idx_catalog_variants_product ON catalog_variants(product_id);
|
|
397
|
+
CREATE INDEX IF NOT EXISTS idx_cost_events_engine_ts ON cost_events(engine_id, ts);
|
|
398
|
+
`);
|
|
399
|
+
}
|
|
400
|
+
function searchTextSql(alias) {
|
|
401
|
+
const brief = `CASE WHEN json_valid(${alias}.brief) THEN ${alias}.brief ELSE '{}' END`;
|
|
402
|
+
return `trim(coalesce(${alias}.prompt, '') || ' ' ||
|
|
403
|
+
coalesce((SELECT group_concat(je.value, ' ') FROM json_each(${brief}, '$.templateFields') AS je), '') || ' ' ||
|
|
404
|
+
coalesce((SELECT group_concat(coalesce(json_extract(je.value, '$.name'), '') || ' ' || coalesce(json_extract(je.value, '$.hex'), ''), ' ')
|
|
405
|
+
FROM json_each(${brief}, '$.tokens') AS je WHERE json_extract(je.value, '$.t') = 'color'), ''))`;
|
|
406
|
+
}
|
|
407
|
+
function tokenRowsSql(alias) {
|
|
408
|
+
const brief = `CASE WHEN json_valid(${alias}.brief) THEN ${alias}.brief ELSE '{}' END`;
|
|
409
|
+
return `SELECT ${alias}.id, json_extract(je.value, '$.t'), json_extract(je.value, '$.id')
|
|
410
|
+
FROM json_each(${brief}, '$.tokens') AS je
|
|
411
|
+
WHERE json_extract(je.value, '$.t') IN ('product', 'character', 'template')
|
|
412
|
+
AND json_extract(je.value, '$.id') IS NOT NULL
|
|
413
|
+
UNION ALL
|
|
414
|
+
SELECT ${alias}.id, 'template', json_extract(${brief}, '$.templateId')
|
|
415
|
+
WHERE json_extract(${brief}, '$.templateId') IS NOT NULL`;
|
|
416
|
+
}
|
|
417
|
+
function tokenRowsFromNodesSql() {
|
|
418
|
+
const brief = "CASE WHEN json_valid(n.brief) THEN n.brief ELSE '{}' END";
|
|
419
|
+
return `SELECT n.id, json_extract(je.value, '$.t'), json_extract(je.value, '$.id')
|
|
420
|
+
FROM nodes n, json_each(${brief}, '$.tokens') AS je
|
|
421
|
+
WHERE json_extract(je.value, '$.t') IN ('product', 'character', 'template')
|
|
422
|
+
AND json_extract(je.value, '$.id') IS NOT NULL
|
|
423
|
+
UNION ALL
|
|
424
|
+
SELECT n.id, 'template', json_extract(${brief}, '$.templateId')
|
|
425
|
+
FROM nodes n
|
|
426
|
+
WHERE json_extract(${brief}, '$.templateId') IS NOT NULL`;
|
|
427
|
+
}
|
|
428
|
+
var SEARCH_INDEX_VERSION = "v1";
|
|
429
|
+
function ensureSearch(db) {
|
|
430
|
+
db.exec(`
|
|
431
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5(text, tokenize='trigram case_sensitive 0 remove_diacritics 1');
|
|
432
|
+
CREATE TABLE IF NOT EXISTS node_tokens (
|
|
433
|
+
node_id TEXT NOT NULL,
|
|
434
|
+
kind TEXT NOT NULL,
|
|
435
|
+
token_id TEXT NOT NULL,
|
|
436
|
+
PRIMARY KEY (node_id, kind, token_id)
|
|
437
|
+
) WITHOUT ROWID;
|
|
438
|
+
CREATE INDEX IF NOT EXISTS idx_node_tokens_token ON node_tokens(token_id, node_id);
|
|
439
|
+
CREATE TRIGGER IF NOT EXISTS nodes_search_ai AFTER INSERT ON nodes BEGIN
|
|
440
|
+
INSERT INTO nodes_fts(rowid, text) VALUES (new.rowid, ${searchTextSql("new")});
|
|
441
|
+
INSERT OR IGNORE INTO node_tokens(node_id, kind, token_id) ${tokenRowsSql("new")};
|
|
442
|
+
END;
|
|
443
|
+
CREATE TRIGGER IF NOT EXISTS nodes_search_au AFTER UPDATE OF prompt, brief ON nodes BEGIN
|
|
444
|
+
DELETE FROM nodes_fts WHERE rowid = old.rowid;
|
|
445
|
+
DELETE FROM node_tokens WHERE node_id = old.id;
|
|
446
|
+
INSERT INTO nodes_fts(rowid, text) VALUES (new.rowid, ${searchTextSql("new")});
|
|
447
|
+
INSERT OR IGNORE INTO node_tokens(node_id, kind, token_id) ${tokenRowsSql("new")};
|
|
448
|
+
END;
|
|
449
|
+
CREATE TRIGGER IF NOT EXISTS nodes_search_ad AFTER DELETE ON nodes BEGIN
|
|
450
|
+
DELETE FROM nodes_fts WHERE rowid = old.rowid;
|
|
451
|
+
DELETE FROM node_tokens WHERE node_id = old.id;
|
|
452
|
+
END;
|
|
453
|
+
`);
|
|
454
|
+
const marker = db.prepare("SELECT value FROM settings WHERE key='search_index'").get()?.value;
|
|
455
|
+
const bounds = db.prepare("SELECT min(rowid) AS lo, max(rowid) AS hi, count(*) AS c FROM nodes").get();
|
|
456
|
+
const indexed = (rowid) => rowid !== null && !!db.prepare("SELECT 1 FROM nodes_fts WHERE rowid = ?").get(rowid);
|
|
457
|
+
const whole = bounds.c === 0 || indexed(bounds.lo) && indexed(bounds.hi);
|
|
458
|
+
if (marker === SEARCH_INDEX_VERSION && whole) return;
|
|
459
|
+
db.transaction(() => {
|
|
460
|
+
db.exec("DELETE FROM nodes_fts");
|
|
461
|
+
db.exec("DELETE FROM node_tokens");
|
|
462
|
+
db.exec(`INSERT INTO nodes_fts(rowid, text) SELECT n.rowid, ${searchTextSql("n")} FROM nodes n`);
|
|
463
|
+
db.exec(`INSERT OR IGNORE INTO node_tokens(node_id, kind, token_id) ${tokenRowsFromNodesSql()}`);
|
|
464
|
+
db.prepare(
|
|
465
|
+
"INSERT INTO settings (key, value) VALUES ('search_index', ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value"
|
|
466
|
+
).run(SEARCH_INDEX_VERSION);
|
|
467
|
+
})();
|
|
468
|
+
}
|
|
469
|
+
var SCHEMA_VERSION = 2;
|
|
470
|
+
var SchemaTooNewError = class extends Error {
|
|
471
|
+
constructor(found, supported, backupsDir) {
|
|
472
|
+
super(
|
|
473
|
+
`This library was written by a newer Scenri (schema ${found}; this build understands ${supported}). Update and retry: npx scenri@latest` + (backupsDir ? ` (a pre-migration snapshot of the library is kept in ${backupsDir})` : "")
|
|
474
|
+
);
|
|
475
|
+
this.name = "SchemaTooNewError";
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
function backupBeforeMigration(db, homeDir, fromVersion) {
|
|
479
|
+
const dir = join(homeDir, "backups");
|
|
480
|
+
mkdirSync(dir, { recursive: true });
|
|
481
|
+
const d = /* @__PURE__ */ new Date();
|
|
482
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
483
|
+
const stamp = `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
|
484
|
+
db.pragma("wal_checkpoint(TRUNCATE)");
|
|
485
|
+
db.prepare("VACUUM INTO ?").run(join(dir, `scenri-v${fromVersion}-${stamp}.db`));
|
|
486
|
+
const old = readdirSync(dir).filter((f) => /^scenri-v\d+-\d{8}-\d{6}\.db$/.test(f)).sort((a, b) => a.slice(-18).localeCompare(b.slice(-18)));
|
|
487
|
+
for (const f of old.slice(0, Math.max(0, old.length - 3))) rmSync(join(dir, f));
|
|
488
|
+
}
|
|
489
|
+
function openDb(homeDir) {
|
|
490
|
+
mkdirSync(homeDir, { recursive: true, mode: 448 });
|
|
491
|
+
const dbPath = join(homeDir, "scenri.db");
|
|
492
|
+
const preExisting = existsSync(dbPath);
|
|
493
|
+
const db = new Database(dbPath);
|
|
494
|
+
try {
|
|
495
|
+
chmodSync(dbPath, 384);
|
|
496
|
+
} catch {
|
|
497
|
+
}
|
|
498
|
+
db.pragma("journal_mode = WAL");
|
|
499
|
+
db.pragma("foreign_keys = ON");
|
|
500
|
+
const found = db.pragma("user_version", { simple: true });
|
|
501
|
+
if (found > SCHEMA_VERSION) {
|
|
502
|
+
db.close();
|
|
503
|
+
throw new SchemaTooNewError(found, SCHEMA_VERSION, join(homeDir, "backups"));
|
|
504
|
+
}
|
|
505
|
+
if (preExisting && found < SCHEMA_VERSION) backupBeforeMigration(db, homeDir, found);
|
|
506
|
+
db.exec(MIGRATIONS);
|
|
507
|
+
const nodeCols = db.pragma("table_info(nodes)").map((c) => c.name);
|
|
508
|
+
if (!nodeCols.includes("overlays")) {
|
|
509
|
+
db.exec("ALTER TABLE nodes ADD COLUMN overlays TEXT NOT NULL DEFAULT '{}'");
|
|
510
|
+
}
|
|
511
|
+
if (!nodeCols.includes("brief")) {
|
|
512
|
+
db.exec("ALTER TABLE nodes ADD COLUMN brief TEXT");
|
|
513
|
+
}
|
|
514
|
+
if (!nodeCols.includes("archived")) {
|
|
515
|
+
db.exec("ALTER TABLE nodes ADD COLUMN archived INTEGER NOT NULL DEFAULT 0");
|
|
516
|
+
}
|
|
517
|
+
if (!nodeCols.includes("duration_ms")) {
|
|
518
|
+
db.exec("ALTER TABLE nodes ADD COLUMN duration_ms INTEGER");
|
|
519
|
+
}
|
|
520
|
+
if (!nodeCols.includes("batch_id")) {
|
|
521
|
+
db.exec("ALTER TABLE nodes ADD COLUMN batch_id TEXT");
|
|
522
|
+
}
|
|
523
|
+
if (!nodeCols.includes("batch_index")) {
|
|
524
|
+
db.exec("ALTER TABLE nodes ADD COLUMN batch_index INTEGER NOT NULL DEFAULT 0");
|
|
525
|
+
}
|
|
526
|
+
const projectCols = db.pragma("table_info(projects)").map((c) => c.name);
|
|
527
|
+
if (!projectCols.includes("slug")) {
|
|
528
|
+
db.exec("ALTER TABLE projects ADD COLUMN slug TEXT");
|
|
529
|
+
}
|
|
530
|
+
const catalogCols = db.pragma("table_info(catalog_products)").map((c) => c.name);
|
|
531
|
+
for (const col of ["variant", "material", "dimensions"]) {
|
|
532
|
+
if (!catalogCols.includes(col)) db.exec(`ALTER TABLE catalog_products ADD COLUMN ${col} TEXT`);
|
|
533
|
+
}
|
|
534
|
+
const catalogImgCols = db.pragma("table_info(catalog_images)").map((c) => c.name);
|
|
535
|
+
if (!catalogImgCols.includes("angle")) db.exec("ALTER TABLE catalog_images ADD COLUMN angle TEXT");
|
|
536
|
+
if (!catalogImgCols.includes("excluded")) {
|
|
537
|
+
db.exec("ALTER TABLE catalog_images ADD COLUMN excluded INTEGER NOT NULL DEFAULT 0");
|
|
538
|
+
}
|
|
539
|
+
widenNodeStatusCheck(db);
|
|
540
|
+
ensureIndexes(db);
|
|
541
|
+
backfillSlugs(db);
|
|
542
|
+
collapseProjects(db);
|
|
543
|
+
splitMultiImageNodes(db);
|
|
544
|
+
ensureSearch(db);
|
|
545
|
+
db.prepare(
|
|
546
|
+
"UPDATE nodes SET status='error', error='interrupted: server restarted mid-generation' WHERE status='running'"
|
|
547
|
+
).run();
|
|
548
|
+
db.pragma(`user_version = ${SCHEMA_VERSION}`);
|
|
549
|
+
return db;
|
|
550
|
+
}
|
|
551
|
+
function createImageStore(homeDir) {
|
|
552
|
+
const dir = join(homeDir, "images");
|
|
553
|
+
mkdirSync(dir, { recursive: true, mode: 448 });
|
|
554
|
+
const fileFor = (hash) => join(dir, `${hash}.png`);
|
|
555
|
+
return {
|
|
556
|
+
save(buf) {
|
|
557
|
+
const hash = createHash("sha256").update(buf).digest("hex").slice(0, 32);
|
|
558
|
+
const file = fileFor(hash);
|
|
559
|
+
if (!existsSync(file)) writeFileSync(file, buf);
|
|
560
|
+
return hash;
|
|
561
|
+
},
|
|
562
|
+
pathFor(hash) {
|
|
563
|
+
if (!/^[a-f0-9]{32}$/.test(hash)) throw new Error("invalid image hash");
|
|
564
|
+
return fileFor(hash);
|
|
565
|
+
},
|
|
566
|
+
read(hash) {
|
|
567
|
+
return readFileSync(this.pathFor(hash));
|
|
568
|
+
},
|
|
569
|
+
has(hash) {
|
|
570
|
+
return /^[a-f0-9]{32}$/.test(hash) && existsSync(fileFor(hash));
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// ../core/src/ledger.ts
|
|
576
|
+
var SpendCapError = class extends Error {
|
|
577
|
+
constructor(engineId, cap, spent, estimate) {
|
|
578
|
+
super(
|
|
579
|
+
`Spend cap for ${engineId}: $${cap.toFixed(2)}/mo. Spent $${spent.toFixed(2)}, next ~$${estimate.toFixed(2)} would exceed it.`
|
|
580
|
+
);
|
|
581
|
+
this.name = "SpendCapError";
|
|
582
|
+
}
|
|
583
|
+
};
|
|
584
|
+
function createLedger(db) {
|
|
585
|
+
const monthStart = () => {
|
|
586
|
+
const d = /* @__PURE__ */ new Date();
|
|
587
|
+
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-01`;
|
|
588
|
+
};
|
|
589
|
+
return {
|
|
590
|
+
recordCost(engineId, nodeId, usd) {
|
|
591
|
+
if (usd > 0)
|
|
592
|
+
db.prepare("INSERT INTO cost_events (engine_id, node_id, cost_usd) VALUES (?,?,?)").run(engineId, nodeId, usd);
|
|
593
|
+
},
|
|
594
|
+
monthlySpend(engineId) {
|
|
595
|
+
const row = db.prepare("SELECT COALESCE(SUM(cost_usd),0) s FROM cost_events WHERE engine_id=? AND ts >= ?").get(engineId, monthStart());
|
|
596
|
+
return row.s;
|
|
597
|
+
},
|
|
598
|
+
totalSpendByEngine() {
|
|
599
|
+
const rows = db.prepare("SELECT engine_id, COALESCE(SUM(cost_usd),0) s FROM cost_events WHERE ts >= ? GROUP BY engine_id").all(monthStart());
|
|
600
|
+
return Object.fromEntries(rows.map((r) => [r.engine_id, r.s]));
|
|
601
|
+
},
|
|
602
|
+
setCap(engineId, capUsd) {
|
|
603
|
+
if (capUsd === null) db.prepare("DELETE FROM spend_caps WHERE engine_id=?").run(engineId);
|
|
604
|
+
else
|
|
605
|
+
db.prepare(
|
|
606
|
+
"INSERT INTO spend_caps (engine_id, monthly_cap_usd) VALUES (?,?) ON CONFLICT(engine_id) DO UPDATE SET monthly_cap_usd=excluded.monthly_cap_usd"
|
|
607
|
+
).run(engineId, capUsd);
|
|
608
|
+
},
|
|
609
|
+
capFor(engineId) {
|
|
610
|
+
const row = db.prepare("SELECT monthly_cap_usd c FROM spend_caps WHERE engine_id=?").get(engineId);
|
|
611
|
+
return row ? row.c : null;
|
|
612
|
+
},
|
|
613
|
+
caps() {
|
|
614
|
+
const rows = db.prepare("SELECT engine_id, monthly_cap_usd c FROM spend_caps").all();
|
|
615
|
+
return Object.fromEntries(rows.map((r) => [r.engine_id, r.c]));
|
|
616
|
+
},
|
|
617
|
+
assertUnderCap(engineId, nextEstimate) {
|
|
618
|
+
const cap = this.capFor(engineId);
|
|
619
|
+
if (cap === null) return;
|
|
620
|
+
const spent = this.monthlySpend(engineId);
|
|
621
|
+
if (spent + nextEstimate > cap) throw new SpendCapError(engineId, cap, spent, nextEstimate);
|
|
622
|
+
}
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// ../core/src/searchRules.ts
|
|
627
|
+
function fold(s) {
|
|
628
|
+
return s.normalize("NFD").replace(new RegExp("\\p{Diacritic}", "gu"), "").replace(/[\u200e\u200f\u061c\u202a-\u202e\u2066-\u2069]/g, "").toLowerCase();
|
|
629
|
+
}
|
|
630
|
+
var STEM_MIN = 4;
|
|
631
|
+
var TRIGRAM_MIN = 3;
|
|
632
|
+
function searchTerms(q) {
|
|
633
|
+
return fold(q).trim().split(/\s+/).filter(Boolean).map((text) => ({
|
|
634
|
+
text,
|
|
635
|
+
stem: text.length >= STEM_MIN && text.endsWith("s") ? text.slice(0, -1) : null
|
|
636
|
+
}));
|
|
637
|
+
}
|
|
638
|
+
function termMatches(haystack, term) {
|
|
639
|
+
const h = fold(haystack);
|
|
640
|
+
return h.includes(term.text) || term.stem !== null && h.includes(term.stem);
|
|
641
|
+
}
|
|
642
|
+
function matchesQuery(haystack, q) {
|
|
643
|
+
const terms = searchTerms(q);
|
|
644
|
+
if (!terms.length) return true;
|
|
645
|
+
return terms.every((t) => termMatches(haystack, t));
|
|
646
|
+
}
|
|
647
|
+
var quote = (s) => `"${s.replace(/"/g, '""')}"`;
|
|
648
|
+
function ftsMatch(term) {
|
|
649
|
+
if (term.text.length < TRIGRAM_MIN) return null;
|
|
650
|
+
return term.stem ? `(${quote(term.text)} OR ${quote(term.stem)})` : quote(term.text);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// ../core/src/store.ts
|
|
654
|
+
var PROMPT_HEAD_CHARS = 240;
|
|
655
|
+
function uniqueSlug(db, name, id) {
|
|
656
|
+
const stmt = db.prepare("SELECT 1 FROM brands WHERE slug=? AND id IS NOT ?");
|
|
657
|
+
return firstFree(slugifyWithId(name, id), (c) => RESERVED_SLUGS.has(c) || !!stmt.get(c, id));
|
|
658
|
+
}
|
|
659
|
+
function uniqueProjectSlug(db, brandId, name, id) {
|
|
660
|
+
const stmt = db.prepare("SELECT 1 FROM projects WHERE brand_id=? AND slug=?");
|
|
661
|
+
return firstFree(slugifyWithId(name, id, "project"), (c) => !!stmt.get(brandId, c));
|
|
662
|
+
}
|
|
663
|
+
function uniqueSetSlug(db, brandId, name, id) {
|
|
664
|
+
const stmt = db.prepare("SELECT 1 FROM sets WHERE brand_id=? AND slug=? AND id IS NOT ?");
|
|
665
|
+
return firstFree(slugifyWithId(name, id, "set"), (c) => !!stmt.get(brandId, c, id));
|
|
666
|
+
}
|
|
667
|
+
var SET_NAME_SEP = String.fromCharCode(31);
|
|
668
|
+
function rowToSet(r) {
|
|
669
|
+
return {
|
|
670
|
+
id: r.id,
|
|
671
|
+
brandId: r.brand_id,
|
|
672
|
+
name: r.name,
|
|
673
|
+
slug: r.slug,
|
|
674
|
+
createdAt: r.created_at,
|
|
675
|
+
updatedAt: r.updated_at
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
var headOf = (prompt) => Array.from(String(prompt ?? "")).slice(0, PROMPT_HEAD_CHARS).join("");
|
|
679
|
+
var CHILD_COUNT_SQL = "(CASE WHEN n.kind = 'root' THEN 0 ELSE (SELECT count(*) FROM nodes c WHERE c.parent_id = n.id AND c.archived = 0) END)";
|
|
680
|
+
var LINEAGE_SIBLINGS_RADIUS = 25;
|
|
681
|
+
var LINEAGE_CHILDREN_MAX = 60;
|
|
682
|
+
var LINEAGE_HISTORY_MAX = 60;
|
|
683
|
+
var FEED_COLS = `n.id, n.project_id, n.parent_id, n.kind, substr(n.prompt, 1, ${PROMPT_HEAD_CHARS}) AS prompt_head,
|
|
684
|
+
n.engine_id, n.status, n.images, n.cost_usd, n.duration_ms, n.kept, n.error, n.created_at, n.brief, n.archived,
|
|
685
|
+
n.batch_id, n.batch_index, ${CHILD_COUNT_SQL} AS child_count`;
|
|
686
|
+
function rowToFeedNode(r) {
|
|
687
|
+
return {
|
|
688
|
+
id: r.id,
|
|
689
|
+
projectId: r.project_id,
|
|
690
|
+
parentId: r.parent_id,
|
|
691
|
+
kind: r.kind,
|
|
692
|
+
promptHead: r.prompt_head ?? headOf(r.prompt),
|
|
693
|
+
engineId: r.engine_id,
|
|
694
|
+
status: r.status,
|
|
695
|
+
images: JSON.parse(r.images),
|
|
696
|
+
costUsd: r.cost_usd,
|
|
697
|
+
durationMs: r.duration_ms ?? null,
|
|
698
|
+
kept: !!r.kept,
|
|
699
|
+
error: r.error,
|
|
700
|
+
createdAt: r.created_at,
|
|
701
|
+
brief: r.brief ? JSON.parse(r.brief) : null,
|
|
702
|
+
archived: !!r.archived,
|
|
703
|
+
batchId: r.batch_id ?? null,
|
|
704
|
+
batchIndex: r.batch_index ?? 0,
|
|
705
|
+
childCount: r.child_count ?? 0
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
function rowToNode(r) {
|
|
709
|
+
return {
|
|
710
|
+
...rowToFeedNode(r),
|
|
711
|
+
prompt: r.prompt,
|
|
712
|
+
overlays: JSON.parse(r.overlays ?? "{}")
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
var lastBatchStamp = 0;
|
|
716
|
+
function batchStamps(count) {
|
|
717
|
+
const base = Math.max(Date.now(), lastBatchStamp + count);
|
|
718
|
+
lastBatchStamp = base;
|
|
719
|
+
const p = (n, w = 2) => String(n).padStart(w, "0");
|
|
720
|
+
return Array.from({ length: count }, (_, i) => {
|
|
721
|
+
const d = new Date(base - i);
|
|
722
|
+
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}.${p(d.getUTCMilliseconds(), 3)}`;
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
var encodeCursor = (k) => Buffer.from(JSON.stringify(k)).toString("base64url");
|
|
726
|
+
function decodeCursor(cursor) {
|
|
727
|
+
try {
|
|
728
|
+
const k = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
|
729
|
+
if (typeof k?.c === "string" && typeof k?.i === "string") return k;
|
|
730
|
+
} catch {
|
|
731
|
+
}
|
|
732
|
+
throw new Error("invalid cursor");
|
|
733
|
+
}
|
|
734
|
+
function filterSql(f, params, withLens) {
|
|
735
|
+
const where = ["n.kind != 'root'"];
|
|
736
|
+
if (withLens) {
|
|
737
|
+
if (f.lens === "archived") where.push("n.archived = 1");
|
|
738
|
+
else if (f.lens === "keepers") where.push("n.archived = 0 AND n.kept = 1");
|
|
739
|
+
else where.push("n.archived = 0");
|
|
740
|
+
}
|
|
741
|
+
if (f.lineage) {
|
|
742
|
+
params.lineage = f.lineage;
|
|
743
|
+
where.push(
|
|
744
|
+
"n.id IN (WITH RECURSIVE d(id) AS (SELECT @lineage UNION ALL SELECT c.id FROM nodes c JOIN d ON c.parent_id = d.id) SELECT id FROM d)"
|
|
745
|
+
);
|
|
746
|
+
} else if (f.set) {
|
|
747
|
+
params.set = f.set;
|
|
748
|
+
where.push("n.id IN (SELECT node_id FROM set_nodes WHERE set_id = @set)");
|
|
749
|
+
} else if (f.ungrouped) {
|
|
750
|
+
where.push("NOT EXISTS (SELECT 1 FROM set_nodes sn WHERE sn.node_id = n.id)");
|
|
751
|
+
}
|
|
752
|
+
if (f.tokens?.length) {
|
|
753
|
+
const names = f.tokens.map((t, i) => {
|
|
754
|
+
params[`tok${i}`] = t;
|
|
755
|
+
return `@tok${i}`;
|
|
756
|
+
});
|
|
757
|
+
where.push(`n.id IN (SELECT node_id FROM node_tokens WHERE token_id IN (${names.join(", ")}))`);
|
|
758
|
+
}
|
|
759
|
+
(f.terms ?? []).forEach((term, i) => {
|
|
760
|
+
const any = [];
|
|
761
|
+
const match = ftsMatch(term);
|
|
762
|
+
if (match) {
|
|
763
|
+
params[`m${i}`] = match;
|
|
764
|
+
any.push(`n.rowid IN (SELECT rowid FROM nodes_fts WHERE nodes_fts MATCH @m${i})`);
|
|
765
|
+
}
|
|
766
|
+
if (term.tokenIds.length) {
|
|
767
|
+
const names = term.tokenIds.map((t, j) => {
|
|
768
|
+
params[`t${i}_${j}`] = t;
|
|
769
|
+
return `@t${i}_${j}`;
|
|
770
|
+
});
|
|
771
|
+
any.push(`n.id IN (SELECT node_id FROM node_tokens WHERE token_id IN (${names.join(", ")}))`);
|
|
772
|
+
}
|
|
773
|
+
if (term.engineIds.length) {
|
|
774
|
+
const names = term.engineIds.map((e, j) => {
|
|
775
|
+
params[`e${i}_${j}`] = e;
|
|
776
|
+
return `@e${i}_${j}`;
|
|
777
|
+
});
|
|
778
|
+
any.push(`n.engine_id IN (${names.join(", ")})`);
|
|
779
|
+
}
|
|
780
|
+
if (any.length) where.push(`(${any.join(" OR ")})`);
|
|
781
|
+
});
|
|
782
|
+
return where;
|
|
783
|
+
}
|
|
784
|
+
function sortSql(sort, cursor, params) {
|
|
785
|
+
if (cursor) {
|
|
786
|
+
params.c = cursor.c;
|
|
787
|
+
params.i = cursor.i;
|
|
788
|
+
params.v = cursor.v ?? 0;
|
|
789
|
+
}
|
|
790
|
+
const newest = "(n.created_at < @c OR (n.created_at = @c AND n.id < @i))";
|
|
791
|
+
const oldest = "(n.created_at > @c OR (n.created_at = @c AND n.id > @i))";
|
|
792
|
+
switch (sort) {
|
|
793
|
+
case "oldest":
|
|
794
|
+
return { order: "n.created_at ASC, n.id ASC", after: cursor ? oldest : null };
|
|
795
|
+
case "cost":
|
|
796
|
+
return {
|
|
797
|
+
order: "n.cost_usd DESC, n.created_at DESC, n.id DESC",
|
|
798
|
+
after: cursor ? `(n.cost_usd < @v OR (n.cost_usd = @v AND ${newest}))` : null
|
|
799
|
+
};
|
|
800
|
+
case "keepers":
|
|
801
|
+
return {
|
|
802
|
+
order: "n.kept DESC, n.created_at DESC, n.id DESC",
|
|
803
|
+
after: cursor ? `(n.kept < @v OR (n.kept = @v AND ${newest}))` : null
|
|
804
|
+
};
|
|
805
|
+
default:
|
|
806
|
+
return { order: "n.created_at DESC, n.id DESC", after: cursor ? newest : null };
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
var keysetOf = (n, sort) => sort === "cost" ? { c: n.createdAt, i: n.id, v: n.costUsd } : sort === "keepers" ? { c: n.createdAt, i: n.id, v: n.kept ? 1 : 0 } : { c: n.createdAt, i: n.id };
|
|
810
|
+
var FEED_PAGE_MAX = 200;
|
|
811
|
+
function createStore(db) {
|
|
812
|
+
return {
|
|
813
|
+
// brands
|
|
814
|
+
createBrand(json) {
|
|
815
|
+
const id = randomUUID();
|
|
816
|
+
db.prepare("INSERT INTO brands (id, slug, json) VALUES (?,?,?)").run(
|
|
817
|
+
id,
|
|
818
|
+
uniqueSlug(db, json.meta.name, id),
|
|
819
|
+
JSON.stringify(json)
|
|
820
|
+
);
|
|
821
|
+
return this.getBrand(id);
|
|
822
|
+
},
|
|
823
|
+
getBrand(id) {
|
|
824
|
+
const r = db.prepare("SELECT * FROM brands WHERE id=?").get(id);
|
|
825
|
+
return r ? { id: r.id, slug: r.slug, json: JSON.parse(r.json), createdAt: r.created_at, updatedAt: r.updated_at } : null;
|
|
826
|
+
},
|
|
827
|
+
listBrands() {
|
|
828
|
+
return db.prepare("SELECT * FROM brands ORDER BY created_at").all().map((r) => ({
|
|
829
|
+
id: r.id,
|
|
830
|
+
slug: r.slug,
|
|
831
|
+
json: JSON.parse(r.json),
|
|
832
|
+
createdAt: r.created_at,
|
|
833
|
+
updatedAt: r.updated_at
|
|
834
|
+
}));
|
|
835
|
+
},
|
|
836
|
+
updateBrand(id, json) {
|
|
837
|
+
db.prepare("UPDATE brands SET json=?, slug=?, updated_at=datetime('now') WHERE id=?").run(
|
|
838
|
+
JSON.stringify(json),
|
|
839
|
+
uniqueSlug(db, json.meta.name, id),
|
|
840
|
+
id
|
|
841
|
+
);
|
|
842
|
+
return this.getBrand(id);
|
|
843
|
+
},
|
|
844
|
+
deleteBrand(id) {
|
|
845
|
+
db.prepare("DELETE FROM brands WHERE id=?").run(id);
|
|
846
|
+
},
|
|
847
|
+
// projects
|
|
848
|
+
createProject(brandId, name) {
|
|
849
|
+
const id = randomUUID();
|
|
850
|
+
db.prepare("INSERT INTO projects (id, brand_id, name, slug) VALUES (?,?,?,?)").run(
|
|
851
|
+
id,
|
|
852
|
+
brandId,
|
|
853
|
+
name,
|
|
854
|
+
uniqueProjectSlug(db, brandId, name, id)
|
|
855
|
+
);
|
|
856
|
+
const rootId = randomUUID();
|
|
857
|
+
db.prepare("INSERT INTO nodes (id, project_id, parent_id, kind, status) VALUES (?,?,NULL,'root','done')").run(
|
|
858
|
+
rootId,
|
|
859
|
+
id
|
|
860
|
+
);
|
|
861
|
+
return { project: this.getProject(id), root: this.getNode(rootId) };
|
|
862
|
+
},
|
|
863
|
+
deleteProject(id) {
|
|
864
|
+
db.prepare("DELETE FROM projects WHERE id=?").run(id);
|
|
865
|
+
},
|
|
866
|
+
getProject(id) {
|
|
867
|
+
const r = db.prepare("SELECT * FROM projects WHERE id=?").get(id);
|
|
868
|
+
return r ? { id: r.id, brandId: r.brand_id, name: r.name, slug: r.slug, createdAt: r.created_at } : null;
|
|
869
|
+
},
|
|
870
|
+
listProjects(brandId) {
|
|
871
|
+
return db.prepare("SELECT * FROM projects WHERE brand_id=? ORDER BY created_at").all(brandId).map(
|
|
872
|
+
(r) => ({
|
|
873
|
+
id: r.id,
|
|
874
|
+
brandId: r.brand_id,
|
|
875
|
+
name: r.name,
|
|
876
|
+
slug: r.slug,
|
|
877
|
+
createdAt: r.created_at
|
|
878
|
+
})
|
|
879
|
+
);
|
|
880
|
+
},
|
|
881
|
+
/**
|
|
882
|
+
* The brand's one project, made on demand.
|
|
883
|
+
*
|
|
884
|
+
* Every node still hangs from a project root, but that is plumbing now, not
|
|
885
|
+
* a place: nothing in the UI names it and nothing but this creates one. The
|
|
886
|
+
* five buttons that used to invent a project each call this instead, so a
|
|
887
|
+
* brand ends up with exactly one no matter which door you came through.
|
|
888
|
+
*/
|
|
889
|
+
workspaceFor(brandId) {
|
|
890
|
+
return this.listProjects(brandId)[0] ?? this.createProject(brandId, "Workspace").project;
|
|
891
|
+
},
|
|
892
|
+
// sets
|
|
893
|
+
createSet(brandId, name) {
|
|
894
|
+
const id = randomUUID();
|
|
895
|
+
db.prepare("INSERT INTO sets (id, brand_id, name, slug) VALUES (?,?,?,?)").run(
|
|
896
|
+
id,
|
|
897
|
+
brandId,
|
|
898
|
+
name,
|
|
899
|
+
uniqueSetSlug(db, brandId, name, id)
|
|
900
|
+
);
|
|
901
|
+
return this.getSet(id);
|
|
902
|
+
},
|
|
903
|
+
getSet(id) {
|
|
904
|
+
const r = db.prepare("SELECT * FROM sets WHERE id=?").get(id);
|
|
905
|
+
return r ? rowToSet(r) : null;
|
|
906
|
+
},
|
|
907
|
+
/**
|
|
908
|
+
* Most recently touched first, everywhere. The old project lists each chose
|
|
909
|
+
* their own order — one ascending by creation, one descending, one capped
|
|
910
|
+
* before it sorted — so the same six names came back in three different
|
|
911
|
+
* sequences depending on which control you opened.
|
|
912
|
+
*/
|
|
913
|
+
listSets(brandId) {
|
|
914
|
+
return db.prepare("SELECT * FROM sets WHERE brand_id=? ORDER BY updated_at DESC, created_at DESC").all(brandId).map(rowToSet);
|
|
915
|
+
},
|
|
916
|
+
renameSet(id, name) {
|
|
917
|
+
const current = this.getSet(id);
|
|
918
|
+
if (!current) return null;
|
|
919
|
+
db.prepare("UPDATE sets SET name=?, slug=?, updated_at=datetime('now') WHERE id=?").run(
|
|
920
|
+
name,
|
|
921
|
+
uniqueSetSlug(db, current.brandId, name, id),
|
|
922
|
+
id
|
|
923
|
+
);
|
|
924
|
+
return this.getSet(id);
|
|
925
|
+
},
|
|
926
|
+
/** The set goes; the shots do not. Membership is a label, never ownership. */
|
|
927
|
+
deleteSet(id) {
|
|
928
|
+
db.prepare("DELETE FROM sets WHERE id=?").run(id);
|
|
929
|
+
},
|
|
930
|
+
addToSet(setId, nodeIds) {
|
|
931
|
+
const add = db.prepare("INSERT OR IGNORE INTO set_nodes (set_id, node_id) VALUES (?,?)");
|
|
932
|
+
db.transaction(() => {
|
|
933
|
+
for (const nodeId of nodeIds) add.run(setId, nodeId);
|
|
934
|
+
db.prepare("UPDATE sets SET updated_at=datetime('now') WHERE id=?").run(setId);
|
|
935
|
+
})();
|
|
936
|
+
},
|
|
937
|
+
removeFromSet(setId, nodeId) {
|
|
938
|
+
db.transaction(() => {
|
|
939
|
+
db.prepare("DELETE FROM set_nodes WHERE set_id=? AND node_id=?").run(setId, nodeId);
|
|
940
|
+
db.prepare("UPDATE sets SET updated_at=datetime('now') WHERE id=?").run(setId);
|
|
941
|
+
})();
|
|
942
|
+
},
|
|
943
|
+
/**
|
|
944
|
+
* Every membership in the brand, keyed by set. One query rather than one
|
|
945
|
+
* per set, because the workspace screen filters on the client: the feed is
|
|
946
|
+
* already loaded, and a set is only a subset of it.
|
|
947
|
+
*/
|
|
948
|
+
membershipFor(brandId) {
|
|
949
|
+
const rows = db.prepare(
|
|
950
|
+
`SELECT sn.set_id, sn.node_id
|
|
951
|
+
FROM set_nodes sn JOIN sets s ON s.id = sn.set_id
|
|
952
|
+
WHERE s.brand_id = ?
|
|
953
|
+
ORDER BY sn.added_at`
|
|
954
|
+
).all(brandId);
|
|
955
|
+
const out = {};
|
|
956
|
+
for (const r of rows) {
|
|
957
|
+
if (!out[r.set_id]) out[r.set_id] = [];
|
|
958
|
+
out[r.set_id].push(r.node_id);
|
|
959
|
+
}
|
|
960
|
+
return out;
|
|
961
|
+
},
|
|
962
|
+
/** One set's members, in the order they were filed. */
|
|
963
|
+
membersOf(setId) {
|
|
964
|
+
return db.prepare("SELECT node_id FROM set_nodes WHERE set_id=? ORDER BY added_at, node_id").all(setId).map((r) => r.node_id);
|
|
965
|
+
},
|
|
966
|
+
// nodes / version tree
|
|
967
|
+
addNode(input) {
|
|
968
|
+
if (input.parentId) {
|
|
969
|
+
const parent = this.getNode(input.parentId);
|
|
970
|
+
if (!parent || parent.projectId !== input.projectId) throw new Error("parent node not found in project");
|
|
971
|
+
}
|
|
972
|
+
const id = randomUUID();
|
|
973
|
+
db.prepare(
|
|
974
|
+
"INSERT INTO nodes (id, project_id, parent_id, kind, prompt, engine_id, created_at) VALUES (?,?,?,?,?,?, strftime('%Y-%m-%d %H:%M:%f','now'))"
|
|
975
|
+
).run(id, input.projectId, input.parentId, input.kind, input.prompt, input.engineId);
|
|
976
|
+
return this.getNode(id);
|
|
977
|
+
},
|
|
978
|
+
/**
|
|
979
|
+
* One multi-shot request, N first-class sibling nodes, one transaction.
|
|
980
|
+
* Slot 0 gets the newest stamp (see batchStamps) so the newest-first feed
|
|
981
|
+
* reads the batch in request order; batch_id is the first node's id, held
|
|
982
|
+
* by every sibling including the first, and stays null for a single send
|
|
983
|
+
* — one shot is not a batch.
|
|
984
|
+
*/
|
|
985
|
+
addNodes(input) {
|
|
986
|
+
if (input.parentId) {
|
|
987
|
+
const parent = this.getNode(input.parentId);
|
|
988
|
+
if (!parent || parent.projectId !== input.projectId) throw new Error("parent node not found in project");
|
|
989
|
+
}
|
|
990
|
+
const count = Math.max(1, Math.floor(input.count));
|
|
991
|
+
const ids = Array.from({ length: count }, () => randomUUID());
|
|
992
|
+
const stamps = batchStamps(count);
|
|
993
|
+
const batchId = count > 1 ? ids[0] : null;
|
|
994
|
+
const insert = db.prepare(
|
|
995
|
+
"INSERT INTO nodes (id, project_id, parent_id, kind, prompt, engine_id, created_at, batch_id, batch_index) VALUES (?,?,?,?,?,?,?,?,?)"
|
|
996
|
+
);
|
|
997
|
+
db.transaction(() => {
|
|
998
|
+
for (let i = 0; i < count; i++) {
|
|
999
|
+
insert.run(
|
|
1000
|
+
ids[i],
|
|
1001
|
+
input.projectId,
|
|
1002
|
+
input.parentId,
|
|
1003
|
+
input.kind,
|
|
1004
|
+
input.prompt,
|
|
1005
|
+
input.engineId,
|
|
1006
|
+
stamps[i],
|
|
1007
|
+
batchId,
|
|
1008
|
+
i
|
|
1009
|
+
);
|
|
1010
|
+
}
|
|
1011
|
+
})();
|
|
1012
|
+
return ids.map((id) => this.getNode(id));
|
|
1013
|
+
},
|
|
1014
|
+
completeNode(id, result) {
|
|
1015
|
+
db.prepare("UPDATE nodes SET status='done', images=?, cost_usd=?, duration_ms=? WHERE id=?").run(
|
|
1016
|
+
JSON.stringify(result.images),
|
|
1017
|
+
result.costUsd,
|
|
1018
|
+
result.durationMs ?? null,
|
|
1019
|
+
id
|
|
1020
|
+
);
|
|
1021
|
+
},
|
|
1022
|
+
/**
|
|
1023
|
+
* The run's money, written once it is known. A batch's first sibling used
|
|
1024
|
+
* to be charged inside completeNode, at the end of the whole call; a
|
|
1025
|
+
* sibling now completes the moment its own image lands, and the cost is
|
|
1026
|
+
* only known when the call resolves, so it is written afterwards, onto a
|
|
1027
|
+
* node that finished. A failed or running node keeps 0, as before.
|
|
1028
|
+
*/
|
|
1029
|
+
chargeNode(id, costUsd) {
|
|
1030
|
+
db.prepare("UPDATE nodes SET cost_usd=? WHERE id=? AND status='done'").run(costUsd, id);
|
|
1031
|
+
},
|
|
1032
|
+
failNode(id, error) {
|
|
1033
|
+
db.prepare("UPDATE nodes SET status='error', error=? WHERE id=?").run(error, id);
|
|
1034
|
+
},
|
|
1035
|
+
cancelNode(id) {
|
|
1036
|
+
db.prepare("UPDATE nodes SET status='cancelled' WHERE id=?").run(id);
|
|
1037
|
+
},
|
|
1038
|
+
getNode(id) {
|
|
1039
|
+
const r = db.prepare(`SELECT n.*, ${CHILD_COUNT_SQL} AS child_count FROM nodes n WHERE n.id=?`).get(id);
|
|
1040
|
+
return r ? rowToNode(r) : null;
|
|
1041
|
+
},
|
|
1042
|
+
/** The list shape of one shot: what a keep or an archive answers with. */
|
|
1043
|
+
getFeedNode(id) {
|
|
1044
|
+
const r = db.prepare(`SELECT ${FEED_COLS} FROM nodes n WHERE n.id=?`).get(id);
|
|
1045
|
+
return r ? rowToFeedNode(r) : null;
|
|
1046
|
+
},
|
|
1047
|
+
/** The project's root, by index, rather than the whole tree read to find it. */
|
|
1048
|
+
rootFor(projectId) {
|
|
1049
|
+
const rows = db.prepare(`SELECT n.*, ${CHILD_COUNT_SQL} AS child_count FROM nodes n WHERE n.project_id=? AND n.kind='root'`).all(projectId);
|
|
1050
|
+
rows.sort(
|
|
1051
|
+
(a, b) => String(a.created_at).localeCompare(String(b.created_at)) || String(a.id).localeCompare(String(b.id))
|
|
1052
|
+
);
|
|
1053
|
+
return rows.length ? rowToNode(rows[0]) : null;
|
|
1054
|
+
},
|
|
1055
|
+
treeFor(projectId) {
|
|
1056
|
+
return db.prepare(
|
|
1057
|
+
`SELECT n.*, ${CHILD_COUNT_SQL} AS child_count FROM nodes n WHERE n.project_id=? ORDER BY n.created_at, n.id`
|
|
1058
|
+
).all(projectId).map(rowToNode);
|
|
1059
|
+
},
|
|
1060
|
+
/**
|
|
1061
|
+
* One page of a project's shots for a place, lens, search and sort.
|
|
1062
|
+
*
|
|
1063
|
+
* Keyset paging on the sort's own columns, never OFFSET: the cost of page
|
|
1064
|
+
* forty is the cost of page one, and a shot landing between two pages
|
|
1065
|
+
* shifts nothing already read. Every clause is served by an index or by
|
|
1066
|
+
* the search index; the whole workspace is never read.
|
|
1067
|
+
*/
|
|
1068
|
+
feedPage(projectId, q) {
|
|
1069
|
+
const limit = Math.max(1, Math.min(FEED_PAGE_MAX, Math.floor(q.limit ?? 60)));
|
|
1070
|
+
const sort = q.sort ?? "newest";
|
|
1071
|
+
const params = { project: projectId, limit: limit + 1 };
|
|
1072
|
+
const where = ["n.project_id = @project", ...filterSql(q, params, true)];
|
|
1073
|
+
const { order, after } = sortSql(sort, q.cursor ? decodeCursor(q.cursor) : null, params);
|
|
1074
|
+
if (after) where.push(after);
|
|
1075
|
+
const rows = db.prepare(`SELECT ${FEED_COLS} FROM nodes n WHERE ${where.join(" AND ")} ORDER BY ${order} LIMIT @limit`).all(params);
|
|
1076
|
+
const items = rows.slice(0, limit).map(rowToFeedNode);
|
|
1077
|
+
const more = rows.length > limit;
|
|
1078
|
+
return { items, next: more && items.length ? encodeCursor(keysetOf(items[items.length - 1], sort)) : null };
|
|
1079
|
+
},
|
|
1080
|
+
/**
|
|
1081
|
+
* What each lens would show from a place and search, plus the two
|
|
1082
|
+
* unscoped totals. The scoped sums and the total read the state index
|
|
1083
|
+
* alone (project, kind, archived, kept: nothing that needs the row), and
|
|
1084
|
+
* the grouped count walks the brand's memberships rather than asking
|
|
1085
|
+
* every shot whether it is in a set.
|
|
1086
|
+
*/
|
|
1087
|
+
feedCounts(projectId, f) {
|
|
1088
|
+
const params = { project: projectId };
|
|
1089
|
+
const where = ["n.project_id = @project", ...filterSql({ ...f, lens: void 0 }, params, false)];
|
|
1090
|
+
const scoped = db.prepare(
|
|
1091
|
+
`SELECT coalesce(sum(n.archived = 0), 0) AS live, coalesce(sum(n.archived = 0 AND n.kept = 1), 0) AS kept,
|
|
1092
|
+
coalesce(sum(n.archived = 1), 0) AS archived
|
|
1093
|
+
FROM nodes n WHERE ${where.join(" AND ")}`
|
|
1094
|
+
).get(params);
|
|
1095
|
+
const totals = db.prepare(
|
|
1096
|
+
`SELECT count(*) AS total, coalesce(sum(n.archived = 0), 0) AS live
|
|
1097
|
+
FROM nodes n WHERE n.project_id = ? AND n.kind != 'root'`
|
|
1098
|
+
).get(projectId);
|
|
1099
|
+
const grouped = db.prepare(
|
|
1100
|
+
`SELECT count(DISTINCT sn.node_id) AS c
|
|
1101
|
+
FROM sets s
|
|
1102
|
+
CROSS JOIN set_nodes sn ON sn.set_id = s.id
|
|
1103
|
+
CROSS JOIN nodes n ON n.id = sn.node_id
|
|
1104
|
+
WHERE s.brand_id = (SELECT brand_id FROM projects WHERE id = ?)
|
|
1105
|
+
AND n.project_id = ? AND n.archived = 0`
|
|
1106
|
+
).get(projectId, projectId).c;
|
|
1107
|
+
return {
|
|
1108
|
+
total: totals.total,
|
|
1109
|
+
all: scoped.live,
|
|
1110
|
+
keepers: scoped.kept,
|
|
1111
|
+
archived: scoped.archived,
|
|
1112
|
+
ungrouped: totals.live - grouped
|
|
1113
|
+
};
|
|
1114
|
+
},
|
|
1115
|
+
/**
|
|
1116
|
+
* Where one shot sits in its tree, from the parent index: its ancestors
|
|
1117
|
+
* up to (never including) the root, the siblings around it, and what
|
|
1118
|
+
* hangs off it. Archived versions stay in the strip, as they did when the
|
|
1119
|
+
* overlay walked the whole workspace.
|
|
1120
|
+
*
|
|
1121
|
+
* The siblings are a window: this shot, and up to twenty-five on either
|
|
1122
|
+
* side in filing order. A top-level shot's siblings are every top-level
|
|
1123
|
+
* shot in the brand, and the whole list was eighteen megabytes on a
|
|
1124
|
+
* brand of twenty thousand; the overlay only ever steps to a neighbour,
|
|
1125
|
+
* and each step asks again, so the window re-centres as it goes.
|
|
1126
|
+
*/
|
|
1127
|
+
lineageOf(id) {
|
|
1128
|
+
const node = this.getFeedNode(id);
|
|
1129
|
+
if (!node) return null;
|
|
1130
|
+
if (node.kind === "root") return { ancestors: [], siblings: [], children: [], history: [] };
|
|
1131
|
+
const ancestors = [];
|
|
1132
|
+
let cur = node.parentId ? this.getFeedNode(node.parentId) : null;
|
|
1133
|
+
for (let hops = 0; cur && cur.kind !== "root" && hops < 64; hops++) {
|
|
1134
|
+
ancestors.unshift(cur);
|
|
1135
|
+
cur = cur.parentId ? this.getFeedNode(cur.parentId) : null;
|
|
1136
|
+
}
|
|
1137
|
+
const before = db.prepare(
|
|
1138
|
+
`SELECT count(*) AS c FROM nodes n
|
|
1139
|
+
WHERE n.parent_id IS ? AND (n.created_at < ? OR (n.created_at = ? AND n.id < ?))`
|
|
1140
|
+
).get(node.parentId, node.createdAt, node.createdAt, node.id).c;
|
|
1141
|
+
const skip = Math.max(0, before - LINEAGE_SIBLINGS_RADIUS);
|
|
1142
|
+
const take = before - skip + 1 + LINEAGE_SIBLINGS_RADIUS;
|
|
1143
|
+
const siblings = db.prepare(
|
|
1144
|
+
`SELECT ${FEED_COLS} FROM nodes n WHERE n.parent_id IS ?
|
|
1145
|
+
ORDER BY n.created_at, n.id LIMIT ? OFFSET ?`
|
|
1146
|
+
).all(node.parentId, take, skip).map(rowToFeedNode);
|
|
1147
|
+
const children = db.prepare(`SELECT ${FEED_COLS} FROM nodes n WHERE n.parent_id = ? ORDER BY n.created_at, n.id LIMIT ?`).all(node.id, LINEAGE_CHILDREN_MAX).map(rowToFeedNode);
|
|
1148
|
+
const rootShot = ancestors[0] ?? node;
|
|
1149
|
+
const history = db.prepare(
|
|
1150
|
+
`WITH RECURSIVE d(id) AS (SELECT @root UNION ALL SELECT c.id FROM nodes c JOIN d ON c.parent_id = d.id)
|
|
1151
|
+
SELECT ${FEED_COLS} FROM nodes n
|
|
1152
|
+
WHERE n.id IN (SELECT id FROM d) AND (n.archived = 0 OR n.id = @self)
|
|
1153
|
+
ORDER BY n.created_at, n.id LIMIT @limit`
|
|
1154
|
+
).all({ root: rootShot.id, self: node.id, limit: LINEAGE_HISTORY_MAX }).map(rowToFeedNode);
|
|
1155
|
+
if (!history.some((n) => n.id === node.id)) {
|
|
1156
|
+
history.pop();
|
|
1157
|
+
history.push(node);
|
|
1158
|
+
}
|
|
1159
|
+
return { ancestors, siblings, children, history };
|
|
1160
|
+
},
|
|
1161
|
+
/** The newest finished shots, newest first, for the rail and the attach panel. */
|
|
1162
|
+
recentShots(projectId, limit = 48) {
|
|
1163
|
+
return db.prepare(
|
|
1164
|
+
`SELECT ${FEED_COLS} FROM nodes n
|
|
1165
|
+
WHERE n.project_id = ? AND n.kind != 'root' AND n.status = 'done' AND n.images != '[]'
|
|
1166
|
+
ORDER BY n.created_at DESC, n.id DESC LIMIT ?`
|
|
1167
|
+
).all(projectId, Math.max(1, Math.min(FEED_PAGE_MAX, limit))).map(rowToFeedNode);
|
|
1168
|
+
},
|
|
1169
|
+
/** A year of runs by day, counted where the rows are. */
|
|
1170
|
+
usageByDay(brandId) {
|
|
1171
|
+
return db.prepare(
|
|
1172
|
+
`SELECT substr(n.created_at, 1, 10) AS day,
|
|
1173
|
+
coalesce(sum(n.kind = 'generation'), 0) AS generations,
|
|
1174
|
+
coalesce(sum(n.kind = 'edit'), 0) AS edits
|
|
1175
|
+
FROM nodes n JOIN projects p ON p.id = n.project_id
|
|
1176
|
+
WHERE p.brand_id = ? AND n.kind != 'root' AND n.created_at >= date('now', '-400 days')
|
|
1177
|
+
GROUP BY day ORDER BY day`
|
|
1178
|
+
).all(brandId).map((r) => ({ day: String(r.day), generations: Number(r.generations), edits: Number(r.edits) }));
|
|
1179
|
+
},
|
|
1180
|
+
/** The compiled prompt of the shot that produced an image, for a reference described in words. */
|
|
1181
|
+
promptForImage(brandId, hash) {
|
|
1182
|
+
const r = db.prepare(
|
|
1183
|
+
`SELECT n.prompt FROM nodes n JOIN projects p ON p.id = n.project_id
|
|
1184
|
+
WHERE p.brand_id = ? AND n.images LIKE ? ORDER BY n.created_at, n.id LIMIT 1`
|
|
1185
|
+
).get(brandId, `%"${hash}"%`);
|
|
1186
|
+
return r?.prompt ?? null;
|
|
1187
|
+
},
|
|
1188
|
+
/**
|
|
1189
|
+
* Every piece of work a brand has in flight, plus whatever finished lately,
|
|
1190
|
+
* in one query. The bar outlives the project screen, so the thing that used
|
|
1191
|
+
* to be answerable only by polling one tree at a time has to be answerable
|
|
1192
|
+
* without knowing which project you are looking at.
|
|
1193
|
+
*
|
|
1194
|
+
* The cutoff is computed in SQL rather than passed in: created_at is
|
|
1195
|
+
* SQLite's own datetime('now') text, and comparing that against a caller's
|
|
1196
|
+
* ISO string is a silent, timezone-shaped mis-filter.
|
|
1197
|
+
*/
|
|
1198
|
+
recentActivity(brandId, limit = 60) {
|
|
1199
|
+
const cols = `${FEED_COLS}, (
|
|
1200
|
+
SELECT group_concat(s.name, char(31))
|
|
1201
|
+
FROM set_nodes sn JOIN sets s ON s.id = sn.set_id
|
|
1202
|
+
WHERE sn.node_id = n.id
|
|
1203
|
+
) AS set_names`;
|
|
1204
|
+
const inBrand = "n.project_id IN (SELECT id FROM projects WHERE brand_id = @brand)";
|
|
1205
|
+
const rows = db.prepare(
|
|
1206
|
+
`SELECT * FROM (
|
|
1207
|
+
SELECT ${cols} FROM nodes n
|
|
1208
|
+
WHERE ${inBrand} AND n.kind != 'root' AND n.status = 'running'
|
|
1209
|
+
UNION ALL
|
|
1210
|
+
SELECT ${cols} FROM nodes n
|
|
1211
|
+
WHERE ${inBrand} AND n.kind != 'root' AND n.status != 'running'
|
|
1212
|
+
AND n.created_at >= datetime('now', '-2 days')
|
|
1213
|
+
)
|
|
1214
|
+
ORDER BY created_at DESC, id DESC
|
|
1215
|
+
LIMIT @limit`
|
|
1216
|
+
).all({ brand: brandId, limit });
|
|
1217
|
+
return rows.map((r) => ({
|
|
1218
|
+
...rowToFeedNode(r),
|
|
1219
|
+
setNames: r.set_names ? String(r.set_names).split(SET_NAME_SEP) : []
|
|
1220
|
+
}));
|
|
1221
|
+
},
|
|
1222
|
+
setKept(id, kept) {
|
|
1223
|
+
db.prepare("UPDATE nodes SET kept=? WHERE id=?").run(kept ? 1 : 0, id);
|
|
1224
|
+
},
|
|
1225
|
+
/**
|
|
1226
|
+
* Archiving also clears the keeper mark.
|
|
1227
|
+
*
|
|
1228
|
+
* The two flags were independent, and the Keepers lens reads the live list,
|
|
1229
|
+
* so archiving a keeper removed it from Keepers and from the Keepers count
|
|
1230
|
+
* without saying anything: the star stayed lit on a shot that was no longer
|
|
1231
|
+
* in the shortlist it claimed to be in. Keepers is a live shortlist and
|
|
1232
|
+
* archive means put away, so one clears the other and the two can never
|
|
1233
|
+
* disagree. Restoring does not re-star: the judgement was made once and
|
|
1234
|
+
* putting the shot back is not the same as making it again.
|
|
1235
|
+
*/
|
|
1236
|
+
setArchived(id, archived) {
|
|
1237
|
+
if (archived) db.prepare("UPDATE nodes SET archived=1, kept=0 WHERE id=?").run(id);
|
|
1238
|
+
else db.prepare("UPDATE nodes SET archived=0 WHERE id=?").run(id);
|
|
1239
|
+
},
|
|
1240
|
+
/** Permanent. Orphans any children rather than blocking or cascading —
|
|
1241
|
+
* same technique collapseProjects already uses for a surplus root. */
|
|
1242
|
+
deleteNode(id) {
|
|
1243
|
+
db.prepare("UPDATE nodes SET parent_id=NULL WHERE parent_id=?").run(id);
|
|
1244
|
+
db.prepare("DELETE FROM nodes WHERE id=?").run(id);
|
|
1245
|
+
},
|
|
1246
|
+
setBrief(id, brief) {
|
|
1247
|
+
db.prepare("UPDATE nodes SET brief=? WHERE id=?").run(JSON.stringify(brief), id);
|
|
1248
|
+
},
|
|
1249
|
+
setOverlays(id, overlays) {
|
|
1250
|
+
db.prepare("UPDATE nodes SET overlays=? WHERE id=?").run(JSON.stringify(overlays), id);
|
|
1251
|
+
},
|
|
1252
|
+
// settings
|
|
1253
|
+
getSetting(key) {
|
|
1254
|
+
const r = db.prepare("SELECT value FROM settings WHERE key=?").get(key);
|
|
1255
|
+
return r ? r.value : null;
|
|
1256
|
+
},
|
|
1257
|
+
setSetting(key, value) {
|
|
1258
|
+
db.prepare(
|
|
1259
|
+
"INSERT INTO settings (key,value) VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value"
|
|
1260
|
+
).run(key, value);
|
|
1261
|
+
},
|
|
1262
|
+
allSettings() {
|
|
1263
|
+
const rows = db.prepare("SELECT key, value FROM settings").all();
|
|
1264
|
+
return Object.fromEntries(rows.map((r) => [r.key, r.value]));
|
|
1265
|
+
}
|
|
1266
|
+
};
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
// ../core/src/catalog/rows.ts
|
|
1270
|
+
function rowSource(r) {
|
|
1271
|
+
return {
|
|
1272
|
+
id: r.id,
|
|
1273
|
+
brandId: r.brand_id,
|
|
1274
|
+
url: r.url,
|
|
1275
|
+
platform: r.platform,
|
|
1276
|
+
status: r.status,
|
|
1277
|
+
lastImportAt: r.last_import_at,
|
|
1278
|
+
createdAt: r.created_at,
|
|
1279
|
+
updatedAt: r.updated_at
|
|
1280
|
+
};
|
|
1281
|
+
}
|
|
1282
|
+
function rowProduct(r) {
|
|
1283
|
+
return {
|
|
1284
|
+
id: r.id,
|
|
1285
|
+
sourceId: r.source_id,
|
|
1286
|
+
brandId: r.brand_id,
|
|
1287
|
+
externalKey: r.external_key,
|
|
1288
|
+
title: r.title,
|
|
1289
|
+
descriptionHtml: r.description_html,
|
|
1290
|
+
url: r.url,
|
|
1291
|
+
handle: r.handle,
|
|
1292
|
+
vendor: r.vendor,
|
|
1293
|
+
productType: r.product_type,
|
|
1294
|
+
tags: JSON.parse(r.tags || "[]"),
|
|
1295
|
+
category: r.category,
|
|
1296
|
+
price: r.price,
|
|
1297
|
+
compareAtPrice: r.compare_at_price,
|
|
1298
|
+
currency: r.currency,
|
|
1299
|
+
available: r.available == null ? null : !!r.available,
|
|
1300
|
+
status: r.status,
|
|
1301
|
+
raw: r.raw ? JSON.parse(r.raw) : null,
|
|
1302
|
+
variant: r.variant ?? null,
|
|
1303
|
+
material: r.material ?? null,
|
|
1304
|
+
dimensions: r.dimensions ?? null,
|
|
1305
|
+
createdAt: r.created_at,
|
|
1306
|
+
updatedAt: r.updated_at
|
|
1307
|
+
};
|
|
1308
|
+
}
|
|
1309
|
+
function rowJob(r) {
|
|
1310
|
+
return {
|
|
1311
|
+
id: r.id,
|
|
1312
|
+
brandId: r.brand_id,
|
|
1313
|
+
sourceId: r.source_id,
|
|
1314
|
+
url: r.url,
|
|
1315
|
+
platform: r.platform,
|
|
1316
|
+
stage: r.stage,
|
|
1317
|
+
discovered: r.discovered,
|
|
1318
|
+
fetched: r.fetched,
|
|
1319
|
+
upserted: r.upserted,
|
|
1320
|
+
imagesDone: r.images_done,
|
|
1321
|
+
imagesTotal: r.images_total,
|
|
1322
|
+
errors: JSON.parse(r.errors || "[]"),
|
|
1323
|
+
warnings: JSON.parse(r.warnings || "[]"),
|
|
1324
|
+
message: r.message,
|
|
1325
|
+
createdAt: r.created_at,
|
|
1326
|
+
updatedAt: r.updated_at,
|
|
1327
|
+
finishedAt: r.finished_at
|
|
1328
|
+
};
|
|
1329
|
+
}
|
|
1330
|
+
var jobById = (db, id) => {
|
|
1331
|
+
const r = db.prepare("SELECT * FROM import_jobs WHERE id=?").get(id);
|
|
1332
|
+
return r ? rowJob(r) : null;
|
|
1333
|
+
};
|
|
1334
|
+
var productById = (db, id) => {
|
|
1335
|
+
const r = db.prepare("SELECT * FROM catalog_products WHERE id=?").get(id);
|
|
1336
|
+
return r ? rowProduct(r) : null;
|
|
1337
|
+
};
|
|
1338
|
+
var productsFor = (db, brandId) => db.prepare(
|
|
1339
|
+
"SELECT * FROM catalog_products WHERE brand_id=? AND status!='unavailable' ORDER BY title COLLATE NOCASE"
|
|
1340
|
+
).all(brandId).map(rowProduct);
|
|
1341
|
+
var rowVariant = (r) => ({
|
|
1342
|
+
id: r.id,
|
|
1343
|
+
productId: r.product_id,
|
|
1344
|
+
externalKey: r.external_key,
|
|
1345
|
+
title: r.title,
|
|
1346
|
+
sku: r.sku,
|
|
1347
|
+
price: r.price,
|
|
1348
|
+
compareAtPrice: r.compare_at_price,
|
|
1349
|
+
currency: r.currency,
|
|
1350
|
+
available: r.available == null ? null : !!r.available,
|
|
1351
|
+
options: JSON.parse(r.options || "{}")
|
|
1352
|
+
});
|
|
1353
|
+
var rowImage = (r) => ({
|
|
1354
|
+
id: r.id,
|
|
1355
|
+
productId: r.product_id,
|
|
1356
|
+
sourceUrl: r.source_url,
|
|
1357
|
+
assetRef: r.asset_ref,
|
|
1358
|
+
width: r.width,
|
|
1359
|
+
height: r.height,
|
|
1360
|
+
position: r.position,
|
|
1361
|
+
alt: r.alt,
|
|
1362
|
+
angle: r.angle ?? null,
|
|
1363
|
+
excluded: !!r.excluded
|
|
1364
|
+
});
|
|
1365
|
+
var variantsForBrand = (db, brandId) => {
|
|
1366
|
+
const out = /* @__PURE__ */ new Map();
|
|
1367
|
+
const rows = db.prepare(
|
|
1368
|
+
`SELECT v.* FROM catalog_variants v JOIN catalog_products p ON p.id = v.product_id
|
|
1369
|
+
WHERE p.brand_id=? AND p.status!='unavailable' ORDER BY v.product_id, v.rowid`
|
|
1370
|
+
).all(brandId);
|
|
1371
|
+
for (const r of rows) {
|
|
1372
|
+
const list = out.get(r.product_id) ?? [];
|
|
1373
|
+
list.push(rowVariant(r));
|
|
1374
|
+
out.set(r.product_id, list);
|
|
1375
|
+
}
|
|
1376
|
+
return out;
|
|
1377
|
+
};
|
|
1378
|
+
var imagesForBrand = (db, brandId) => {
|
|
1379
|
+
const out = /* @__PURE__ */ new Map();
|
|
1380
|
+
const rows = db.prepare(
|
|
1381
|
+
`SELECT i.* FROM catalog_images i JOIN catalog_products p ON p.id = i.product_id
|
|
1382
|
+
WHERE p.brand_id=? AND p.status!='unavailable' ORDER BY i.product_id, i.position`
|
|
1383
|
+
).all(brandId);
|
|
1384
|
+
for (const r of rows) {
|
|
1385
|
+
const list = out.get(r.product_id) ?? [];
|
|
1386
|
+
list.push(rowImage(r));
|
|
1387
|
+
out.set(r.product_id, list);
|
|
1388
|
+
}
|
|
1389
|
+
return out;
|
|
1390
|
+
};
|
|
1391
|
+
var variantsFor = (db, productId) => db.prepare("SELECT * FROM catalog_variants WHERE product_id=?").all(productId).map((r) => ({
|
|
1392
|
+
id: r.id,
|
|
1393
|
+
productId: r.product_id,
|
|
1394
|
+
externalKey: r.external_key,
|
|
1395
|
+
title: r.title,
|
|
1396
|
+
sku: r.sku,
|
|
1397
|
+
price: r.price,
|
|
1398
|
+
compareAtPrice: r.compare_at_price,
|
|
1399
|
+
currency: r.currency,
|
|
1400
|
+
available: r.available == null ? null : !!r.available,
|
|
1401
|
+
options: JSON.parse(r.options || "{}")
|
|
1402
|
+
}));
|
|
1403
|
+
var imagesFor = (db, productId) => db.prepare("SELECT * FROM catalog_images WHERE product_id=? ORDER BY position").all(productId).map(
|
|
1404
|
+
(r) => ({
|
|
1405
|
+
id: r.id,
|
|
1406
|
+
productId: r.product_id,
|
|
1407
|
+
sourceUrl: r.source_url,
|
|
1408
|
+
assetRef: r.asset_ref,
|
|
1409
|
+
width: r.width,
|
|
1410
|
+
height: r.height,
|
|
1411
|
+
position: r.position,
|
|
1412
|
+
alt: r.alt,
|
|
1413
|
+
angle: r.angle ?? null,
|
|
1414
|
+
excluded: !!r.excluded
|
|
1415
|
+
})
|
|
1416
|
+
);
|
|
1417
|
+
|
|
1418
|
+
// ../core/src/catalog/sources.ts
|
|
1419
|
+
function sourceMethods(db) {
|
|
1420
|
+
return {
|
|
1421
|
+
upsertSource(brandId, url, platform) {
|
|
1422
|
+
const existing = db.prepare("SELECT * FROM catalog_sources WHERE brand_id=? AND url=?").get(brandId, url);
|
|
1423
|
+
if (existing) {
|
|
1424
|
+
db.prepare("UPDATE catalog_sources SET platform=?, updated_at=datetime('now') WHERE id=?").run(
|
|
1425
|
+
platform,
|
|
1426
|
+
existing.id
|
|
1427
|
+
);
|
|
1428
|
+
return rowSource(db.prepare("SELECT * FROM catalog_sources WHERE id=?").get(existing.id));
|
|
1429
|
+
}
|
|
1430
|
+
const id = randomUUID();
|
|
1431
|
+
db.prepare("INSERT INTO catalog_sources (id, brand_id, url, platform, status) VALUES (?,?,?,?, 'idle')").run(
|
|
1432
|
+
id,
|
|
1433
|
+
brandId,
|
|
1434
|
+
url,
|
|
1435
|
+
platform
|
|
1436
|
+
);
|
|
1437
|
+
return rowSource(db.prepare("SELECT * FROM catalog_sources WHERE id=?").get(id));
|
|
1438
|
+
},
|
|
1439
|
+
getSourceForBrand(brandId) {
|
|
1440
|
+
const r = db.prepare("SELECT * FROM catalog_sources WHERE brand_id=? ORDER BY updated_at DESC LIMIT 1").get(brandId);
|
|
1441
|
+
return r ? rowSource(r) : null;
|
|
1442
|
+
},
|
|
1443
|
+
getSource(id) {
|
|
1444
|
+
const r = db.prepare("SELECT * FROM catalog_sources WHERE id=?").get(id);
|
|
1445
|
+
return r ? rowSource(r) : null;
|
|
1446
|
+
},
|
|
1447
|
+
setSourceStatus(id, status, touchImport = false) {
|
|
1448
|
+
if (touchImport) {
|
|
1449
|
+
db.prepare(
|
|
1450
|
+
"UPDATE catalog_sources SET status=?, last_import_at=datetime('now'), updated_at=datetime('now') WHERE id=?"
|
|
1451
|
+
).run(status, id);
|
|
1452
|
+
} else {
|
|
1453
|
+
db.prepare("UPDATE catalog_sources SET status=?, updated_at=datetime('now') WHERE id=?").run(status, id);
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
};
|
|
1457
|
+
}
|
|
1458
|
+
function jobMethods(db) {
|
|
1459
|
+
return {
|
|
1460
|
+
createJob(input) {
|
|
1461
|
+
const id = randomUUID();
|
|
1462
|
+
db.prepare(
|
|
1463
|
+
`INSERT INTO import_jobs (id, brand_id, source_id, url, platform, stage)
|
|
1464
|
+
VALUES (?,?,?,?,?,'queued')`
|
|
1465
|
+
).run(id, input.brandId, input.sourceId ?? null, input.url, input.platform ?? "unknown");
|
|
1466
|
+
return jobById(db, id);
|
|
1467
|
+
},
|
|
1468
|
+
getJob(id) {
|
|
1469
|
+
const r = db.prepare("SELECT * FROM import_jobs WHERE id=?").get(id);
|
|
1470
|
+
return r ? rowJob(r) : null;
|
|
1471
|
+
},
|
|
1472
|
+
listJobs(brandId) {
|
|
1473
|
+
return db.prepare("SELECT * FROM import_jobs WHERE brand_id=? ORDER BY created_at DESC").all(brandId).map(rowJob);
|
|
1474
|
+
},
|
|
1475
|
+
updateJob(id, patch) {
|
|
1476
|
+
const cur = jobById(db, id);
|
|
1477
|
+
if (!cur) return null;
|
|
1478
|
+
const stage = patch.stage ?? cur.stage;
|
|
1479
|
+
const finished = patch.finished || stage === "completed" || stage === "partial" || stage === "failed";
|
|
1480
|
+
db.prepare(
|
|
1481
|
+
`UPDATE import_jobs SET
|
|
1482
|
+
source_id=?, platform=?, stage=?, discovered=?, fetched=?, upserted=?,
|
|
1483
|
+
images_done=?, images_total=?, errors=?, warnings=?, message=?,
|
|
1484
|
+
updated_at=datetime('now'),
|
|
1485
|
+
finished_at=CASE WHEN ? THEN COALESCE(finished_at, datetime('now')) ELSE finished_at END
|
|
1486
|
+
WHERE id=?`
|
|
1487
|
+
).run(
|
|
1488
|
+
patch.sourceId !== void 0 ? patch.sourceId : cur.sourceId,
|
|
1489
|
+
patch.platform ?? cur.platform,
|
|
1490
|
+
stage,
|
|
1491
|
+
patch.discovered ?? cur.discovered,
|
|
1492
|
+
patch.fetched ?? cur.fetched,
|
|
1493
|
+
patch.upserted ?? cur.upserted,
|
|
1494
|
+
patch.imagesDone ?? cur.imagesDone,
|
|
1495
|
+
patch.imagesTotal ?? cur.imagesTotal,
|
|
1496
|
+
JSON.stringify(patch.errors ?? cur.errors),
|
|
1497
|
+
JSON.stringify(patch.warnings ?? cur.warnings),
|
|
1498
|
+
patch.message !== void 0 ? patch.message : cur.message,
|
|
1499
|
+
finished ? 1 : 0,
|
|
1500
|
+
id
|
|
1501
|
+
);
|
|
1502
|
+
return jobById(db, id);
|
|
1503
|
+
}
|
|
1504
|
+
};
|
|
1505
|
+
}
|
|
1506
|
+
function productImportMethods(db) {
|
|
1507
|
+
return {
|
|
1508
|
+
upsertProduct(input) {
|
|
1509
|
+
const existing = db.prepare("SELECT id FROM catalog_products WHERE source_id=? AND external_key=?").get(input.sourceId, input.externalKey);
|
|
1510
|
+
const id = existing?.id ?? randomUUID();
|
|
1511
|
+
if (existing) {
|
|
1512
|
+
db.prepare(
|
|
1513
|
+
// `category` is deliberately absent. The store's own taxonomy is
|
|
1514
|
+
// `product_type`; `category` is this app's field, set by the user on
|
|
1515
|
+
// the product page, and a re-import used to silently revert it.
|
|
1516
|
+
`UPDATE catalog_products SET
|
|
1517
|
+
title=?, description_html=?, url=?, handle=?, vendor=?, product_type=?, tags=?,
|
|
1518
|
+
price=?, compare_at_price=?, currency=?, available=?, status='active', raw=?,
|
|
1519
|
+
updated_at=datetime('now')
|
|
1520
|
+
WHERE id=?`
|
|
1521
|
+
).run(
|
|
1522
|
+
input.title,
|
|
1523
|
+
input.descriptionHtml ?? null,
|
|
1524
|
+
input.url,
|
|
1525
|
+
input.handle ?? null,
|
|
1526
|
+
input.vendor ?? null,
|
|
1527
|
+
input.productType ?? null,
|
|
1528
|
+
JSON.stringify(input.tags ?? []),
|
|
1529
|
+
input.price ?? null,
|
|
1530
|
+
input.compareAtPrice ?? null,
|
|
1531
|
+
input.currency ?? null,
|
|
1532
|
+
input.available == null ? null : input.available ? 1 : 0,
|
|
1533
|
+
JSON.stringify(input.raw ?? null),
|
|
1534
|
+
id
|
|
1535
|
+
);
|
|
1536
|
+
} else {
|
|
1537
|
+
db.prepare(
|
|
1538
|
+
`INSERT INTO catalog_products (
|
|
1539
|
+
id, source_id, brand_id, external_key, title, description_html, url, handle, vendor,
|
|
1540
|
+
product_type, tags, category, price, compare_at_price, currency, available, status, raw
|
|
1541
|
+
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, 'active', ?)`
|
|
1542
|
+
).run(
|
|
1543
|
+
id,
|
|
1544
|
+
input.sourceId,
|
|
1545
|
+
input.brandId,
|
|
1546
|
+
input.externalKey,
|
|
1547
|
+
input.title,
|
|
1548
|
+
input.descriptionHtml ?? null,
|
|
1549
|
+
input.url,
|
|
1550
|
+
input.handle ?? null,
|
|
1551
|
+
input.vendor ?? null,
|
|
1552
|
+
input.productType ?? null,
|
|
1553
|
+
JSON.stringify(input.tags ?? []),
|
|
1554
|
+
input.category ?? null,
|
|
1555
|
+
input.price ?? null,
|
|
1556
|
+
input.compareAtPrice ?? null,
|
|
1557
|
+
input.currency ?? null,
|
|
1558
|
+
input.available == null ? null : input.available ? 1 : 0,
|
|
1559
|
+
JSON.stringify(input.raw ?? null)
|
|
1560
|
+
);
|
|
1561
|
+
}
|
|
1562
|
+
db.prepare("DELETE FROM catalog_variants WHERE product_id=?").run(id);
|
|
1563
|
+
for (const v of input.variants ?? []) {
|
|
1564
|
+
db.prepare(
|
|
1565
|
+
`INSERT INTO catalog_variants (id, product_id, external_key, title, sku, price, compare_at_price, currency, available, options)
|
|
1566
|
+
VALUES (?,?,?,?,?,?,?,?,?,?)`
|
|
1567
|
+
).run(
|
|
1568
|
+
randomUUID(),
|
|
1569
|
+
id,
|
|
1570
|
+
v.externalKey,
|
|
1571
|
+
v.title ?? null,
|
|
1572
|
+
v.sku ?? null,
|
|
1573
|
+
v.price ?? null,
|
|
1574
|
+
v.compareAtPrice ?? null,
|
|
1575
|
+
v.currency ?? null,
|
|
1576
|
+
v.available == null ? null : v.available ? 1 : 0,
|
|
1577
|
+
JSON.stringify(v.options ?? {})
|
|
1578
|
+
);
|
|
1579
|
+
}
|
|
1580
|
+
const existingImgs = db.prepare("SELECT * FROM catalog_images WHERE product_id=?").all(id);
|
|
1581
|
+
const byUrl = new Map(existingImgs.map((r) => [r.source_url, r]));
|
|
1582
|
+
const maxPos = existingImgs.reduce((m, r) => Math.max(m, r.position ?? 0), -1);
|
|
1583
|
+
const local = existingImgs.filter((r) => String(r.source_url ?? "").startsWith("local:"));
|
|
1584
|
+
let appended = 0;
|
|
1585
|
+
const merged = [
|
|
1586
|
+
...local.map((r) => ({
|
|
1587
|
+
id: r.id,
|
|
1588
|
+
sourceUrl: r.source_url,
|
|
1589
|
+
assetRef: r.asset_ref,
|
|
1590
|
+
width: r.width,
|
|
1591
|
+
height: r.height,
|
|
1592
|
+
alt: r.alt,
|
|
1593
|
+
angle: r.angle ?? null,
|
|
1594
|
+
excluded: r.excluded ?? 0,
|
|
1595
|
+
sort: r.position ?? 0
|
|
1596
|
+
})),
|
|
1597
|
+
...(input.images ?? []).map((img) => {
|
|
1598
|
+
const prev = byUrl.get(img.sourceUrl);
|
|
1599
|
+
return {
|
|
1600
|
+
id: prev?.id ?? randomUUID(),
|
|
1601
|
+
sourceUrl: img.sourceUrl,
|
|
1602
|
+
assetRef: img.assetRef ?? prev?.asset_ref ?? null,
|
|
1603
|
+
width: img.width ?? prev?.width ?? null,
|
|
1604
|
+
height: img.height ?? prev?.height ?? null,
|
|
1605
|
+
alt: img.alt ?? prev?.alt ?? null,
|
|
1606
|
+
angle: prev?.angle ?? null,
|
|
1607
|
+
// A crawl re-reporting an image is not the user changing their
|
|
1608
|
+
// mind about it.
|
|
1609
|
+
excluded: prev?.excluded ?? 0,
|
|
1610
|
+
sort: prev ? prev.position ?? 0 : maxPos + 1 + appended++
|
|
1611
|
+
};
|
|
1612
|
+
})
|
|
1613
|
+
].sort((a, b) => a.sort - b.sort);
|
|
1614
|
+
db.prepare("DELETE FROM catalog_images WHERE product_id=?").run(id);
|
|
1615
|
+
merged.forEach((img, position) => {
|
|
1616
|
+
db.prepare(
|
|
1617
|
+
`INSERT INTO catalog_images (id, product_id, source_url, asset_ref, width, height, position, alt, angle, excluded)
|
|
1618
|
+
VALUES (?,?,?,?,?,?,?,?,?,?)`
|
|
1619
|
+
).run(
|
|
1620
|
+
img.id,
|
|
1621
|
+
id,
|
|
1622
|
+
img.sourceUrl,
|
|
1623
|
+
img.assetRef,
|
|
1624
|
+
img.width,
|
|
1625
|
+
img.height,
|
|
1626
|
+
position,
|
|
1627
|
+
img.alt,
|
|
1628
|
+
img.angle,
|
|
1629
|
+
img.excluded
|
|
1630
|
+
);
|
|
1631
|
+
});
|
|
1632
|
+
for (const col of input.collections ?? []) {
|
|
1633
|
+
let colRow = db.prepare("SELECT id FROM catalog_collections WHERE source_id=? AND external_key=?").get(input.sourceId, col.externalKey);
|
|
1634
|
+
if (!colRow) {
|
|
1635
|
+
const colId = randomUUID();
|
|
1636
|
+
db.prepare(
|
|
1637
|
+
"INSERT INTO catalog_collections (id, source_id, external_key, title, url) VALUES (?,?,?,?,?)"
|
|
1638
|
+
).run(colId, input.sourceId, col.externalKey, col.title, col.url ?? null);
|
|
1639
|
+
colRow = { id: colId };
|
|
1640
|
+
} else {
|
|
1641
|
+
db.prepare("UPDATE catalog_collections SET title=?, url=? WHERE id=?").run(
|
|
1642
|
+
col.title,
|
|
1643
|
+
col.url ?? null,
|
|
1644
|
+
colRow.id
|
|
1645
|
+
);
|
|
1646
|
+
}
|
|
1647
|
+
db.prepare("INSERT OR IGNORE INTO catalog_collection_products (collection_id, product_id) VALUES (?,?)").run(
|
|
1648
|
+
colRow.id,
|
|
1649
|
+
id
|
|
1650
|
+
);
|
|
1651
|
+
}
|
|
1652
|
+
return productById(db, id);
|
|
1653
|
+
},
|
|
1654
|
+
setImageAsset(productId, sourceUrl, assetRef, meta) {
|
|
1655
|
+
db.prepare(
|
|
1656
|
+
`UPDATE catalog_images SET asset_ref=?, width=COALESCE(?, width), height=COALESCE(?, height)
|
|
1657
|
+
WHERE product_id=? AND source_url=?`
|
|
1658
|
+
).run(assetRef, meta?.width ?? null, meta?.height ?? null, productId, sourceUrl);
|
|
1659
|
+
},
|
|
1660
|
+
listImagesNeedingAssets(brandId, limit = 500) {
|
|
1661
|
+
return db.prepare(
|
|
1662
|
+
`SELECT i.* FROM catalog_images i
|
|
1663
|
+
JOIN catalog_products p ON p.id = i.product_id
|
|
1664
|
+
WHERE p.brand_id=? AND (i.asset_ref IS NULL OR i.asset_ref='')
|
|
1665
|
+
ORDER BY i.position ASC LIMIT ?`
|
|
1666
|
+
).all(brandId, limit).map((r) => ({
|
|
1667
|
+
id: r.id,
|
|
1668
|
+
productId: r.product_id,
|
|
1669
|
+
sourceUrl: r.source_url,
|
|
1670
|
+
assetRef: r.asset_ref,
|
|
1671
|
+
width: r.width,
|
|
1672
|
+
height: r.height,
|
|
1673
|
+
position: r.position,
|
|
1674
|
+
alt: r.alt,
|
|
1675
|
+
angle: r.angle ?? null,
|
|
1676
|
+
excluded: !!r.excluded
|
|
1677
|
+
}));
|
|
1678
|
+
},
|
|
1679
|
+
markMissingUnavailable(sourceId, seenExternalKeys) {
|
|
1680
|
+
if (!seenExternalKeys.length) {
|
|
1681
|
+
const r2 = db.prepare(
|
|
1682
|
+
"UPDATE catalog_products SET status='unavailable', updated_at=datetime('now') WHERE source_id=? AND status='active'"
|
|
1683
|
+
).run(sourceId);
|
|
1684
|
+
return r2.changes;
|
|
1685
|
+
}
|
|
1686
|
+
const placeholders = seenExternalKeys.map(() => "?").join(",");
|
|
1687
|
+
const r = db.prepare(
|
|
1688
|
+
`UPDATE catalog_products SET status='unavailable', updated_at=datetime('now')
|
|
1689
|
+
WHERE source_id=? AND status='active' AND external_key NOT IN (${placeholders})`
|
|
1690
|
+
).run(sourceId, ...seenExternalKeys);
|
|
1691
|
+
return r.changes;
|
|
1692
|
+
}
|
|
1693
|
+
};
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
// ../core/src/catalog/reads.ts
|
|
1697
|
+
function readMethods(db) {
|
|
1698
|
+
return {
|
|
1699
|
+
getProduct(id) {
|
|
1700
|
+
return productById(db, id);
|
|
1701
|
+
},
|
|
1702
|
+
getProductByLibraryId(libraryId) {
|
|
1703
|
+
if (!libraryId.startsWith("cat-")) return null;
|
|
1704
|
+
return productById(db, libraryId.slice(4));
|
|
1705
|
+
},
|
|
1706
|
+
listProducts(brandId) {
|
|
1707
|
+
return productsFor(db, brandId);
|
|
1708
|
+
},
|
|
1709
|
+
listVariants(productId) {
|
|
1710
|
+
return variantsFor(db, productId);
|
|
1711
|
+
},
|
|
1712
|
+
listImages(productId) {
|
|
1713
|
+
return imagesFor(db, productId);
|
|
1714
|
+
}
|
|
1715
|
+
};
|
|
1716
|
+
}
|
|
1717
|
+
function mutationMethods(db) {
|
|
1718
|
+
return {
|
|
1719
|
+
deleteCatalogProduct(id) {
|
|
1720
|
+
db.prepare("DELETE FROM catalog_products WHERE id=?").run(id);
|
|
1721
|
+
},
|
|
1722
|
+
/**
|
|
1723
|
+
* The fields this app invents on top of an imported product. Everything a
|
|
1724
|
+
* store supplies — title, price, vendor, variants — stays the store's and
|
|
1725
|
+
* is refreshed by every import; these four have no counterpart there, so
|
|
1726
|
+
* they are the user's and an import never touches them.
|
|
1727
|
+
*/
|
|
1728
|
+
updateProduct(id, patch) {
|
|
1729
|
+
const cols = ["category", "variant", "material", "dimensions"].filter((k) => k in patch);
|
|
1730
|
+
if (cols.length) {
|
|
1731
|
+
db.prepare(
|
|
1732
|
+
`UPDATE catalog_products SET ${cols.map((c) => `${c}=?`).join(", ")}, updated_at=datetime('now') WHERE id=?`
|
|
1733
|
+
).run(...cols.map((c) => patch[c] ?? null), id);
|
|
1734
|
+
}
|
|
1735
|
+
return productById(db, id);
|
|
1736
|
+
},
|
|
1737
|
+
/**
|
|
1738
|
+
* An angle the user shot themselves, added to an imported product. The
|
|
1739
|
+
* `local:` prefix is what marks it as not-from-the-store, which is how the
|
|
1740
|
+
* import merge knows to carry it across instead of deleting it.
|
|
1741
|
+
*/
|
|
1742
|
+
addLocalImage(productId, assetRef, angle) {
|
|
1743
|
+
const next = (db.prepare("SELECT MAX(position) AS p FROM catalog_images WHERE product_id=?").get(productId)?.p ?? -1) + 1;
|
|
1744
|
+
db.prepare(
|
|
1745
|
+
`INSERT INTO catalog_images (id, product_id, source_url, asset_ref, position, angle)
|
|
1746
|
+
VALUES (?,?,?,?,?,?)`
|
|
1747
|
+
).run(randomUUID(), productId, `local:${assetRef}`, assetRef, next, angle ?? null);
|
|
1748
|
+
},
|
|
1749
|
+
/**
|
|
1750
|
+
* Say which of a product's images make up its reference set, and in what
|
|
1751
|
+
* order. `assetRefs` is the whole set: anything left out stops being used.
|
|
1752
|
+
*
|
|
1753
|
+
* An image the user uploaded here is theirs, so leaving it out deletes it.
|
|
1754
|
+
* A store image is not — the next import would fetch it straight back — so
|
|
1755
|
+
* leaving one out marks it excluded instead. That is the difference between
|
|
1756
|
+
* a delete this can honour and one it cannot, and it is also what lets a
|
|
1757
|
+
* store image be put back: pass it in again.
|
|
1758
|
+
*/
|
|
1759
|
+
setImageOrder(productId, assetRefs) {
|
|
1760
|
+
const rows = imagesFor(db, productId).filter((i) => i.assetRef);
|
|
1761
|
+
const keep = new Set(assetRefs);
|
|
1762
|
+
const ordered = assetRefs.map((ref) => rows.find((r) => r.assetRef === ref)).filter(Boolean);
|
|
1763
|
+
const dropped = rows.filter((r) => !keep.has(r.assetRef));
|
|
1764
|
+
const setAside = dropped.filter((r) => !String(r.sourceUrl).startsWith("local:"));
|
|
1765
|
+
db.transaction(() => {
|
|
1766
|
+
for (const r of dropped) {
|
|
1767
|
+
if (String(r.sourceUrl).startsWith("local:")) db.prepare("DELETE FROM catalog_images WHERE id=?").run(r.id);
|
|
1768
|
+
}
|
|
1769
|
+
for (const r of setAside) db.prepare("UPDATE catalog_images SET excluded=1 WHERE id=?").run(r.id);
|
|
1770
|
+
for (const r of ordered) db.prepare("UPDATE catalog_images SET excluded=0 WHERE id=?").run(r.id);
|
|
1771
|
+
const tail = imagesFor(db, productId).filter((i) => !i.assetRef);
|
|
1772
|
+
[...ordered, ...setAside, ...tail].forEach((r, i) => {
|
|
1773
|
+
db.prepare("UPDATE catalog_images SET position=? WHERE id=?").run(i, r.id);
|
|
1774
|
+
});
|
|
1775
|
+
})();
|
|
1776
|
+
}
|
|
1777
|
+
/** Merge manual kit products + catalog into one library list. */
|
|
1778
|
+
};
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
// ../core/src/catalog/library.ts
|
|
1782
|
+
function libraryMethods(db) {
|
|
1783
|
+
return {
|
|
1784
|
+
listLibraryProducts(brandId, brandJson) {
|
|
1785
|
+
const manual = (brandJson?.products ?? []).map((p) => ({
|
|
1786
|
+
id: p.id,
|
|
1787
|
+
name: p.name,
|
|
1788
|
+
origin: "manual",
|
|
1789
|
+
category: p.category ?? null,
|
|
1790
|
+
variant: p.variant ?? null,
|
|
1791
|
+
material: p.material ?? null,
|
|
1792
|
+
dimensions: p.dimensions ?? null,
|
|
1793
|
+
shots: (p.shots ?? []).map((s) => ({
|
|
1794
|
+
file: s.file,
|
|
1795
|
+
locked: s.locked ?? true,
|
|
1796
|
+
angle: s.angle ?? null,
|
|
1797
|
+
alt: s.alt ?? s.angle ?? null
|
|
1798
|
+
}))
|
|
1799
|
+
}));
|
|
1800
|
+
const imagesBy = imagesForBrand(db, brandId);
|
|
1801
|
+
const variantsBy = variantsForBrand(db, brandId);
|
|
1802
|
+
const catalog = productsFor(db, brandId).map((p) => {
|
|
1803
|
+
const images = imagesBy.get(p.id) ?? [];
|
|
1804
|
+
const shot = (i) => ({
|
|
1805
|
+
file: i.assetRef,
|
|
1806
|
+
locked: true,
|
|
1807
|
+
angle: i.angle,
|
|
1808
|
+
alt: i.alt,
|
|
1809
|
+
local: String(i.sourceUrl).startsWith("local:")
|
|
1810
|
+
});
|
|
1811
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1812
|
+
const usable = images.filter((i) => i.assetRef && !seen.has(i.assetRef) && seen.add(i.assetRef));
|
|
1813
|
+
const shots = usable.filter((i) => !i.excluded).map(shot);
|
|
1814
|
+
const hiddenShots = usable.filter((i) => i.excluded).map(shot);
|
|
1815
|
+
return {
|
|
1816
|
+
id: `cat-${p.id}`,
|
|
1817
|
+
name: p.title,
|
|
1818
|
+
origin: "catalog",
|
|
1819
|
+
url: p.url,
|
|
1820
|
+
descriptionHtml: p.descriptionHtml,
|
|
1821
|
+
vendor: p.vendor,
|
|
1822
|
+
productType: p.productType,
|
|
1823
|
+
tags: p.tags,
|
|
1824
|
+
category: p.category,
|
|
1825
|
+
variant: p.variant,
|
|
1826
|
+
material: p.material,
|
|
1827
|
+
dimensions: p.dimensions,
|
|
1828
|
+
price: p.price,
|
|
1829
|
+
compareAtPrice: p.compareAtPrice,
|
|
1830
|
+
currency: p.currency,
|
|
1831
|
+
available: p.available,
|
|
1832
|
+
status: p.status,
|
|
1833
|
+
shots,
|
|
1834
|
+
hiddenShots,
|
|
1835
|
+
variants: variantsBy.get(p.id) ?? []
|
|
1836
|
+
};
|
|
1837
|
+
});
|
|
1838
|
+
return [...manual, ...catalog];
|
|
1839
|
+
}
|
|
1840
|
+
};
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
// ../core/src/catalogStore.ts
|
|
1844
|
+
function createCatalogStore(db) {
|
|
1845
|
+
return {
|
|
1846
|
+
...sourceMethods(db),
|
|
1847
|
+
...jobMethods(db),
|
|
1848
|
+
...productImportMethods(db),
|
|
1849
|
+
...readMethods(db),
|
|
1850
|
+
...mutationMethods(db),
|
|
1851
|
+
...libraryMethods(db)
|
|
1852
|
+
};
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
// ../core/src/engine.ts
|
|
1856
|
+
var REFERENCE_ROLE_DIRECTIVE = {
|
|
1857
|
+
product: "the exact product \u2014 preserve its label, shape, colors and design faithfully; do not redesign it",
|
|
1858
|
+
character: "the exact person \u2014 match their face, facial structure, skin, hair and build exactly; their clothing, pose and background are capture context, not styling to reproduce",
|
|
1859
|
+
brand: "the brand's own mark \u2014 if the direction calls for the mark to appear, reproduce it exactly as drawn, same colours, letterforms and proportions, every character down to the smallest secondary lettering, in its original script and reading direction, never translated, transliterated or re-spelled; otherwise take only its colour and treatment, and never its subject, geometry or composition",
|
|
1860
|
+
// Only a figure-led scene attaches one of these now, so this says what that
|
|
1861
|
+
// case actually needs. It used to read "environment and light only - take no
|
|
1862
|
+
// subject or person from it", which handed the model a photograph of a face
|
|
1863
|
+
// treatment and told it to ignore the treatment. A hand-attached reference
|
|
1864
|
+
// says "match ... treatment" and works; this now says the same thing, with the
|
|
1865
|
+
// identity carve-out a scene needs and a lone reference does not. The tail
|
|
1866
|
+
// names staged objects as stand-ins rather than just "no product": a bare
|
|
1867
|
+
// prohibition still left the demo object in the frame, because the model had
|
|
1868
|
+
// nowhere to put what the photograph so vividly showed.
|
|
1869
|
+
//
|
|
1870
|
+
// The carve-out leads. It used to sit forty words in, one subordinate
|
|
1871
|
+
// clause after a paragraph of "match this" - and the tester case (a close
|
|
1872
|
+
// portrait as the scene image beside a selected presenter) showed which
|
|
1873
|
+
// half the model heard. Every treatment clause is retained word for word;
|
|
1874
|
+
// only the order and the register of the identity refusal changed.
|
|
1875
|
+
scene: "a reference for this world, never for a person: take no identity from the person in it \u2014 not their face, not their likeness \u2014 they are an anonymous stand-in whose place the attached subject takes. Match the environment, the light, and the material, density, scale, finish and spread of the treatment applied to the figure, including which parts of the form it covers and how far it reaches; treat any product, garment or prop staged in it as the same kind of stand-in, demonstrating placement and scale \u2014 never an object to reproduce",
|
|
1876
|
+
composition: "a reference for framing, camera angle and pose only \u2014 take no subject, color, material or branding from it",
|
|
1877
|
+
style: "a reference for overall treatment and mood only \u2014 take no composition, subject or product detail from it",
|
|
1878
|
+
reference: "a reference to match in composition, lighting and treatment"
|
|
1879
|
+
};
|
|
1880
|
+
var EDIT_REFERENCE_ROLE_DIRECTIVE = {
|
|
1881
|
+
product: "the exact product: keep or restore its label, shape and design faithfully",
|
|
1882
|
+
character: "the exact person: keep their face, facial structure, skin, hair and build faithfully; take no clothing, pose or background from this reference, and keep the source image's existing outfit unless the instruction changes it",
|
|
1883
|
+
brand: "the brand's own mark: reproduce it exactly as drawn wherever it appears \u2014 every character down to the smallest secondary lettering, in its original script and reading direction \u2014 never redrawn, re-lettered, translated or transliterated",
|
|
1884
|
+
scene: "a reference for environment, light and treatment only \u2014 take no identity from any person in it",
|
|
1885
|
+
composition: "a reference for framing and pose only",
|
|
1886
|
+
style: "a reference for treatment and mood only",
|
|
1887
|
+
reference: "a reference for composition, lighting and treatment only"
|
|
1888
|
+
};
|
|
1889
|
+
var BUDGET_EXHAUSTED = "scenri:budget-exhausted";
|
|
1890
|
+
var ASPECT_TOLERANCE = 0.15;
|
|
1891
|
+
var NAMED_RATIOS = [
|
|
1892
|
+
["1:1", 1],
|
|
1893
|
+
["4:5", 4 / 5],
|
|
1894
|
+
["5:4", 5 / 4],
|
|
1895
|
+
["2:3", 2 / 3],
|
|
1896
|
+
["3:2", 3 / 2],
|
|
1897
|
+
["3:4", 3 / 4],
|
|
1898
|
+
["4:3", 4 / 3],
|
|
1899
|
+
["9:16", 9 / 16],
|
|
1900
|
+
["16:9", 16 / 9],
|
|
1901
|
+
["2:1", 2],
|
|
1902
|
+
["1:2", 0.5]
|
|
1903
|
+
];
|
|
1904
|
+
function ratioLabel(width, height) {
|
|
1905
|
+
const ratio = width / height;
|
|
1906
|
+
for (const [label, value] of NAMED_RATIOS) {
|
|
1907
|
+
if (Math.abs(ratio - value) / value < 0.02) return label;
|
|
1908
|
+
}
|
|
1909
|
+
const gcd = (a, b) => b ? gcd(b, a % b) : a;
|
|
1910
|
+
const d = gcd(width, height) || 1;
|
|
1911
|
+
return `${Math.round(width / d)}:${Math.round(height / d)}`;
|
|
1912
|
+
}
|
|
1913
|
+
function budgetSize(width, height, pixelBudget) {
|
|
1914
|
+
const ratio = width / height;
|
|
1915
|
+
if (!(ratio > 0) || !Number.isFinite(ratio) || !(pixelBudget > 0)) return { width, height };
|
|
1916
|
+
return {
|
|
1917
|
+
width: Math.round(Math.sqrt(pixelBudget * ratio)),
|
|
1918
|
+
height: Math.round(Math.sqrt(pixelBudget / ratio))
|
|
1919
|
+
};
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
// ../core/src/index.ts
|
|
1923
|
+
function defaultHome() {
|
|
1924
|
+
return process.env.SCENRI_HOME || join(homedir(), ".scenri");
|
|
1925
|
+
}
|
|
1926
|
+
function createCore(homeDir = defaultHome()) {
|
|
1927
|
+
const db = openDb(homeDir);
|
|
1928
|
+
return {
|
|
1929
|
+
home: homeDir,
|
|
1930
|
+
store: createStore(db),
|
|
1931
|
+
catalog: createCatalogStore(db),
|
|
1932
|
+
images: createImageStore(homeDir),
|
|
1933
|
+
ledger: createLedger(db),
|
|
1934
|
+
close: () => db.close()
|
|
1935
|
+
};
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
export { ASPECT_TOLERANCE, BUDGET_EXHAUSTED, EDIT_REFERENCE_ROLE_DIRECTIVE, REFERENCE_ROLE_DIRECTIVE, SCHEMA_VERSION, STEM_MIN, SchemaTooNewError, SpendCapError, TRIGRAM_MIN, budgetSize, createCatalogStore, createCore, createStore, defaultHome, fold, ftsMatch, matchesQuery, ratioLabel, searchTerms, termMatches, uniqueProjectSlug, uniqueSetSlug, uniqueSlug };
|
|
1939
|
+
//# sourceMappingURL=chunk-OJAG3FRX.js.map
|
|
1940
|
+
//# sourceMappingURL=chunk-OJAG3FRX.js.map
|