ima2-gen 1.1.4 → 1.1.5

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.
@@ -11,6 +11,7 @@ import { startJob, finishJob } from "../lib/inflight.js";
11
11
  import { getStyleSheet } from "../lib/sessionStore.js";
12
12
  import { renderStyleSheetPrefix } from "../lib/styleSheet.js";
13
13
  import { logEvent, logError } from "../lib/logger.js";
14
+ import { embedImageMetadataBestEffort } from "../lib/imageMetadataStore.js";
14
15
 
15
16
  function validateModeration(ctx, moderation) {
16
17
  if (typeof moderation !== "string" || !ctx.config.oauth.validModeration.has(moderation)) {
@@ -104,6 +105,8 @@ export function registerGenerateRoutes(app, ctx) {
104
105
  return res.status(403).json({ error: "API key provider is disabled. Use OAuth (Codex login).", code: "APIKEY_DISABLED" });
105
106
  }
106
107
  const client = req.get("x-ima2-client") || "ui";
108
+ const referenceDiagnostics = refCheck.referenceDiagnostics || [];
109
+ const referenceMismatchCount = referenceDiagnostics.filter((ref) => ref.warnings?.includes("mime_mismatch")).length;
107
110
  logEvent("generate", "request", {
108
111
  requestId,
109
112
  client,
@@ -114,6 +117,9 @@ export function registerGenerateRoutes(app, ctx) {
114
117
  moderation,
115
118
  n: count,
116
119
  refs: refCheck.refs.length,
120
+ referenceMismatchCount,
121
+ refDetectedMimes: [...new Set(referenceDiagnostics.map((ref) => ref.detectedMime).filter(Boolean))].join(","),
122
+ refDeclaredMimes: [...new Set(referenceDiagnostics.map((ref) => ref.declaredMime).filter(Boolean))].join(","),
117
123
  sessionId,
118
124
  clientNodeId,
119
125
  promptChars: typeof prompt === "string" ? prompt.length : 0,
@@ -136,7 +142,7 @@ export function registerGenerateRoutes(app, ctx) {
136
142
  quality,
137
143
  size,
138
144
  moderation,
139
- refCheck.refs,
145
+ refCheck.refDetails || refCheck.refs,
140
146
  requestId,
141
147
  normalizedPromptMode,
142
148
  ctx,
@@ -165,8 +171,11 @@ export function registerGenerateRoutes(app, ctx) {
165
171
  if (r.status === "fulfilled" && r.value.b64) {
166
172
  const rand = randomBytes(ctx.config.ids.generatedHexBytes).toString("hex");
167
173
  const filename = `${Date.now()}_${rand}_${images.length}.${format}`;
168
- await writeFile(join(ctx.config.storage.generatedDir, filename), Buffer.from(r.value.b64, "base64"));
169
174
  const meta = {
175
+ kind: "classic",
176
+ requestId,
177
+ sessionId,
178
+ clientNodeId,
170
179
  prompt,
171
180
  userPrompt: prompt,
172
181
  revisedPrompt: r.value.revisedPrompt || null,
@@ -182,7 +191,21 @@ export function registerGenerateRoutes(app, ctx) {
182
191
  createdAt: Date.now(),
183
192
  usage: r.value.usage || null,
184
193
  webSearchCalls: r.value.webSearchCalls || 0,
194
+ refsCount: refCheck.refs.length,
185
195
  };
196
+ const rawBuffer = Buffer.from(r.value.b64, "base64");
197
+ const embedded = await embedImageMetadataBestEffort(rawBuffer, format, meta, {
198
+ version: ctx.packageVersion,
199
+ });
200
+ if (!embedded.embedded) {
201
+ logEvent("generate", "metadata_embed_skipped", {
202
+ requestId,
203
+ filename,
204
+ code: embedded.code,
205
+ warning: embedded.warning,
206
+ });
207
+ }
208
+ await writeFile(join(ctx.config.storage.generatedDir, filename), embedded.buffer);
186
209
  await writeFile(join(ctx.config.storage.generatedDir, filename + ".json"), JSON.stringify(meta)).catch(() => {});
187
210
  images.push({
188
211
  image: `data:${mime};base64,${r.value.b64}`,
@@ -214,6 +237,10 @@ export function registerGenerateRoutes(app, ctx) {
214
237
  upstreamCode: firstErr.upstreamCode || null,
215
238
  upstreamType: firstErr.upstreamType || null,
216
239
  upstreamParam: firstErr.upstreamParam || null,
240
+ diagnosticReason: firstErr.diagnosticReason || null,
241
+ retryKind: firstErr.retryKind || null,
242
+ referencesDroppedOnRetry: firstErr.referencesDroppedOnRetry ?? null,
243
+ errorEventCount: firstErr.eventCount ?? null,
217
244
  requestId,
218
245
  });
219
246
  }
@@ -270,6 +297,10 @@ export function registerGenerateRoutes(app, ctx) {
270
297
  upstreamCode: err.upstreamCode || null,
271
298
  upstreamType: err.upstreamType || null,
272
299
  upstreamParam: err.upstreamParam || null,
300
+ diagnosticReason: err.diagnosticReason || null,
301
+ retryKind: err.retryKind || null,
302
+ referencesDroppedOnRetry: err.referencesDroppedOnRetry ?? null,
303
+ errorEventCount: err.eventCount ?? null,
273
304
  requestId,
274
305
  });
275
306
  } finally {
package/routes/history.js CHANGED
@@ -2,6 +2,7 @@ import { listHistoryRows } from "../lib/historyList.js";
2
2
  import { trashAsset, restoreAsset } from "../lib/assetLifecycle.js";
3
3
  import { getSessionTitleMap } from "../lib/sessionStore.js";
4
4
  import { logError, logEvent } from "../lib/logger.js";
5
+ import { getDb } from "../lib/db.js";
5
6
 
6
7
  export function registerHistoryRoutes(app, ctx) {
7
8
  app.get("/api/history", async (req, res) => {
@@ -16,9 +17,18 @@ export function registerHistoryRoutes(app, ctx) {
16
17
  const sinceTs = parseInt(req.query.since);
17
18
  const sessionId = typeof req.query.sessionId === "string" ? req.query.sessionId : null;
18
19
  const groupBy = req.query.groupBy === "session" ? "session" : null;
20
+ const browserId = req.headers["x-ima2-browser-id"] || null;
19
21
 
20
22
  const rows = await listHistoryRows(ctx.config.storage.generatedDir);
21
23
 
24
+ // Enrich with favorite status
25
+ let favoriteSet = new Set();
26
+ if (browserId) {
27
+ const db = getDb();
28
+ const favRows = db.prepare("SELECT filename FROM gallery_favorites WHERE browser_id = ?").all(browserId);
29
+ favoriteSet = new Set(favRows.map((r) => r.filename));
30
+ }
31
+
22
32
  let filtered = rows;
23
33
  if (Number.isFinite(sinceTs)) {
24
34
  filtered = filtered.filter((r) => r.createdAt > sinceTs);
@@ -34,7 +44,7 @@ export function registerHistoryRoutes(app, ctx) {
34
44
  filtered = filtered.filter((r) => r.sessionId === sessionId);
35
45
  }
36
46
 
37
- const page = filtered.slice(0, limit);
47
+ const page = filtered.slice(0, limit).map((r) => ({ ...r, isFavorite: favoriteSet.has(r.filename) }));
38
48
  const nextCursor = page.length === limit && filtered.length > limit
39
49
  ? { before: page[page.length - 1].createdAt, beforeFilename: page[page.length - 1].filename }
40
50
  : null;
@@ -99,4 +109,35 @@ export function registerHistoryRoutes(app, ctx) {
99
109
  res.status(err.status || 500).json({ error: err.message });
100
110
  }
101
111
  });
112
+
113
+ app.post("/api/history/favorite", async (req, res) => {
114
+ try {
115
+ const db = getDb();
116
+ const { filename } = req.body || {};
117
+ const browserId = req.headers["x-ima2-browser-id"];
118
+
119
+ if (!filename || typeof filename !== "string") {
120
+ return res.status(400).json({ error: "filename is required" });
121
+ }
122
+ if (!browserId || typeof browserId !== "string") {
123
+ return res.status(400).json({ error: "X-Ima2-Browser-Id header is required" });
124
+ }
125
+
126
+ const existing = db.prepare("SELECT id FROM gallery_favorites WHERE browser_id = ? AND filename = ?").get(browserId, filename);
127
+
128
+ if (existing) {
129
+ db.prepare("DELETE FROM gallery_favorites WHERE browser_id = ? AND filename = ?").run(browserId, filename);
130
+ res.json({ isFavorite: false });
131
+ } else {
132
+ const id = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
133
+ db.prepare(
134
+ "INSERT INTO gallery_favorites (id, browser_id, filename, favorited_at) VALUES (?, ?, ?, ?)"
135
+ ).run(id, browserId, filename, Math.floor(Date.now() / 1000));
136
+ res.json({ isFavorite: true });
137
+ }
138
+ } catch (err) {
139
+ logError("history", "favorite_error", err);
140
+ res.status(500).json({ error: err.message });
141
+ }
142
+ });
102
143
  }
package/routes/index.js CHANGED
@@ -6,14 +6,18 @@ import { registerNodeRoutes } from "./nodes.js";
6
6
  import { registerGenerateRoutes } from "./generate.js";
7
7
  import { registerStorageRoutes } from "./storage.js";
8
8
  import { registerCardNewsRoutes } from "./cardNews.js";
9
+ import { registerMetadataRoutes } from "./metadata.js";
10
+ import { registerPromptRoutes } from "./prompts.js";
9
11
 
10
12
  export function configureRoutes(app, ctx) {
11
13
  registerHealthRoutes(app, ctx);
12
14
  registerStorageRoutes(app, ctx);
15
+ registerMetadataRoutes(app, ctx);
13
16
  registerHistoryRoutes(app, ctx);
14
17
  registerSessionRoutes(app, ctx);
15
18
  registerEditRoutes(app, ctx);
16
19
  registerNodeRoutes(app, ctx);
17
20
  if (ctx.config.features.cardNews) registerCardNewsRoutes(app, ctx);
18
21
  registerGenerateRoutes(app, ctx);
22
+ registerPromptRoutes(app, ctx);
19
23
  }
@@ -0,0 +1,71 @@
1
+ import { Buffer } from "node:buffer";
2
+ import {
3
+ isSupportedMetadataFormat,
4
+ normalizeImageMetadataFormat,
5
+ readEmbeddedImageMetadata,
6
+ } from "../lib/imageMetadataStore.js";
7
+
8
+ const MIME_FORMATS = {
9
+ "image/png": "png",
10
+ "image/jpeg": "jpeg",
11
+ "image/webp": "webp",
12
+ };
13
+
14
+ function parseDataUrl(dataUrl) {
15
+ if (typeof dataUrl !== "string") return null;
16
+ const match = /^data:([^;,]+);base64,(.+)$/s.exec(dataUrl);
17
+ if (!match) return null;
18
+ return { mime: match[1].toLowerCase(), rawB64: match[2] };
19
+ }
20
+
21
+ export function registerMetadataRoutes(app, ctx) {
22
+ app.post("/api/metadata/read", async (req, res) => {
23
+ try {
24
+ const parsed = parseDataUrl(req.body?.dataUrl);
25
+ if (!parsed) {
26
+ return res.status(400).json({
27
+ ok: false,
28
+ code: "IMAGE_METADATA_INVALID",
29
+ error: "A base64 image data URL is required.",
30
+ });
31
+ }
32
+ if (parsed.rawB64.length > ctx.config.limits.maxMetadataReadB64Bytes) {
33
+ return res.status(413).json({
34
+ ok: false,
35
+ code: "IMAGE_METADATA_TOO_LARGE",
36
+ error: "Image is too large to inspect for metadata.",
37
+ });
38
+ }
39
+ const format = normalizeImageMetadataFormat(MIME_FORMATS[parsed.mime]);
40
+ if (!isSupportedMetadataFormat(format)) {
41
+ return res.status(400).json({
42
+ ok: false,
43
+ code: "IMAGE_METADATA_UNSUPPORTED_FORMAT",
44
+ error: "Only PNG, JPEG, and WebP metadata can be inspected.",
45
+ });
46
+ }
47
+ const result = await readEmbeddedImageMetadata(Buffer.from(parsed.rawB64, "base64"));
48
+ if (!result.metadata) {
49
+ return res.json({
50
+ ok: true,
51
+ metadata: null,
52
+ source: null,
53
+ code: "IMAGE_METADATA_NOT_FOUND",
54
+ warnings: result.warnings,
55
+ });
56
+ }
57
+ return res.json({
58
+ ok: true,
59
+ metadata: result.metadata,
60
+ source: result.source,
61
+ warnings: result.warnings,
62
+ });
63
+ } catch (error) {
64
+ return res.status(400).json({
65
+ ok: false,
66
+ code: error?.code || "IMAGE_METADATA_INVALID",
67
+ error: error?.message || "Could not read image metadata.",
68
+ });
69
+ }
70
+ });
71
+ }
package/routes/nodes.js CHANGED
@@ -181,7 +181,10 @@ export function registerNodeRoutes(app, ctx) {
181
181
  parentB64 = await loadAssetB64(ctx.rootDir, externalSrc, ctx.config.storage.generatedDir);
182
182
  }
183
183
  const operation = parentB64 ? "edit" : "generate";
184
- const refsForRequest = contextMode === "parent-only" ? [] : refCheck.refs;
184
+ const referenceDiagnostics = refCheck.referenceDiagnostics || [];
185
+ const generateReferenceDiagnostics = operation === "generate" ? referenceDiagnostics : [];
186
+ const referenceMismatchCount = generateReferenceDiagnostics.filter((ref) => ref.warnings?.includes("mime_mismatch")).length;
187
+ const refsForRequest = contextMode === "parent-only" ? [] : (refCheck.refDetails || refCheck.refs);
185
188
  const webSearchEnabled = !parentB64 || searchMode === "on";
186
189
  const parentImagePresent = !!parentB64;
187
190
  const inputImageCount = (parentImagePresent ? 1 : 0) + refsForRequest.length;
@@ -196,6 +199,9 @@ export function registerNodeRoutes(app, ctx) {
196
199
  size,
197
200
  moderation,
198
201
  refs: refsForRequest.length,
202
+ referenceMismatchCount,
203
+ refDetectedMimes: [...new Set(generateReferenceDiagnostics.map((ref) => ref.detectedMime).filter(Boolean))].join(","),
204
+ refDeclaredMimes: [...new Set(generateReferenceDiagnostics.map((ref) => ref.declaredMime).filter(Boolean))].join(","),
199
205
  inputImageCount,
200
206
  parentImagePresent,
201
207
  contextMode,
@@ -302,6 +308,9 @@ export function registerNodeRoutes(app, ctx) {
302
308
  upstreamCode: lastErr?.upstreamCode || lastErr?.code,
303
309
  errorEventType: lastErr?.eventType,
304
310
  errorEventCount: lastErr?.eventCount,
311
+ diagnosticReason: lastErr?.diagnosticReason,
312
+ retryKind: lastErr?.retryKind,
313
+ referencesDroppedOnRetry: lastErr?.referencesDroppedOnRetry,
305
314
  attempts: MAX_RETRIES + 1,
306
315
  outerHttpAlreadyCommitted: res.headersSent,
307
316
  sseErrorSent: streamResponse,
@@ -318,6 +327,11 @@ export function registerNodeRoutes(app, ctx) {
318
327
  upstreamParam: lastErr?.upstreamParam || null,
319
328
  errorEventType: lastErr?.eventType || null,
320
329
  errorEventCount: lastErr?.eventCount ?? null,
330
+ diagnosticReason: finalErr.diagnosticReason || lastErr?.diagnosticReason || null,
331
+ retryKind: finalErr.retryKind || lastErr?.retryKind || null,
332
+ referencesDroppedOnRetry: finalErr.referencesDroppedOnRetry ?? lastErr?.referencesDroppedOnRetry ?? null,
333
+ refsCount: finalErr.refsCount ?? lastErr?.refsCount ?? null,
334
+ inputImageCount: finalErr.inputImageCount ?? lastErr?.inputImageCount ?? null,
321
335
  },
322
336
  );
323
337
  }
@@ -0,0 +1,379 @@
1
+ import { logError, logEvent } from "../lib/logger.js";
2
+ import { getDb } from "../lib/db.js";
3
+
4
+ function getPromptsDb() {
5
+ return getDb();
6
+ }
7
+
8
+ function generateId() {
9
+ return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
10
+ }
11
+
12
+ export function registerPromptRoutes(app, ctx) {
13
+ // ── Prompts ───────────────────────────────────────────────────────────────
14
+
15
+ app.get("/api/prompts", async (req, res) => {
16
+ try {
17
+ const db = getPromptsDb();
18
+ const search = typeof req.query.search === "string" ? req.query.search.trim() : "";
19
+ const folderId = typeof req.query.folderId === "string" ? req.query.folderId : null;
20
+ const favoritesOnly = req.query.favoritesOnly === "1" || req.query.favoritesOnly === "true";
21
+
22
+ let where = "WHERE 1=1";
23
+ const params = [];
24
+
25
+ if (folderId) {
26
+ where += " AND p.folder_id = ?";
27
+ params.push(folderId);
28
+ } else {
29
+ where += " AND p.folder_id != '__trash__'";
30
+ }
31
+
32
+ if (favoritesOnly) {
33
+ where += " AND p.is_favorite = 1";
34
+ }
35
+
36
+ if (search) {
37
+ where += " AND (p.name LIKE ? OR p.text LIKE ? OR p.tags LIKE ?)";
38
+ const like = `%${search}%`;
39
+ params.push(like, like, like);
40
+ }
41
+
42
+ const prompts = db
43
+ .prepare(
44
+ `SELECT p.*, f.name as folder_name
45
+ FROM prompts p
46
+ LEFT JOIN prompt_folders f ON p.folder_id = f.id
47
+ ${where}
48
+ ORDER BY p.updated_at DESC`
49
+ )
50
+ .all(...params);
51
+
52
+ const folders = db
53
+ .prepare("SELECT * FROM prompt_folders WHERE id NOT IN ('__root__', '__trash__') ORDER BY name COLLATE NOCASE")
54
+ .all();
55
+
56
+ res.json({ prompts: prompts.map(normalizePrompt), folders: folders.map(normalizeFolder) });
57
+ } catch (err) {
58
+ logError("prompts", "list_error", err);
59
+ res.status(500).json({ error: err.message });
60
+ }
61
+ });
62
+
63
+ app.post("/api/prompts", async (req, res) => {
64
+ try {
65
+ const db = getPromptsDb();
66
+ const { name, text, tags, folderId, mode } = req.body || {};
67
+
68
+ if (!text || typeof text !== "string") {
69
+ return res.status(400).json({ error: "text is required" });
70
+ }
71
+
72
+ const promptName = typeof name === "string" && name.trim() ? name.trim() : text.slice(0, 30);
73
+ const folder_id = typeof folderId === "string" && folderId ? folderId : "__root__";
74
+ const tagsJson = Array.isArray(tags) ? JSON.stringify(tags) : null;
75
+ const id = generateId();
76
+ const now = Math.floor(Date.now() / 1000);
77
+
78
+ db.prepare(
79
+ `INSERT INTO prompts (id, folder_id, name, text, tags, mode, created_at, updated_at)
80
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
81
+ ).run(id, folder_id, promptName, text, tagsJson, mode || null, now, now);
82
+
83
+ logEvent("prompts", "created", { id, folder_id });
84
+ res.status(201).json({ prompt: normalizePrompt(db.prepare("SELECT * FROM prompts WHERE id = ?").get(id)) });
85
+ } catch (err) {
86
+ logError("prompts", "create_error", err);
87
+ res.status(500).json({ error: err.message });
88
+ }
89
+ });
90
+
91
+ app.get("/api/prompts/:id", async (req, res) => {
92
+ try {
93
+ const db = getPromptsDb();
94
+ const row = db.prepare("SELECT * FROM prompts WHERE id = ?").get(req.params.id);
95
+ if (!row) return res.status(404).json({ error: "Not found" });
96
+ res.json({ prompt: normalizePrompt(row) });
97
+ } catch (err) {
98
+ logError("prompts", "get_error", err);
99
+ res.status(500).json({ error: err.message });
100
+ }
101
+ });
102
+
103
+ app.patch("/api/prompts/:id", async (req, res) => {
104
+ try {
105
+ const db = getPromptsDb();
106
+ const { name, text, tags, folderId, mode } = req.body || {};
107
+ const sets = [];
108
+ const params = [];
109
+
110
+ if (typeof name === "string") { sets.push("name = ?"); params.push(name); }
111
+ if (typeof text === "string") { sets.push("text = ?"); params.push(text); }
112
+ if (Array.isArray(tags)) { sets.push("tags = ?"); params.push(JSON.stringify(tags)); }
113
+ if (typeof folderId === "string") { sets.push("folder_id = ?"); params.push(folderId); }
114
+ if (typeof mode === "string") { sets.push("mode = ?"); params.push(mode); }
115
+
116
+ if (sets.length === 0) return res.status(400).json({ error: "No fields to update" });
117
+
118
+ sets.push("updated_at = ?");
119
+ params.push(Math.floor(Date.now() / 1000));
120
+ params.push(req.params.id);
121
+
122
+ db.prepare(`UPDATE prompts SET ${sets.join(", ")} WHERE id = ?`).run(...params);
123
+
124
+ const row = db.prepare("SELECT * FROM prompts WHERE id = ?").get(req.params.id);
125
+ res.json({ prompt: normalizePrompt(row) });
126
+ } catch (err) {
127
+ logError("prompts", "patch_error", err);
128
+ res.status(500).json({ error: err.message });
129
+ }
130
+ });
131
+
132
+ app.delete("/api/prompts/:id", async (req, res) => {
133
+ try {
134
+ const db = getPromptsDb();
135
+ db.prepare("UPDATE prompts SET folder_id = '__trash__', updated_at = ? WHERE id = ?").run(
136
+ Math.floor(Date.now() / 1000),
137
+ req.params.id,
138
+ );
139
+ logEvent("prompts", "soft_deleted", { id: req.params.id });
140
+ res.json({ ok: true });
141
+ } catch (err) {
142
+ logError("prompts", "delete_error", err);
143
+ res.status(500).json({ error: err.message });
144
+ }
145
+ });
146
+
147
+ app.post("/api/prompts/:id/favorite", async (req, res) => {
148
+ try {
149
+ const db = getPromptsDb();
150
+ const row = db.prepare("SELECT is_favorite FROM prompts WHERE id = ?").get(req.params.id);
151
+ if (!row) return res.status(404).json({ error: "Not found" });
152
+
153
+ const newVal = row.is_favorite ? 0 : 1;
154
+ const now = Math.floor(Date.now() / 1000);
155
+ db.prepare("UPDATE prompts SET is_favorite = ?, favorited_at = ? WHERE id = ?").run(
156
+ newVal,
157
+ newVal ? now : null,
158
+ req.params.id,
159
+ );
160
+
161
+ res.json({ isFavorite: !!newVal, favoritedAt: newVal ? now : null });
162
+ } catch (err) {
163
+ logError("prompts", "favorite_error", err);
164
+ res.status(500).json({ error: err.message });
165
+ }
166
+ });
167
+
168
+ // ── Import / Export ───────────────────────────────────────────────────────
169
+
170
+ app.post("/api/prompts/import", async (req, res) => {
171
+ try {
172
+ const db = getPromptsDb();
173
+ const { folders: importFolders = [], prompts: importPrompts = [] } = req.body || {};
174
+
175
+ const result = { foldersCreated: 0, promptsImported: 0, duplicatesSkipped: 0 };
176
+ const now = Math.floor(Date.now() / 1000);
177
+
178
+ // Build name→id map for existing folders
179
+ const existingFolders = db.prepare("SELECT * FROM prompt_folders").all();
180
+ const folderMap = new Map(existingFolders.map((f) => [f.id, f]));
181
+ const namePathMap = new Map();
182
+ for (const f of existingFolders) {
183
+ const parent = folderMap.get(f.parent_id);
184
+ const path = parent && parent.id !== "__root__" ? `${parent.name}/${f.name}` : f.name;
185
+ namePathMap.set(path.toLowerCase(), f.id);
186
+ }
187
+
188
+ // Import folders
189
+ for (const f of importFolders) {
190
+ if (!f.name) continue;
191
+ const path = f.parentId && f.parentId !== "__root__"
192
+ ? `${folderMap.get(f.parentId)?.name || ""}/${f.name}`
193
+ : f.name;
194
+ if (namePathMap.has(path.toLowerCase())) continue;
195
+
196
+ const id = f.id || generateId();
197
+ db.prepare(
198
+ "INSERT INTO prompt_folders (id, parent_id, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)"
199
+ ).run(id, f.parentId || "__root__", f.name, now, now);
200
+ namePathMap.set(path.toLowerCase(), id);
201
+ folderMap.set(id, { id, parent_id: f.parentId || "__root__", name: f.name });
202
+ result.foldersCreated++;
203
+ }
204
+
205
+ // Import prompts
206
+ for (const p of importPrompts) {
207
+ if (!p.text) continue;
208
+ const folderId = p.folderId && folderMap.has(p.folderId) ? p.folderId : "__root__";
209
+ // Check duplicate by text + folder
210
+ const dup = db.prepare("SELECT 1 FROM prompts WHERE text = ? AND folder_id = ? LIMIT 1").get(p.text, folderId);
211
+ if (dup) {
212
+ result.duplicatesSkipped++;
213
+ continue;
214
+ }
215
+ const id = p.id || generateId();
216
+ const tagsJson = Array.isArray(p.tags) ? JSON.stringify(p.tags) : null;
217
+ db.prepare(
218
+ `INSERT INTO prompts (id, folder_id, name, text, tags, mode, is_favorite, created_at, updated_at)
219
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
220
+ ).run(id, folderId, p.name || p.text.slice(0, 30), p.text, tagsJson, p.mode || null, p.isFavorite ? 1 : 0, now, now);
221
+ result.promptsImported++;
222
+ }
223
+
224
+ logEvent("prompts", "imported", result);
225
+ res.json(result);
226
+ } catch (err) {
227
+ logError("prompts", "import_error", err);
228
+ res.status(500).json({ error: err.message });
229
+ }
230
+ });
231
+
232
+ app.get("/api/prompts/export", async (req, res) => {
233
+ try {
234
+ const db = getPromptsDb();
235
+ const prompts = db.prepare("SELECT * FROM prompts WHERE folder_id != '__trash__'").all();
236
+ const folders = db.prepare("SELECT * FROM prompt_folders WHERE id NOT IN ('__root__', '__trash__')").all();
237
+
238
+ res.json({
239
+ version: 1,
240
+ exportedAt: new Date().toISOString(),
241
+ folders: folders.map((f) => ({ id: f.id, name: f.name, parentId: f.parent_id })),
242
+ prompts: prompts.map((p) => ({
243
+ id: p.id,
244
+ name: p.name,
245
+ text: p.text,
246
+ tags: p.tags ? JSON.parse(p.tags) : [],
247
+ folderId: p.folder_id,
248
+ mode: p.mode,
249
+ isFavorite: !!p.is_favorite,
250
+ })),
251
+ });
252
+ } catch (err) {
253
+ logError("prompts", "export_error", err);
254
+ res.status(500).json({ error: err.message });
255
+ }
256
+ });
257
+
258
+ // ── Folders ───────────────────────────────────────────────────────────────
259
+
260
+ app.get("/api/prompts/folders", async (req, res) => {
261
+ try {
262
+ const db = getPromptsDb();
263
+ const rows = db.prepare("SELECT * FROM prompt_folders WHERE id NOT IN ('__root__', '__trash__') ORDER BY name COLLATE NOCASE").all();
264
+ res.json({ folders: rows.map(normalizeFolder) });
265
+ } catch (err) {
266
+ logError("prompts", "folders_list_error", err);
267
+ res.status(500).json({ error: err.message });
268
+ }
269
+ });
270
+
271
+ app.post("/api/prompts/folders", async (req, res) => {
272
+ try {
273
+ const db = getPromptsDb();
274
+ const { name, parentId } = req.body || {};
275
+ if (!name || typeof name !== "string" || !name.trim()) {
276
+ return res.status(400).json({ error: "name is required" });
277
+ }
278
+
279
+ const parent_id = typeof parentId === "string" && parentId ? parentId : "__root__";
280
+ const now = Math.floor(Date.now() / 1000);
281
+ const id = generateId();
282
+
283
+ try {
284
+ db.prepare(
285
+ "INSERT INTO prompt_folders (id, parent_id, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)"
286
+ ).run(id, parent_id, name.trim(), now, now);
287
+ } catch (err) {
288
+ if (err.message && err.message.includes("UNIQUE constraint failed")) {
289
+ return res.status(409).json({ error: "Folder name already exists in this parent" });
290
+ }
291
+ throw err;
292
+ }
293
+
294
+ res.status(201).json({ folder: normalizeFolder(db.prepare("SELECT * FROM prompt_folders WHERE id = ?").get(id)) });
295
+ } catch (err) {
296
+ logError("prompts", "folder_create_error", err);
297
+ res.status(500).json({ error: err.message });
298
+ }
299
+ });
300
+
301
+ app.patch("/api/prompts/folders/:id", async (req, res) => {
302
+ try {
303
+ const db = getPromptsDb();
304
+ const { name, parentId } = req.body || {};
305
+ const sets = [];
306
+ const params = [];
307
+
308
+ if (typeof name === "string" && name.trim()) { sets.push("name = ?"); params.push(name.trim()); }
309
+ if (typeof parentId === "string") { sets.push("parent_id = ?"); params.push(parentId); }
310
+ if (sets.length === 0) return res.status(400).json({ error: "No fields to update" });
311
+
312
+ sets.push("updated_at = ?");
313
+ params.push(Math.floor(Date.now() / 1000));
314
+ params.push(req.params.id);
315
+
316
+ try {
317
+ db.prepare(`UPDATE prompt_folders SET ${sets.join(", ")} WHERE id = ?`).run(...params);
318
+ } catch (err) {
319
+ if (err.message && err.message.includes("UNIQUE constraint failed")) {
320
+ return res.status(409).json({ error: "Folder name already exists in this parent" });
321
+ }
322
+ throw err;
323
+ }
324
+
325
+ const row = db.prepare("SELECT * FROM prompt_folders WHERE id = ?").get(req.params.id);
326
+ res.json({ folder: normalizeFolder(row) });
327
+ } catch (err) {
328
+ logError("prompts", "folder_patch_error", err);
329
+ res.status(500).json({ error: err.message });
330
+ }
331
+ });
332
+
333
+ app.delete("/api/prompts/folders/:id", async (req, res) => {
334
+ try {
335
+ const db = getPromptsDb();
336
+ const strategy = req.query.strategy === "deleteItems" ? "deleteItems" : "moveToRoot";
337
+
338
+ if (strategy === "moveToRoot") {
339
+ db.prepare("UPDATE prompts SET folder_id = '__root__' WHERE folder_id = ?").run(req.params.id);
340
+ } else {
341
+ db.prepare("UPDATE prompts SET folder_id = '__trash__' WHERE folder_id = ?").run(req.params.id);
342
+ }
343
+
344
+ db.prepare("DELETE FROM prompt_folders WHERE id = ?").run(req.params.id);
345
+ logEvent("prompts", "folder_deleted", { id: req.params.id, strategy });
346
+ res.json({ ok: true });
347
+ } catch (err) {
348
+ logError("prompts", "folder_delete_error", err);
349
+ res.status(500).json({ error: err.message });
350
+ }
351
+ });
352
+ }
353
+
354
+ // ── Helpers ─────────────────────────────────────────────────────────────────
355
+
356
+ function normalizePrompt(row) {
357
+ return {
358
+ id: row.id,
359
+ folderId: row.folder_id,
360
+ name: row.name,
361
+ text: row.text,
362
+ tags: row.tags ? JSON.parse(row.tags) : [],
363
+ mode: row.mode,
364
+ isFavorite: !!row.is_favorite,
365
+ favoritedAt: row.favorited_at || null,
366
+ createdAt: row.created_at,
367
+ updatedAt: row.updated_at,
368
+ };
369
+ }
370
+
371
+ function normalizeFolder(row) {
372
+ return {
373
+ id: row.id,
374
+ parentId: row.parent_id,
375
+ name: row.name,
376
+ createdAt: row.created_at,
377
+ updatedAt: row.updated_at,
378
+ };
379
+ }