@gallop.software/studio 1.5.10 → 2.0.1
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/app/api/studio/[...path]/route.ts +1 -0
- package/app/layout.tsx +23 -0
- package/app/page.tsx +90 -0
- package/bin/studio.mjs +110 -0
- package/dist/handlers/index.js +77 -55
- package/dist/handlers/index.js.map +1 -1
- package/dist/handlers/index.mjs +128 -106
- package/dist/handlers/index.mjs.map +1 -1
- package/dist/index.d.mts +14 -10
- package/dist/index.d.ts +14 -10
- package/dist/index.js +2 -177
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +4 -179
- package/dist/index.mjs.map +1 -1
- package/next.config.mjs +22 -0
- package/package.json +18 -10
- package/src/components/AddNewModal.tsx +402 -0
- package/src/components/ErrorModal.tsx +89 -0
- package/src/components/R2SetupModal.tsx +400 -0
- package/src/components/StudioBreadcrumb.tsx +115 -0
- package/src/components/StudioButton.tsx +200 -0
- package/src/components/StudioContext.tsx +219 -0
- package/src/components/StudioDetailView.tsx +714 -0
- package/src/components/StudioFileGrid.tsx +704 -0
- package/src/components/StudioFileList.tsx +743 -0
- package/src/components/StudioFolderPicker.tsx +342 -0
- package/src/components/StudioModal.tsx +473 -0
- package/src/components/StudioPreview.tsx +399 -0
- package/src/components/StudioSettings.tsx +536 -0
- package/src/components/StudioToolbar.tsx +1448 -0
- package/src/components/StudioUI.tsx +731 -0
- package/src/components/styles/common.ts +236 -0
- package/src/components/tokens.ts +78 -0
- package/src/components/useStudioActions.tsx +497 -0
- package/src/config/index.ts +7 -0
- package/src/config/workspace.ts +52 -0
- package/src/handlers/favicon.ts +152 -0
- package/src/handlers/files.ts +784 -0
- package/src/handlers/images.ts +949 -0
- package/src/handlers/import.ts +190 -0
- package/src/handlers/index.ts +168 -0
- package/src/handlers/list.ts +627 -0
- package/src/handlers/scan.ts +311 -0
- package/src/handlers/utils/cdn.ts +234 -0
- package/src/handlers/utils/files.ts +64 -0
- package/src/handlers/utils/index.ts +4 -0
- package/src/handlers/utils/meta.ts +102 -0
- package/src/handlers/utils/thumbnails.ts +98 -0
- package/src/hooks/useFileList.ts +143 -0
- package/src/index.tsx +36 -0
- package/src/lib/api.ts +176 -0
- package/src/types.ts +119 -0
- package/dist/StudioUI-GJK45R3T.js +0 -6500
- package/dist/StudioUI-GJK45R3T.js.map +0 -1
- package/dist/StudioUI-QZ54STXE.mjs +0 -6500
- package/dist/StudioUI-QZ54STXE.mjs.map +0 -1
- package/dist/chunk-N6JYTJCB.js +0 -68
- package/dist/chunk-N6JYTJCB.js.map +0 -1
- package/dist/chunk-RHI3UROE.mjs +0 -68
- package/dist/chunk-RHI3UROE.mjs.map +0 -1
|
@@ -0,0 +1,949 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
2
|
+
import { promises as fs } from 'fs'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'
|
|
5
|
+
import { getAllThumbnailPaths, isProcessed } from '../types'
|
|
6
|
+
import {
|
|
7
|
+
loadMeta,
|
|
8
|
+
saveMeta,
|
|
9
|
+
isImageFile,
|
|
10
|
+
getContentType,
|
|
11
|
+
processImage,
|
|
12
|
+
downloadFromCdn,
|
|
13
|
+
uploadToCdn,
|
|
14
|
+
deleteLocalThumbnails,
|
|
15
|
+
deleteThumbnailsFromCdn,
|
|
16
|
+
getOrAddCdnIndex,
|
|
17
|
+
getFileEntries,
|
|
18
|
+
getMetaEntry,
|
|
19
|
+
getCdnUrls,
|
|
20
|
+
downloadFromRemoteUrl,
|
|
21
|
+
purgeCloudflareCache,
|
|
22
|
+
} from './utils'
|
|
23
|
+
import { getPublicPath } from '../config'
|
|
24
|
+
|
|
25
|
+
export async function handleSync(request: NextRequest) {
|
|
26
|
+
const accountId = process.env.CLOUDFLARE_R2_ACCOUNT_ID
|
|
27
|
+
const accessKeyId = process.env.CLOUDFLARE_R2_ACCESS_KEY_ID
|
|
28
|
+
const secretAccessKey = process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY
|
|
29
|
+
const bucketName = process.env.CLOUDFLARE_R2_BUCKET_NAME
|
|
30
|
+
const publicUrl = process.env.CLOUDFLARE_R2_PUBLIC_URL?.replace(/\/\s*$/, '')
|
|
31
|
+
|
|
32
|
+
if (!accountId || !accessKeyId || !secretAccessKey || !bucketName || !publicUrl) {
|
|
33
|
+
return NextResponse.json(
|
|
34
|
+
{ error: 'R2 not configured. Set CLOUDFLARE_R2_* environment variables.' },
|
|
35
|
+
{ status: 400 }
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const { imageKeys } = await request.json() as { imageKeys: string[] }
|
|
41
|
+
|
|
42
|
+
if (!imageKeys || !Array.isArray(imageKeys) || imageKeys.length === 0) {
|
|
43
|
+
return NextResponse.json({ error: 'No image keys provided' }, { status: 400 })
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const meta = await loadMeta()
|
|
47
|
+
const cdnUrls = getCdnUrls(meta)
|
|
48
|
+
|
|
49
|
+
// Get or add CDN URL to the _cdns array
|
|
50
|
+
const cdnIndex = getOrAddCdnIndex(meta, publicUrl)
|
|
51
|
+
|
|
52
|
+
const r2 = new S3Client({
|
|
53
|
+
region: 'auto',
|
|
54
|
+
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
|
|
55
|
+
credentials: { accessKeyId, secretAccessKey },
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
const pushed: string[] = []
|
|
59
|
+
const errors: string[] = []
|
|
60
|
+
const urlsToPurge: string[] = []
|
|
61
|
+
|
|
62
|
+
for (let imageKey of imageKeys) {
|
|
63
|
+
// Normalize key to have leading /
|
|
64
|
+
if (!imageKey.startsWith('/')) {
|
|
65
|
+
imageKey = `/${imageKey}`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const entry = getMetaEntry(meta, imageKey)
|
|
69
|
+
if (!entry) {
|
|
70
|
+
errors.push(`Image not found in meta: ${imageKey}. Run Scan first.`)
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Check if already pushed to our R2
|
|
75
|
+
const existingCdnUrl = entry.c !== undefined ? cdnUrls[entry.c] : undefined
|
|
76
|
+
const isAlreadyInOurR2 = existingCdnUrl === publicUrl
|
|
77
|
+
|
|
78
|
+
if (isAlreadyInOurR2) {
|
|
79
|
+
pushed.push(imageKey)
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Check if this is a remote image (in another CDN)
|
|
84
|
+
const isRemote = entry.c !== undefined && existingCdnUrl !== publicUrl
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
let originalBuffer: Buffer
|
|
88
|
+
|
|
89
|
+
if (isRemote) {
|
|
90
|
+
// Download from remote URL
|
|
91
|
+
const remoteUrl = `${existingCdnUrl}${imageKey}`
|
|
92
|
+
originalBuffer = await downloadFromRemoteUrl(remoteUrl)
|
|
93
|
+
} else {
|
|
94
|
+
// Read from local file
|
|
95
|
+
const originalLocalPath = getPublicPath(imageKey)
|
|
96
|
+
try {
|
|
97
|
+
originalBuffer = await fs.readFile(originalLocalPath)
|
|
98
|
+
} catch {
|
|
99
|
+
errors.push(`Original file not found: ${imageKey}`)
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Upload original to R2
|
|
105
|
+
await r2.send(
|
|
106
|
+
new PutObjectCommand({
|
|
107
|
+
Bucket: bucketName,
|
|
108
|
+
Key: imageKey.replace(/^\//, ''),
|
|
109
|
+
Body: originalBuffer,
|
|
110
|
+
ContentType: getContentType(imageKey),
|
|
111
|
+
})
|
|
112
|
+
)
|
|
113
|
+
urlsToPurge.push(`${publicUrl}${imageKey}`)
|
|
114
|
+
|
|
115
|
+
// Upload thumbnails (only if processed locally, not for remote imports)
|
|
116
|
+
if (!isRemote && isProcessed(entry)) {
|
|
117
|
+
for (const thumbPath of getAllThumbnailPaths(imageKey)) {
|
|
118
|
+
const localPath = getPublicPath(thumbPath)
|
|
119
|
+
try {
|
|
120
|
+
const fileBuffer = await fs.readFile(localPath)
|
|
121
|
+
await r2.send(
|
|
122
|
+
new PutObjectCommand({
|
|
123
|
+
Bucket: bucketName,
|
|
124
|
+
Key: thumbPath.replace(/^\//, ''),
|
|
125
|
+
Body: fileBuffer,
|
|
126
|
+
ContentType: getContentType(thumbPath),
|
|
127
|
+
})
|
|
128
|
+
)
|
|
129
|
+
urlsToPurge.push(`${publicUrl}${thumbPath}`)
|
|
130
|
+
} catch {
|
|
131
|
+
// Thumbnail might not exist
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
entry.c = cdnIndex
|
|
137
|
+
|
|
138
|
+
// Delete local files (only for non-remote, local images being pushed)
|
|
139
|
+
if (!isRemote) {
|
|
140
|
+
const originalLocalPath = getPublicPath(imageKey)
|
|
141
|
+
|
|
142
|
+
// Delete local thumbnails
|
|
143
|
+
for (const thumbPath of getAllThumbnailPaths(imageKey)) {
|
|
144
|
+
const localPath = getPublicPath(thumbPath)
|
|
145
|
+
try { await fs.unlink(localPath) } catch { /* ignore */ }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Delete local original
|
|
149
|
+
try { await fs.unlink(originalLocalPath) } catch { /* ignore */ }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
pushed.push(imageKey)
|
|
153
|
+
} catch (error) {
|
|
154
|
+
console.error(`Failed to push ${imageKey}:`, error)
|
|
155
|
+
errors.push(`Failed to push: ${imageKey}`)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
await saveMeta(meta)
|
|
160
|
+
|
|
161
|
+
// Purge Cloudflare cache for uploaded files
|
|
162
|
+
if (urlsToPurge.length > 0) {
|
|
163
|
+
await purgeCloudflareCache(urlsToPurge)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return NextResponse.json({
|
|
167
|
+
success: true,
|
|
168
|
+
pushed,
|
|
169
|
+
errors: errors.length > 0 ? errors : undefined,
|
|
170
|
+
})
|
|
171
|
+
} catch (error) {
|
|
172
|
+
console.error('Failed to push:', error)
|
|
173
|
+
return NextResponse.json({ error: 'Failed to push to CDN' }, { status: 500 })
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function handleReprocess(request: NextRequest) {
|
|
178
|
+
const publicUrl = process.env.CLOUDFLARE_R2_PUBLIC_URL?.replace(/\/\s*$/, '')
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
const { imageKeys } = await request.json() as { imageKeys: string[] }
|
|
182
|
+
|
|
183
|
+
if (!imageKeys || !Array.isArray(imageKeys) || imageKeys.length === 0) {
|
|
184
|
+
return NextResponse.json({ error: 'No image keys provided' }, { status: 400 })
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const meta = await loadMeta()
|
|
188
|
+
const cdnUrls = getCdnUrls(meta)
|
|
189
|
+
const processed: string[] = []
|
|
190
|
+
const errors: string[] = []
|
|
191
|
+
const urlsToPurge: string[] = []
|
|
192
|
+
|
|
193
|
+
for (let imageKey of imageKeys) {
|
|
194
|
+
// Normalize key to have leading /
|
|
195
|
+
if (!imageKey.startsWith('/')) {
|
|
196
|
+
imageKey = `/${imageKey}`
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
let buffer: Buffer
|
|
201
|
+
const entry = getMetaEntry(meta, imageKey)
|
|
202
|
+
const existingCdnIndex = entry?.c
|
|
203
|
+
const existingCdnUrl = existingCdnIndex !== undefined ? cdnUrls[existingCdnIndex] : undefined
|
|
204
|
+
|
|
205
|
+
// Determine if this is our R2 or a remote CDN
|
|
206
|
+
const isInOurR2 = existingCdnUrl === publicUrl
|
|
207
|
+
const isRemote = existingCdnIndex !== undefined && !isInOurR2
|
|
208
|
+
|
|
209
|
+
const originalPath = getPublicPath(imageKey)
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
buffer = await fs.readFile(originalPath)
|
|
213
|
+
} catch {
|
|
214
|
+
if (isInOurR2) {
|
|
215
|
+
// Download original from our R2
|
|
216
|
+
buffer = await downloadFromCdn(imageKey)
|
|
217
|
+
const dir = path.dirname(originalPath)
|
|
218
|
+
await fs.mkdir(dir, { recursive: true })
|
|
219
|
+
await fs.writeFile(originalPath, buffer)
|
|
220
|
+
} else if (isRemote && existingCdnUrl) {
|
|
221
|
+
// Download from remote URL
|
|
222
|
+
const remoteUrl = `${existingCdnUrl}${imageKey}`
|
|
223
|
+
buffer = await downloadFromRemoteUrl(remoteUrl)
|
|
224
|
+
const dir = path.dirname(originalPath)
|
|
225
|
+
await fs.mkdir(dir, { recursive: true })
|
|
226
|
+
await fs.writeFile(originalPath, buffer)
|
|
227
|
+
} else {
|
|
228
|
+
throw new Error(`File not found: ${imageKey}`)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const updatedEntry = await processImage(buffer, imageKey)
|
|
233
|
+
// No need to set p flag - presence of thumbnail dims (sm/md/lg/f) indicates processed
|
|
234
|
+
|
|
235
|
+
if (isInOurR2) {
|
|
236
|
+
// Re-upload thumbnails to R2 and clean up local files
|
|
237
|
+
updatedEntry.c = existingCdnIndex
|
|
238
|
+
await uploadToCdn(imageKey)
|
|
239
|
+
|
|
240
|
+
// Collect URLs to purge
|
|
241
|
+
for (const thumbPath of getAllThumbnailPaths(imageKey)) {
|
|
242
|
+
urlsToPurge.push(`${publicUrl}${thumbPath}`)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
await deleteLocalThumbnails(imageKey)
|
|
246
|
+
// Delete local original
|
|
247
|
+
try { await fs.unlink(originalPath) } catch { /* ignore */ }
|
|
248
|
+
} else if (isRemote) {
|
|
249
|
+
// Remote image processed locally - remove c flag, now it's local
|
|
250
|
+
// Keep the original and thumbnails locally
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
meta[imageKey] = updatedEntry
|
|
254
|
+
processed.push(imageKey)
|
|
255
|
+
} catch (error) {
|
|
256
|
+
console.error(`Failed to reprocess ${imageKey}:`, error)
|
|
257
|
+
errors.push(imageKey)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
await saveMeta(meta)
|
|
262
|
+
|
|
263
|
+
// Purge Cloudflare cache for re-uploaded thumbnails
|
|
264
|
+
if (urlsToPurge.length > 0) {
|
|
265
|
+
await purgeCloudflareCache(urlsToPurge)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return NextResponse.json({
|
|
269
|
+
success: true,
|
|
270
|
+
processed,
|
|
271
|
+
errors: errors.length > 0 ? errors : undefined,
|
|
272
|
+
})
|
|
273
|
+
} catch (error) {
|
|
274
|
+
console.error('Failed to reprocess:', error)
|
|
275
|
+
return NextResponse.json({ error: 'Failed to reprocess images' }, { status: 500 })
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export async function handleUnprocessStream(request: NextRequest) {
|
|
280
|
+
const publicUrl = process.env.CLOUDFLARE_R2_PUBLIC_URL?.replace(/\/\s*$/, '')
|
|
281
|
+
const encoder = new TextEncoder()
|
|
282
|
+
|
|
283
|
+
// Parse the request body before creating the stream
|
|
284
|
+
let imageKeys: string[]
|
|
285
|
+
try {
|
|
286
|
+
const body = await request.json() as { imageKeys: string[] }
|
|
287
|
+
imageKeys = body.imageKeys
|
|
288
|
+
|
|
289
|
+
if (!imageKeys || !Array.isArray(imageKeys) || imageKeys.length === 0) {
|
|
290
|
+
return NextResponse.json({ error: 'No image keys provided' }, { status: 400 })
|
|
291
|
+
}
|
|
292
|
+
} catch {
|
|
293
|
+
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const stream = new ReadableStream({
|
|
297
|
+
async start(controller) {
|
|
298
|
+
const sendEvent = (data: object) => {
|
|
299
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`))
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
try {
|
|
303
|
+
const meta = await loadMeta()
|
|
304
|
+
const cdnUrls = getCdnUrls(meta)
|
|
305
|
+
const removed: string[] = []
|
|
306
|
+
const skipped: string[] = []
|
|
307
|
+
const errors: string[] = []
|
|
308
|
+
const urlsToPurge: string[] = []
|
|
309
|
+
|
|
310
|
+
const total = imageKeys.length
|
|
311
|
+
sendEvent({ type: 'start', total })
|
|
312
|
+
|
|
313
|
+
for (let i = 0; i < imageKeys.length; i++) {
|
|
314
|
+
let imageKey = imageKeys[i]
|
|
315
|
+
|
|
316
|
+
// Normalize key to have leading /
|
|
317
|
+
if (!imageKey.startsWith('/')) {
|
|
318
|
+
imageKey = `/${imageKey}`
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
sendEvent({
|
|
322
|
+
type: 'progress',
|
|
323
|
+
current: i + 1,
|
|
324
|
+
total,
|
|
325
|
+
percent: Math.round(((i + 1) / total) * 100),
|
|
326
|
+
message: `Removing thumbnails for ${imageKey.slice(1)}...`
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
try {
|
|
330
|
+
const entry = getMetaEntry(meta, imageKey)
|
|
331
|
+
if (!entry) {
|
|
332
|
+
errors.push(imageKey)
|
|
333
|
+
continue
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Check if this image has any thumbnails
|
|
337
|
+
const hasThumbnails = entry.sm || entry.md || entry.lg || entry.f
|
|
338
|
+
if (!hasThumbnails) {
|
|
339
|
+
skipped.push(imageKey)
|
|
340
|
+
continue
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const existingCdnIndex = entry.c
|
|
344
|
+
const existingCdnUrl = existingCdnIndex !== undefined ? cdnUrls[existingCdnIndex] : undefined
|
|
345
|
+
const isInOurR2 = existingCdnUrl === publicUrl
|
|
346
|
+
|
|
347
|
+
// Delete local thumbnails
|
|
348
|
+
await deleteLocalThumbnails(imageKey)
|
|
349
|
+
|
|
350
|
+
// Delete cloud thumbnails if in our R2
|
|
351
|
+
if (isInOurR2) {
|
|
352
|
+
await deleteThumbnailsFromCdn(imageKey)
|
|
353
|
+
|
|
354
|
+
// Collect URLs to purge from cache
|
|
355
|
+
for (const thumbPath of getAllThumbnailPaths(imageKey)) {
|
|
356
|
+
urlsToPurge.push(`${publicUrl}${thumbPath}`)
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Update meta - keep o, b, c but remove thumbnail dimensions
|
|
361
|
+
meta[imageKey] = {
|
|
362
|
+
o: entry.o,
|
|
363
|
+
b: entry.b,
|
|
364
|
+
...(entry.c !== undefined ? { c: entry.c } : {}),
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
removed.push(imageKey)
|
|
368
|
+
} catch (error) {
|
|
369
|
+
console.error(`Failed to unprocess ${imageKey}:`, error)
|
|
370
|
+
errors.push(imageKey)
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
sendEvent({ type: 'cleanup', message: 'Saving metadata...' })
|
|
375
|
+
await saveMeta(meta)
|
|
376
|
+
|
|
377
|
+
if (urlsToPurge.length > 0) {
|
|
378
|
+
sendEvent({ type: 'cleanup', message: 'Purging CDN cache...' })
|
|
379
|
+
await purgeCloudflareCache(urlsToPurge)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Build completion message
|
|
383
|
+
let message = `Removed thumbnails from ${removed.length} image${removed.length !== 1 ? 's' : ''}.`
|
|
384
|
+
if (skipped.length > 0) {
|
|
385
|
+
message += ` ${skipped.length} image${skipped.length !== 1 ? 's' : ''} had no thumbnails.`
|
|
386
|
+
}
|
|
387
|
+
if (errors.length > 0) {
|
|
388
|
+
message += ` ${errors.length} image${errors.length !== 1 ? 's' : ''} failed.`
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
sendEvent({
|
|
392
|
+
type: 'complete',
|
|
393
|
+
processed: removed.length,
|
|
394
|
+
skipped: skipped.length,
|
|
395
|
+
errors: errors.length,
|
|
396
|
+
message
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
controller.close()
|
|
400
|
+
} catch (error) {
|
|
401
|
+
console.error('Unprocess stream error:', error)
|
|
402
|
+
sendEvent({ type: 'error', message: 'Failed to remove thumbnails' })
|
|
403
|
+
controller.close()
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
return new Response(stream, {
|
|
409
|
+
headers: {
|
|
410
|
+
'Content-Type': 'text/event-stream',
|
|
411
|
+
'Cache-Control': 'no-cache',
|
|
412
|
+
Connection: 'keep-alive',
|
|
413
|
+
},
|
|
414
|
+
})
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export async function handleReprocessStream(request: NextRequest) {
|
|
418
|
+
const publicUrl = process.env.CLOUDFLARE_R2_PUBLIC_URL?.replace(/\/\s*$/, '')
|
|
419
|
+
const encoder = new TextEncoder()
|
|
420
|
+
|
|
421
|
+
// Parse the request body before creating the stream
|
|
422
|
+
let imageKeys: string[]
|
|
423
|
+
try {
|
|
424
|
+
const body = await request.json() as { imageKeys: string[] }
|
|
425
|
+
imageKeys = body.imageKeys
|
|
426
|
+
|
|
427
|
+
if (!imageKeys || !Array.isArray(imageKeys) || imageKeys.length === 0) {
|
|
428
|
+
return NextResponse.json({ error: 'No image keys provided' }, { status: 400 })
|
|
429
|
+
}
|
|
430
|
+
} catch {
|
|
431
|
+
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const stream = new ReadableStream({
|
|
435
|
+
async start(controller) {
|
|
436
|
+
const sendEvent = (data: object) => {
|
|
437
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`))
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
try {
|
|
441
|
+
const meta = await loadMeta()
|
|
442
|
+
const cdnUrls = getCdnUrls(meta)
|
|
443
|
+
const processed: string[] = []
|
|
444
|
+
const errors: string[] = []
|
|
445
|
+
const urlsToPurge: string[] = []
|
|
446
|
+
|
|
447
|
+
const total = imageKeys.length
|
|
448
|
+
sendEvent({ type: 'start', total })
|
|
449
|
+
|
|
450
|
+
for (let i = 0; i < imageKeys.length; i++) {
|
|
451
|
+
let imageKey = imageKeys[i]
|
|
452
|
+
|
|
453
|
+
// Normalize key to have leading /
|
|
454
|
+
if (!imageKey.startsWith('/')) {
|
|
455
|
+
imageKey = `/${imageKey}`
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
sendEvent({
|
|
459
|
+
type: 'progress',
|
|
460
|
+
current: i + 1,
|
|
461
|
+
total,
|
|
462
|
+
percent: Math.round(((i + 1) / total) * 100),
|
|
463
|
+
message: `Processing ${imageKey.slice(1)}...`
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
try {
|
|
467
|
+
let buffer: Buffer
|
|
468
|
+
const entry = getMetaEntry(meta, imageKey)
|
|
469
|
+
const existingCdnIndex = entry?.c
|
|
470
|
+
const existingCdnUrl = existingCdnIndex !== undefined ? cdnUrls[existingCdnIndex] : undefined
|
|
471
|
+
|
|
472
|
+
// Determine if this is our R2 or a remote CDN
|
|
473
|
+
const isInOurR2 = existingCdnUrl === publicUrl
|
|
474
|
+
const isRemote = existingCdnIndex !== undefined && !isInOurR2
|
|
475
|
+
|
|
476
|
+
const originalPath = getPublicPath(imageKey)
|
|
477
|
+
|
|
478
|
+
try {
|
|
479
|
+
buffer = await fs.readFile(originalPath)
|
|
480
|
+
} catch {
|
|
481
|
+
if (isInOurR2) {
|
|
482
|
+
buffer = await downloadFromCdn(imageKey)
|
|
483
|
+
const dir = path.dirname(originalPath)
|
|
484
|
+
await fs.mkdir(dir, { recursive: true })
|
|
485
|
+
await fs.writeFile(originalPath, buffer)
|
|
486
|
+
} else if (isRemote && existingCdnUrl) {
|
|
487
|
+
const remoteUrl = `${existingCdnUrl}${imageKey}`
|
|
488
|
+
buffer = await downloadFromRemoteUrl(remoteUrl)
|
|
489
|
+
const dir = path.dirname(originalPath)
|
|
490
|
+
await fs.mkdir(dir, { recursive: true })
|
|
491
|
+
await fs.writeFile(originalPath, buffer)
|
|
492
|
+
} else {
|
|
493
|
+
throw new Error(`File not found: ${imageKey}`)
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const ext = path.extname(imageKey).toLowerCase()
|
|
498
|
+
const isSvg = ext === '.svg'
|
|
499
|
+
|
|
500
|
+
if (isSvg) {
|
|
501
|
+
const imageDir = path.dirname(imageKey.slice(1))
|
|
502
|
+
const imagesPath = getPublicPath('images', imageDir === '.' ? '' : imageDir)
|
|
503
|
+
await fs.mkdir(imagesPath, { recursive: true })
|
|
504
|
+
|
|
505
|
+
const fileName = path.basename(imageKey)
|
|
506
|
+
const destPath = path.join(imagesPath, fileName)
|
|
507
|
+
await fs.writeFile(destPath, buffer)
|
|
508
|
+
|
|
509
|
+
meta[imageKey] = {
|
|
510
|
+
...entry,
|
|
511
|
+
o: { w: 0, h: 0 },
|
|
512
|
+
b: '',
|
|
513
|
+
f: { w: 0, h: 0 },
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (isRemote) {
|
|
517
|
+
delete (meta[imageKey] as import('../types').MetaEntry).c
|
|
518
|
+
}
|
|
519
|
+
} else {
|
|
520
|
+
const updatedEntry = await processImage(buffer, imageKey)
|
|
521
|
+
|
|
522
|
+
if (isInOurR2) {
|
|
523
|
+
updatedEntry.c = existingCdnIndex
|
|
524
|
+
await uploadToCdn(imageKey)
|
|
525
|
+
|
|
526
|
+
for (const thumbPath of getAllThumbnailPaths(imageKey)) {
|
|
527
|
+
urlsToPurge.push(`${publicUrl}${thumbPath}`)
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
await deleteLocalThumbnails(imageKey)
|
|
531
|
+
try { await fs.unlink(originalPath) } catch { /* ignore */ }
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
meta[imageKey] = updatedEntry
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
processed.push(imageKey)
|
|
538
|
+
} catch (error) {
|
|
539
|
+
console.error(`Failed to reprocess ${imageKey}:`, error)
|
|
540
|
+
errors.push(imageKey)
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
sendEvent({ type: 'cleanup', message: 'Saving metadata...' })
|
|
545
|
+
await saveMeta(meta)
|
|
546
|
+
|
|
547
|
+
if (urlsToPurge.length > 0) {
|
|
548
|
+
sendEvent({ type: 'cleanup', message: 'Purging CDN cache...' })
|
|
549
|
+
await purgeCloudflareCache(urlsToPurge)
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Build completion message
|
|
553
|
+
let message = `Generated thumbnails for ${processed.length} image${processed.length !== 1 ? 's' : ''}.`
|
|
554
|
+
if (errors.length > 0) {
|
|
555
|
+
message += ` ${errors.length} image${errors.length !== 1 ? 's' : ''} failed.`
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
sendEvent({
|
|
559
|
+
type: 'complete',
|
|
560
|
+
processed: processed.length,
|
|
561
|
+
errors: errors.length,
|
|
562
|
+
message
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
controller.close()
|
|
566
|
+
} catch (error) {
|
|
567
|
+
console.error('Reprocess stream error:', error)
|
|
568
|
+
sendEvent({ type: 'error', message: 'Failed to generate thumbnails' })
|
|
569
|
+
controller.close()
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
})
|
|
573
|
+
|
|
574
|
+
return new Response(stream, {
|
|
575
|
+
headers: {
|
|
576
|
+
'Content-Type': 'text/event-stream',
|
|
577
|
+
'Cache-Control': 'no-cache',
|
|
578
|
+
Connection: 'keep-alive',
|
|
579
|
+
},
|
|
580
|
+
})
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
export async function handleProcessAllStream() {
|
|
584
|
+
const publicUrl = process.env.CLOUDFLARE_R2_PUBLIC_URL?.replace(/\/\s*$/, '')
|
|
585
|
+
const encoder = new TextEncoder()
|
|
586
|
+
|
|
587
|
+
const stream = new ReadableStream({
|
|
588
|
+
async start(controller) {
|
|
589
|
+
const sendEvent = (data: object) => {
|
|
590
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`))
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
try {
|
|
594
|
+
const meta = await loadMeta()
|
|
595
|
+
const cdnUrls = getCdnUrls(meta)
|
|
596
|
+
const processed: string[] = []
|
|
597
|
+
const errors: string[] = []
|
|
598
|
+
const orphansRemoved: string[] = []
|
|
599
|
+
const urlsToPurge: string[] = []
|
|
600
|
+
|
|
601
|
+
// Count images in different states
|
|
602
|
+
let alreadyProcessed = 0
|
|
603
|
+
|
|
604
|
+
// Get all images from meta that need processing (no p flag = not processed yet)
|
|
605
|
+
const imagesToProcess: Array<{ key: string; entry: import('../types').MetaEntry }> = []
|
|
606
|
+
|
|
607
|
+
for (const [key, entry] of getFileEntries(meta)) {
|
|
608
|
+
const fileName = path.basename(key)
|
|
609
|
+
if (!isImageFile(fileName)) continue
|
|
610
|
+
|
|
611
|
+
// Check if needs processing (no thumbnail dims = not processed yet)
|
|
612
|
+
if (!isProcessed(entry)) {
|
|
613
|
+
imagesToProcess.push({ key, entry })
|
|
614
|
+
} else {
|
|
615
|
+
alreadyProcessed++
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const total = imagesToProcess.length
|
|
620
|
+
sendEvent({ type: 'start', total })
|
|
621
|
+
|
|
622
|
+
for (let i = 0; i < imagesToProcess.length; i++) {
|
|
623
|
+
const { key, entry } = imagesToProcess[i]
|
|
624
|
+
const fullPath = getPublicPath(key)
|
|
625
|
+
const existingCdnIndex = entry.c
|
|
626
|
+
const existingCdnUrl = existingCdnIndex !== undefined ? cdnUrls[existingCdnIndex] : undefined
|
|
627
|
+
|
|
628
|
+
// Determine if this is our R2 or a remote CDN
|
|
629
|
+
const isInOurR2 = existingCdnUrl === publicUrl
|
|
630
|
+
const isRemote = existingCdnIndex !== undefined && !isInOurR2
|
|
631
|
+
|
|
632
|
+
sendEvent({
|
|
633
|
+
type: 'progress',
|
|
634
|
+
current: i + 1,
|
|
635
|
+
total,
|
|
636
|
+
percent: Math.round(((i + 1) / total) * 100),
|
|
637
|
+
currentFile: key.slice(1) // Remove leading /
|
|
638
|
+
})
|
|
639
|
+
|
|
640
|
+
try {
|
|
641
|
+
let buffer: Buffer
|
|
642
|
+
|
|
643
|
+
// Download from appropriate source
|
|
644
|
+
if (isInOurR2) {
|
|
645
|
+
buffer = await downloadFromCdn(key)
|
|
646
|
+
const dir = path.dirname(fullPath)
|
|
647
|
+
await fs.mkdir(dir, { recursive: true })
|
|
648
|
+
await fs.writeFile(fullPath, buffer)
|
|
649
|
+
} else if (isRemote && existingCdnUrl) {
|
|
650
|
+
const remoteUrl = `${existingCdnUrl}${key}`
|
|
651
|
+
buffer = await downloadFromRemoteUrl(remoteUrl)
|
|
652
|
+
const dir = path.dirname(fullPath)
|
|
653
|
+
await fs.mkdir(dir, { recursive: true })
|
|
654
|
+
await fs.writeFile(fullPath, buffer)
|
|
655
|
+
} else {
|
|
656
|
+
buffer = await fs.readFile(fullPath)
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const ext = path.extname(key).toLowerCase()
|
|
660
|
+
const isSvg = ext === '.svg'
|
|
661
|
+
|
|
662
|
+
if (isSvg) {
|
|
663
|
+
const imageDir = path.dirname(key.slice(1))
|
|
664
|
+
const imagesPath = getPublicPath('images', imageDir === '.' ? '' : imageDir)
|
|
665
|
+
await fs.mkdir(imagesPath, { recursive: true })
|
|
666
|
+
|
|
667
|
+
const fileName = path.basename(key)
|
|
668
|
+
const destPath = path.join(imagesPath, fileName)
|
|
669
|
+
await fs.writeFile(destPath, buffer)
|
|
670
|
+
|
|
671
|
+
meta[key] = {
|
|
672
|
+
...entry,
|
|
673
|
+
o: { w: 0, h: 0 },
|
|
674
|
+
b: '',
|
|
675
|
+
f: { w: 0, h: 0 }, // SVG has "full" to indicate processed
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// Remote images become local after processing
|
|
679
|
+
if (isRemote) {
|
|
680
|
+
delete (meta[key] as import('../types').MetaEntry).c
|
|
681
|
+
}
|
|
682
|
+
} else {
|
|
683
|
+
const processedEntry = await processImage(buffer, key)
|
|
684
|
+
meta[key] = {
|
|
685
|
+
...processedEntry,
|
|
686
|
+
...(isInOurR2 ? { c: existingCdnIndex } : {}),
|
|
687
|
+
}
|
|
688
|
+
// Remote images become local after processing (no c)
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// If image was in our R2, upload new thumbnails and clean up local files
|
|
692
|
+
if (isInOurR2) {
|
|
693
|
+
await uploadToCdn(key)
|
|
694
|
+
|
|
695
|
+
// Collect URLs to purge
|
|
696
|
+
for (const thumbPath of getAllThumbnailPaths(key)) {
|
|
697
|
+
urlsToPurge.push(`${publicUrl}${thumbPath}`)
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
await deleteLocalThumbnails(key)
|
|
701
|
+
// Delete local original
|
|
702
|
+
try { await fs.unlink(fullPath) } catch { /* ignore */ }
|
|
703
|
+
}
|
|
704
|
+
// Remote images stay local after processing (original + thumbnails)
|
|
705
|
+
|
|
706
|
+
processed.push(key.slice(1))
|
|
707
|
+
} catch (error) {
|
|
708
|
+
console.error(`Failed to process ${key}:`, error)
|
|
709
|
+
errors.push(key.slice(1))
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
sendEvent({ type: 'cleanup', message: 'Removing orphaned thumbnails...' })
|
|
714
|
+
|
|
715
|
+
// Build set of expected thumbnail paths
|
|
716
|
+
const trackedPaths = new Set<string>()
|
|
717
|
+
for (const [imageKey, entry] of getFileEntries(meta)) {
|
|
718
|
+
// Only track local thumbnails (not pushed to CDN)
|
|
719
|
+
if (entry.c === undefined) {
|
|
720
|
+
for (const thumbPath of getAllThumbnailPaths(imageKey)) {
|
|
721
|
+
trackedPaths.add(thumbPath)
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
async function findOrphans(dir: string, relativePath: string = ''): Promise<void> {
|
|
727
|
+
try {
|
|
728
|
+
const entries = await fs.readdir(dir, { withFileTypes: true })
|
|
729
|
+
|
|
730
|
+
for (const fsEntry of entries) {
|
|
731
|
+
if (fsEntry.name.startsWith('.')) continue
|
|
732
|
+
|
|
733
|
+
const entryFullPath = path.join(dir, fsEntry.name)
|
|
734
|
+
const relPath = relativePath ? `${relativePath}/${fsEntry.name}` : fsEntry.name
|
|
735
|
+
|
|
736
|
+
if (fsEntry.isDirectory()) {
|
|
737
|
+
await findOrphans(entryFullPath, relPath)
|
|
738
|
+
} else if (isImageFile(fsEntry.name)) {
|
|
739
|
+
const publicPath = `/images/${relPath}`
|
|
740
|
+
if (!trackedPaths.has(publicPath)) {
|
|
741
|
+
try {
|
|
742
|
+
await fs.unlink(entryFullPath)
|
|
743
|
+
orphansRemoved.push(publicPath)
|
|
744
|
+
} catch (err) {
|
|
745
|
+
console.error(`Failed to remove orphan ${publicPath}:`, err)
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
} catch {
|
|
751
|
+
// Directory might not exist
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const imagesDir = getPublicPath('images')
|
|
756
|
+
try {
|
|
757
|
+
await findOrphans(imagesDir)
|
|
758
|
+
} catch {
|
|
759
|
+
// images dir might not exist
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
async function removeEmptyDirs(dir: string): Promise<boolean> {
|
|
763
|
+
try {
|
|
764
|
+
const entries = await fs.readdir(dir, { withFileTypes: true })
|
|
765
|
+
let isEmpty = true
|
|
766
|
+
|
|
767
|
+
for (const fsEntry of entries) {
|
|
768
|
+
if (fsEntry.isDirectory()) {
|
|
769
|
+
const subDirEmpty = await removeEmptyDirs(path.join(dir, fsEntry.name))
|
|
770
|
+
if (!subDirEmpty) isEmpty = false
|
|
771
|
+
} else {
|
|
772
|
+
isEmpty = false
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
if (isEmpty && dir !== imagesDir) {
|
|
777
|
+
await fs.rmdir(dir)
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
return isEmpty
|
|
781
|
+
} catch {
|
|
782
|
+
return true
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
try {
|
|
787
|
+
await removeEmptyDirs(imagesDir)
|
|
788
|
+
} catch {
|
|
789
|
+
// images dir might not exist
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
await saveMeta(meta)
|
|
793
|
+
|
|
794
|
+
// Purge Cloudflare cache for re-uploaded thumbnails
|
|
795
|
+
if (urlsToPurge.length > 0) {
|
|
796
|
+
await purgeCloudflareCache(urlsToPurge)
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
sendEvent({
|
|
800
|
+
type: 'complete',
|
|
801
|
+
processed: processed.length,
|
|
802
|
+
alreadyProcessed,
|
|
803
|
+
orphansRemoved: orphansRemoved.length,
|
|
804
|
+
errors: errors.length,
|
|
805
|
+
})
|
|
806
|
+
} catch (error) {
|
|
807
|
+
console.error('Failed to process all:', error)
|
|
808
|
+
sendEvent({ type: 'error', message: 'Failed to process images' })
|
|
809
|
+
} finally {
|
|
810
|
+
controller.close()
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
})
|
|
814
|
+
|
|
815
|
+
return new Response(stream, {
|
|
816
|
+
headers: {
|
|
817
|
+
'Content-Type': 'text/event-stream',
|
|
818
|
+
'Cache-Control': 'no-cache',
|
|
819
|
+
'Connection': 'keep-alive',
|
|
820
|
+
},
|
|
821
|
+
})
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* Download images from R2 CDN to local storage (streaming)
|
|
826
|
+
* This removes the images from R2 and stores them locally
|
|
827
|
+
*/
|
|
828
|
+
export async function handleDownloadStream(request: NextRequest) {
|
|
829
|
+
const { imageKeys } = await request.json() as { imageKeys: string[] }
|
|
830
|
+
|
|
831
|
+
if (!imageKeys || !Array.isArray(imageKeys) || imageKeys.length === 0) {
|
|
832
|
+
return NextResponse.json({ error: 'No image keys provided' }, { status: 400 })
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
const stream = new ReadableStream({
|
|
836
|
+
async start(controller) {
|
|
837
|
+
const encoder = new TextEncoder()
|
|
838
|
+
const sendEvent = (data: Record<string, unknown>) => {
|
|
839
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`))
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
sendEvent({ type: 'start', total: imageKeys.length })
|
|
843
|
+
|
|
844
|
+
const downloaded: string[] = []
|
|
845
|
+
const skipped: string[] = []
|
|
846
|
+
const errors: string[] = []
|
|
847
|
+
|
|
848
|
+
try {
|
|
849
|
+
const meta = await loadMeta()
|
|
850
|
+
|
|
851
|
+
for (let i = 0; i < imageKeys.length; i++) {
|
|
852
|
+
const imageKey = imageKeys[i]
|
|
853
|
+
const entry = getMetaEntry(meta, imageKey)
|
|
854
|
+
|
|
855
|
+
if (!entry || entry.c === undefined) {
|
|
856
|
+
skipped.push(imageKey)
|
|
857
|
+
sendEvent({
|
|
858
|
+
type: 'progress',
|
|
859
|
+
current: i + 1,
|
|
860
|
+
total: imageKeys.length,
|
|
861
|
+
message: `Skipped ${imageKey} (not on cloud)`,
|
|
862
|
+
})
|
|
863
|
+
continue
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
try {
|
|
867
|
+
// Download original from R2
|
|
868
|
+
const imageBuffer = await downloadFromCdn(imageKey)
|
|
869
|
+
|
|
870
|
+
// Ensure directory exists
|
|
871
|
+
const localPath = getPublicPath(imageKey.replace(/^\//, ''))
|
|
872
|
+
await fs.mkdir(path.dirname(localPath), { recursive: true })
|
|
873
|
+
|
|
874
|
+
// Write to local filesystem
|
|
875
|
+
await fs.writeFile(localPath, imageBuffer)
|
|
876
|
+
|
|
877
|
+
// Delete thumbnails from R2
|
|
878
|
+
await deleteThumbnailsFromCdn(imageKey)
|
|
879
|
+
|
|
880
|
+
// Check if image was processed (has thumbnails)
|
|
881
|
+
const wasProcessed = isProcessed(entry)
|
|
882
|
+
|
|
883
|
+
// Remove the c property (no longer on CDN)
|
|
884
|
+
delete entry.c
|
|
885
|
+
|
|
886
|
+
// If it was processed, regenerate thumbnails locally
|
|
887
|
+
if (wasProcessed) {
|
|
888
|
+
const processedEntry = await processImage(imageBuffer, imageKey)
|
|
889
|
+
// Update dimensions in meta
|
|
890
|
+
entry.sm = processedEntry.sm
|
|
891
|
+
entry.md = processedEntry.md
|
|
892
|
+
entry.lg = processedEntry.lg
|
|
893
|
+
entry.f = processedEntry.f
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
downloaded.push(imageKey)
|
|
897
|
+
sendEvent({
|
|
898
|
+
type: 'progress',
|
|
899
|
+
current: i + 1,
|
|
900
|
+
total: imageKeys.length,
|
|
901
|
+
message: `Downloaded ${imageKey}`,
|
|
902
|
+
})
|
|
903
|
+
} catch (error) {
|
|
904
|
+
console.error(`Failed to download ${imageKey}:`, error)
|
|
905
|
+
errors.push(imageKey)
|
|
906
|
+
sendEvent({
|
|
907
|
+
type: 'progress',
|
|
908
|
+
current: i + 1,
|
|
909
|
+
total: imageKeys.length,
|
|
910
|
+
message: `Failed to download ${imageKey}`,
|
|
911
|
+
})
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
await saveMeta(meta)
|
|
916
|
+
|
|
917
|
+
// Build completion message
|
|
918
|
+
let message = `Downloaded ${downloaded.length} image${downloaded.length !== 1 ? 's' : ''}.`
|
|
919
|
+
if (skipped.length > 0) {
|
|
920
|
+
message += ` ${skipped.length} image${skipped.length !== 1 ? 's were' : ' was'} not on cloud.`
|
|
921
|
+
}
|
|
922
|
+
if (errors.length > 0) {
|
|
923
|
+
message += ` ${errors.length} image${errors.length !== 1 ? 's' : ''} failed.`
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
sendEvent({
|
|
927
|
+
type: 'complete',
|
|
928
|
+
downloaded: downloaded.length,
|
|
929
|
+
skipped: skipped.length,
|
|
930
|
+
errors: errors.length,
|
|
931
|
+
message,
|
|
932
|
+
})
|
|
933
|
+
} catch (error) {
|
|
934
|
+
console.error('Download stream error:', error)
|
|
935
|
+
sendEvent({ type: 'error', message: 'Failed to download images' })
|
|
936
|
+
} finally {
|
|
937
|
+
controller.close()
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
})
|
|
941
|
+
|
|
942
|
+
return new Response(stream, {
|
|
943
|
+
headers: {
|
|
944
|
+
'Content-Type': 'text/event-stream',
|
|
945
|
+
'Cache-Control': 'no-cache',
|
|
946
|
+
'Connection': 'keep-alive',
|
|
947
|
+
},
|
|
948
|
+
})
|
|
949
|
+
}
|