@dickpy/dsh-imagegen 1.2.3 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -201
- package/README.md +203 -181
- package/cordis.patch.yml +8 -8
- package/docs/images/multi-model-comparison.png +0 -0
- package/lib/client.js +2711 -1318
- package/lib/client.js.map +1 -1
- package/lib/index.js +830 -155
- package/package.json +70 -68
- package/src/agent-image-tools.ts +418 -316
- package/src/client/ImageGenPanel.tsx +1703 -1476
- package/src/client/SettingsCard.tsx +936 -648
- package/src/client/TemplateLibrary.tsx +336 -336
- package/src/client/api.ts +193 -193
- package/src/client/channels-form.ts +263 -0
- package/src/client/controller.ts +46 -46
- package/src/client/conversation-sync.ts +14 -0
- package/src/client/css-modules.d.ts +5 -5
- package/src/client/helpers.ts +33 -33
- package/src/client/image-toolview.module.css +73 -73
- package/src/client/image-toolview.tsx +170 -152
- package/src/client/index.ts +32 -22
- package/src/client/locales.ts +610 -484
- package/src/client/mount.tsx +185 -96
- package/src/client/panel.module.css +1713 -1445
- package/src/client/settings-card.module.css +1023 -536
- package/src/client/settings-form.ts +336 -336
- package/src/client/settings-scope.ts +298 -250
- package/src/client/sidebar-entry.ts +148 -102
- package/src/client/templates.module.css +453 -453
- package/src/engine.ts +520 -464
- package/src/gallery-store.ts +286 -280
- package/src/generation-runtime.ts +79 -48
- package/src/history-store.ts +250 -238
- package/src/image-format.ts +11 -0
- package/src/image-models.ts +19 -19
- package/src/index.ts +318 -212
- package/src/model-catalog.ts +115 -0
- package/src/presets.ts +71 -0
- package/src/prompt-enhancer.ts +137 -79
- package/src/protocol.ts +338 -253
- package/src/routes.ts +916 -738
- package/src/task-queue.ts +113 -103
- package/src/templates/cases.json +10196 -10196
- package/src/templates-store.ts +278 -278
- package/src/updater.ts +117 -117
package/src/gallery-store.ts
CHANGED
|
@@ -1,280 +1,286 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Host-persisted gallery (user-curated favorites): mirrors the history store
|
|
3
|
-
* (image files + an index.json under ~/.dsh/dsh-imagegen/gallery/) but with no
|
|
4
|
-
* size cap — every entry is an explicit user choice. Appends are deduplicated
|
|
5
|
-
* by image content so adding the same generated image twice is a no-op.
|
|
6
|
-
*
|
|
7
|
-
* Framework-free (node:fs + node:crypto only) so the route layer can drive it
|
|
8
|
-
* directly.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { promises as fs } from 'node:fs'
|
|
12
|
-
import { createHash } from 'node:crypto'
|
|
13
|
-
import { homedir } from 'node:os'
|
|
14
|
-
import path from 'node:path'
|
|
15
|
-
import type { GenerateMode, HistoryEntry, HistoryEntryInput } from './protocol.ts'
|
|
16
|
-
|
|
17
|
-
const HISTORY_DIR = path.join(homedir(), '.dsh', 'dsh-imagegen')
|
|
18
|
-
const GALLERY_DIR = path.join(HISTORY_DIR, 'gallery')
|
|
19
|
-
const INDEX_PATH = path.join(GALLERY_DIR, 'index.json')
|
|
20
|
-
const IMAGES_DIR = path.join(GALLERY_DIR, 'images')
|
|
21
|
-
|
|
22
|
-
/** One gallery entry carries the same wire shape as a history entry. */
|
|
23
|
-
export interface GalleryAppendResult {
|
|
24
|
-
/** The full gallery list after the append (newest first). */
|
|
25
|
-
entries: HistoryEntry[]
|
|
26
|
-
/** Whether a new entry was added (false = a content-identical image was
|
|
27
|
-
* already in the gallery, so the append was skipped). */
|
|
28
|
-
added: boolean
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// Gallery mutations read and replace one shared index. Serialize them so
|
|
32
|
-
// overlapping requests cannot each read an old index and lose the other's row.
|
|
33
|
-
let pendingMutation: Promise<void> = Promise.resolve()
|
|
34
|
-
|
|
35
|
-
function mutateGallery<T>(operation: () => Promise<T>): Promise<T> {
|
|
36
|
-
const next = pendingMutation.then(operation, operation)
|
|
37
|
-
pendingMutation = next.then(() => undefined, () => undefined)
|
|
38
|
-
return next
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** One image's on-disk record (file name + mime, never base64). */
|
|
42
|
-
interface StoredImage {
|
|
43
|
-
file: string
|
|
44
|
-
mime: string
|
|
45
|
-
revisedPrompt?: string
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** One entry's on-disk record. `hash` fingerprints the first image so the
|
|
49
|
-
* store can refuse duplicate appends cheaply. */
|
|
50
|
-
interface StoredEntry {
|
|
51
|
-
id: string
|
|
52
|
-
createdAt: number
|
|
53
|
-
mode: GenerateMode
|
|
54
|
-
model: string
|
|
55
|
-
prompt: string
|
|
56
|
-
size: string
|
|
57
|
-
quality: string
|
|
58
|
-
detail: string
|
|
59
|
-
n: number
|
|
60
|
-
images: StoredImage[]
|
|
61
|
-
hash?: string
|
|
62
|
-
refName?: string
|
|
63
|
-
tags?: string[]
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
case 'image/
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
case '.
|
|
88
|
-
case '.
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
if (
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
return
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
&& typeof entry.
|
|
142
|
-
&&
|
|
143
|
-
&& entry.
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Host-persisted gallery (user-curated favorites): mirrors the history store
|
|
3
|
+
* (image files + an index.json under ~/.dsh/dsh-imagegen/gallery/) but with no
|
|
4
|
+
* size cap — every entry is an explicit user choice. Appends are deduplicated
|
|
5
|
+
* by image content so adding the same generated image twice is a no-op.
|
|
6
|
+
*
|
|
7
|
+
* Framework-free (node:fs + node:crypto only) so the route layer can drive it
|
|
8
|
+
* directly.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { promises as fs } from 'node:fs'
|
|
12
|
+
import { createHash } from 'node:crypto'
|
|
13
|
+
import { homedir } from 'node:os'
|
|
14
|
+
import path from 'node:path'
|
|
15
|
+
import type { GenerateMode, HistoryEntry, HistoryEntryInput } from './protocol.ts'
|
|
16
|
+
|
|
17
|
+
const HISTORY_DIR = path.join(homedir(), '.dsh', 'dsh-imagegen')
|
|
18
|
+
const GALLERY_DIR = path.join(HISTORY_DIR, 'gallery')
|
|
19
|
+
const INDEX_PATH = path.join(GALLERY_DIR, 'index.json')
|
|
20
|
+
const IMAGES_DIR = path.join(GALLERY_DIR, 'images')
|
|
21
|
+
|
|
22
|
+
/** One gallery entry carries the same wire shape as a history entry. */
|
|
23
|
+
export interface GalleryAppendResult {
|
|
24
|
+
/** The full gallery list after the append (newest first). */
|
|
25
|
+
entries: HistoryEntry[]
|
|
26
|
+
/** Whether a new entry was added (false = a content-identical image was
|
|
27
|
+
* already in the gallery, so the append was skipped). */
|
|
28
|
+
added: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Gallery mutations read and replace one shared index. Serialize them so
|
|
32
|
+
// overlapping requests cannot each read an old index and lose the other's row.
|
|
33
|
+
let pendingMutation: Promise<void> = Promise.resolve()
|
|
34
|
+
|
|
35
|
+
function mutateGallery<T>(operation: () => Promise<T>): Promise<T> {
|
|
36
|
+
const next = pendingMutation.then(operation, operation)
|
|
37
|
+
pendingMutation = next.then(() => undefined, () => undefined)
|
|
38
|
+
return next
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One image's on-disk record (file name + mime, never base64). */
|
|
42
|
+
interface StoredImage {
|
|
43
|
+
file: string
|
|
44
|
+
mime: string
|
|
45
|
+
revisedPrompt?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** One entry's on-disk record. `hash` fingerprints the first image so the
|
|
49
|
+
* store can refuse duplicate appends cheaply. */
|
|
50
|
+
interface StoredEntry {
|
|
51
|
+
id: string
|
|
52
|
+
createdAt: number
|
|
53
|
+
mode: GenerateMode
|
|
54
|
+
model: string
|
|
55
|
+
prompt: string
|
|
56
|
+
size: string
|
|
57
|
+
quality: string
|
|
58
|
+
detail: string
|
|
59
|
+
n: number
|
|
60
|
+
images: StoredImage[]
|
|
61
|
+
hash?: string
|
|
62
|
+
refName?: string
|
|
63
|
+
tags?: string[]
|
|
64
|
+
channelId?: string
|
|
65
|
+
channel?: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The index.json shape. */
|
|
69
|
+
interface IndexFile {
|
|
70
|
+
entries: StoredEntry[]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** File extension for a MIME type (image file names). */
|
|
74
|
+
function extensionOf(mime: string): string {
|
|
75
|
+
switch (mime.split(';')[0]!.trim()) {
|
|
76
|
+
case 'image/jpeg': return 'jpg'
|
|
77
|
+
case 'image/webp': return 'webp'
|
|
78
|
+
case 'image/gif': return 'gif'
|
|
79
|
+
default: return 'png'
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** MIME type for a stored image file name (image route responses). */
|
|
84
|
+
function mimeOfFile(file: string): string {
|
|
85
|
+
const ext = path.extname(file).toLowerCase()
|
|
86
|
+
switch (ext) {
|
|
87
|
+
case '.jpg':
|
|
88
|
+
case '.jpeg': return 'image/jpeg'
|
|
89
|
+
case '.webp': return 'image/webp'
|
|
90
|
+
case '.gif': return 'image/gif'
|
|
91
|
+
default: return 'image/png'
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Sanitize an entry id for use as a file-name prefix. */
|
|
96
|
+
function safeId(id: string): string {
|
|
97
|
+
const cleaned = id.replace(/[^a-zA-Z0-9-]/g, '-')
|
|
98
|
+
return cleaned === '' ? 'entry' : cleaned
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Short content fingerprint of the entry's first image. */
|
|
102
|
+
function fingerprint(input: HistoryEntryInput): string | undefined {
|
|
103
|
+
const first = input.images[0]
|
|
104
|
+
if (first === undefined) return undefined
|
|
105
|
+
return createHash('sha1').update(first.b64).digest('hex')
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Ensure the storage directories exist. */
|
|
109
|
+
async function ensureDirs(): Promise<void> {
|
|
110
|
+
await fs.mkdir(IMAGES_DIR, { recursive: true })
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Read the index, tolerating a missing/corrupt file. */
|
|
114
|
+
async function readIndex(): Promise<StoredEntry[]> {
|
|
115
|
+
try {
|
|
116
|
+
const raw = await fs.readFile(INDEX_PATH, 'utf8')
|
|
117
|
+
const parsed: unknown = JSON.parse(raw)
|
|
118
|
+
if (parsed === null || typeof parsed !== 'object') return []
|
|
119
|
+
const entries = (parsed as { entries?: unknown }).entries
|
|
120
|
+
if (!Array.isArray(entries)) return []
|
|
121
|
+
return entries.filter(isStoredEntry)
|
|
122
|
+
} catch {
|
|
123
|
+
return []
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Persist the index. */
|
|
128
|
+
async function writeIndex(entries: StoredEntry[]): Promise<void> {
|
|
129
|
+
await ensureDirs()
|
|
130
|
+
const payload: IndexFile = { entries }
|
|
131
|
+
const tmp = `${INDEX_PATH}.tmp-${process.pid}`
|
|
132
|
+
await fs.writeFile(tmp, JSON.stringify(payload), 'utf8')
|
|
133
|
+
await fs.rename(tmp, INDEX_PATH)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Structural guard for a stored entry. */
|
|
137
|
+
function isStoredEntry(value: unknown): value is StoredEntry {
|
|
138
|
+
if (value === null || typeof value !== 'object') return false
|
|
139
|
+
const entry = value as Record<string, unknown>
|
|
140
|
+
return typeof entry.id === 'string'
|
|
141
|
+
&& typeof entry.createdAt === 'number'
|
|
142
|
+
&& (entry.mode === 'text' || entry.mode === 'edit')
|
|
143
|
+
&& typeof entry.prompt === 'string'
|
|
144
|
+
&& Array.isArray(entry.images)
|
|
145
|
+
&& entry.images.every(image => {
|
|
146
|
+
if (image === null || typeof image !== 'object') return false
|
|
147
|
+
const record = image as Record<string, unknown>
|
|
148
|
+
return typeof record.file === 'string' && typeof record.mime === 'string'
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Remove one entry's image files (best effort). */
|
|
153
|
+
async function removeEntryFiles(entry: StoredEntry): Promise<void> {
|
|
154
|
+
for (const image of entry.images) {
|
|
155
|
+
try { await fs.rm(path.join(IMAGES_DIR, image.file), { force: true }) } catch { /* ignore */ }
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Project a stored entry onto the wire shape (image URLs). */
|
|
160
|
+
function toWire(entry: StoredEntry): HistoryEntry {
|
|
161
|
+
return {
|
|
162
|
+
id: entry.id,
|
|
163
|
+
createdAt: entry.createdAt,
|
|
164
|
+
mode: entry.mode,
|
|
165
|
+
model: entry.model,
|
|
166
|
+
prompt: entry.prompt,
|
|
167
|
+
size: entry.size,
|
|
168
|
+
quality: entry.quality,
|
|
169
|
+
detail: entry.detail,
|
|
170
|
+
n: entry.n,
|
|
171
|
+
images: entry.images.map(image => ({
|
|
172
|
+
url: `/api/dsh-imagegen/gallery/image/${image.file}`,
|
|
173
|
+
mime: image.mime,
|
|
174
|
+
...image.revisedPrompt === undefined ? {} : { revisedPrompt: image.revisedPrompt },
|
|
175
|
+
})),
|
|
176
|
+
...entry.refName === undefined ? {} : { refName: entry.refName },
|
|
177
|
+
...entry.tags === undefined ? {} : { tags: entry.tags },
|
|
178
|
+
...entry.channel === undefined ? {} : { channel: entry.channel },
|
|
179
|
+
...entry.channelId === undefined ? {} : { channelId: entry.channelId },
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** List the persisted gallery, newest first, as wire entries. */
|
|
184
|
+
export async function listGallery(): Promise<HistoryEntry[]> {
|
|
185
|
+
const entries = await readIndex()
|
|
186
|
+
return entries.map(toWire)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Append one image to the gallery. Deduplicates by first-image content —
|
|
190
|
+
* appending an image already in the gallery returns `added: false` with the
|
|
191
|
+
* list unchanged. No size cap: every entry is an explicit user choice. */
|
|
192
|
+
export async function appendGallery(input: HistoryEntryInput): Promise<GalleryAppendResult> {
|
|
193
|
+
return mutateGallery(async () => {
|
|
194
|
+
await ensureDirs()
|
|
195
|
+
const hash = fingerprint(input)
|
|
196
|
+
if (hash !== undefined) {
|
|
197
|
+
const existing = await readIndex()
|
|
198
|
+
if (existing.some(entry => entry.hash === hash)) {
|
|
199
|
+
return { entries: existing.map(toWire), added: false }
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const prefix = safeId(input.id)
|
|
203
|
+
const storedImages: StoredImage[] = []
|
|
204
|
+
try {
|
|
205
|
+
for (let index = 0; index < input.images.length; index++) {
|
|
206
|
+
const image = input.images[index]!
|
|
207
|
+
const file = `${prefix}-${index}.${extensionOf(image.mime)}`
|
|
208
|
+
await fs.writeFile(path.join(IMAGES_DIR, file), Buffer.from(image.b64, 'base64'))
|
|
209
|
+
storedImages.push({
|
|
210
|
+
file,
|
|
211
|
+
mime: image.mime,
|
|
212
|
+
...image.revisedPrompt === undefined ? {} : { revisedPrompt: image.revisedPrompt },
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
} catch (error) {
|
|
216
|
+
await removeEntryFiles({ images: storedImages } as StoredEntry)
|
|
217
|
+
throw error
|
|
218
|
+
}
|
|
219
|
+
const entry: StoredEntry = {
|
|
220
|
+
id: input.id,
|
|
221
|
+
createdAt: input.createdAt,
|
|
222
|
+
mode: input.mode,
|
|
223
|
+
model: input.model,
|
|
224
|
+
prompt: input.prompt,
|
|
225
|
+
size: input.size,
|
|
226
|
+
quality: input.quality,
|
|
227
|
+
detail: input.detail,
|
|
228
|
+
n: input.n,
|
|
229
|
+
images: storedImages,
|
|
230
|
+
...hash === undefined ? {} : { hash },
|
|
231
|
+
...input.refName === undefined ? {} : { refName: input.refName },
|
|
232
|
+
...input.channelId === undefined ? {} : { channelId: input.channelId },
|
|
233
|
+
...input.channel === undefined ? {} : { channel: input.channel },
|
|
234
|
+
}
|
|
235
|
+
const merged = [entry, ...await readIndex()]
|
|
236
|
+
await writeIndex(merged)
|
|
237
|
+
return { entries: merged.map(toWire), added: true }
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Remove one entry (and its image files). */
|
|
242
|
+
export async function removeGallery(id: string): Promise<HistoryEntry[]> {
|
|
243
|
+
return mutateGallery(async () => {
|
|
244
|
+
const previous = await readIndex()
|
|
245
|
+
const target = previous.find(entry => entry.id === id)
|
|
246
|
+
if (target !== undefined) await removeEntryFiles(target)
|
|
247
|
+
const kept = previous.filter(entry => entry.id !== id)
|
|
248
|
+
await writeIndex(kept)
|
|
249
|
+
return kept.map(toWire)
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Replace the user-managed labels for one gallery entry. */
|
|
254
|
+
export async function updateGalleryTags(id: string, tags: string[]): Promise<HistoryEntry[]> {
|
|
255
|
+
return mutateGallery(async () => {
|
|
256
|
+
const normalized = [...new Set(tags.map(tag => tag.trim()).filter(Boolean))].slice(0, 20)
|
|
257
|
+
const entries = await readIndex()
|
|
258
|
+
const target = entries.find(entry => entry.id === id)
|
|
259
|
+
if (target !== undefined) target.tags = normalized
|
|
260
|
+
await writeIndex(entries)
|
|
261
|
+
return entries.map(toWire)
|
|
262
|
+
})
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Remove every entry (and all image files). */
|
|
266
|
+
export async function clearGallery(): Promise<HistoryEntry[]> {
|
|
267
|
+
return mutateGallery(async () => {
|
|
268
|
+
const previous = await readIndex()
|
|
269
|
+
for (const entry of previous) await removeEntryFiles(entry)
|
|
270
|
+
await writeIndex([])
|
|
271
|
+
return []
|
|
272
|
+
})
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Read one stored image file by its (validated) file name. */
|
|
276
|
+
export async function readGalleryImage(file: string): Promise<{ data: Buffer; mime: string } | undefined> {
|
|
277
|
+
// Only accept <id>-<index>.<png|jpg|jpeg|webp|gif> — the exact names this
|
|
278
|
+
// store writes — so the route can never escape the images directory.
|
|
279
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9-]*-[0-9]+\.(png|jpg|jpeg|webp|gif)$/.test(file)) return undefined
|
|
280
|
+
try {
|
|
281
|
+
const data = await fs.readFile(path.join(IMAGES_DIR, file))
|
|
282
|
+
return { data, mime: mimeOfFile(file) }
|
|
283
|
+
} catch {
|
|
284
|
+
return undefined
|
|
285
|
+
}
|
|
286
|
+
}
|