@gallop.software/studio 2.3.51 → 2.3.52

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.
@@ -1016,8 +1016,8 @@ async function handleFolderImages(request) {
1016
1016
  }
1017
1017
 
1018
1018
  // src/handlers/files.ts
1019
- import { promises as fs6 } from "fs";
1020
- import path6 from "path";
1019
+ import { promises as fs7 } from "fs";
1020
+ import path7 from "path";
1021
1021
  import sharp2 from "sharp";
1022
1022
 
1023
1023
  // src/handlers/utils/folders.ts
@@ -1087,324 +1087,288 @@ async function cleanupEmptyFoldersRecursive(dir) {
1087
1087
  }
1088
1088
  }
1089
1089
 
1090
- // src/handlers/files.ts
1091
- async function handleUpload(request) {
1090
+ // src/handlers/images.ts
1091
+ import { promises as fs6 } from "fs";
1092
+ import path6 from "path";
1093
+ import { S3Client as S3Client2, PutObjectCommand as PutObjectCommand2, DeleteObjectCommand as DeleteObjectCommand2 } from "@aws-sdk/client-s3";
1094
+ var cancelledOperations = /* @__PURE__ */ new Set();
1095
+ function cancelOperation(operationId) {
1096
+ cancelledOperations.add(operationId);
1097
+ setTimeout(() => cancelledOperations.delete(operationId), 6e4);
1098
+ }
1099
+ function isOperationCancelled(operationId) {
1100
+ return cancelledOperations.has(operationId);
1101
+ }
1102
+ function clearCancelledOperation(operationId) {
1103
+ cancelledOperations.delete(operationId);
1104
+ }
1105
+ async function handleSync(request) {
1106
+ const accountId = process.env.CLOUDFLARE_R2_ACCOUNT_ID;
1107
+ const accessKeyId = process.env.CLOUDFLARE_R2_ACCESS_KEY_ID;
1108
+ const secretAccessKey = process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY;
1109
+ const bucketName = process.env.CLOUDFLARE_R2_BUCKET_NAME;
1110
+ const publicUrl = process.env.CLOUDFLARE_R2_PUBLIC_URL?.replace(/\/\s*$/, "");
1111
+ if (!accountId || !accessKeyId || !secretAccessKey || !bucketName || !publicUrl) {
1112
+ return jsonResponse(
1113
+ { error: "R2 not configured. Set CLOUDFLARE_R2_* environment variables." },
1114
+ { status: 400 }
1115
+ );
1116
+ }
1092
1117
  try {
1093
- const formData = await request.formData();
1094
- const file = formData.get("file");
1095
- const targetPath = formData.get("path") || "public";
1096
- if (!file) {
1097
- return jsonResponse({ error: "No file provided" }, { status: 400 });
1118
+ const { imageKeys } = await request.json();
1119
+ if (!imageKeys || !Array.isArray(imageKeys) || imageKeys.length === 0) {
1120
+ return jsonResponse({ error: "No image keys provided" }, { status: 400 });
1098
1121
  }
1099
- const bytes = await file.arrayBuffer();
1100
- const buffer = Buffer.from(bytes);
1101
- const fileName = slugifyFilename(file.name);
1102
- const ext = path6.extname(fileName).toLowerCase();
1103
- const isImage = isImageFile(fileName);
1104
- const isMedia = isMediaFile(fileName);
1105
1122
  const meta = await loadMeta();
1106
- let relativeDir = "";
1107
- if (targetPath === "public") {
1108
- relativeDir = "";
1109
- } else if (targetPath.startsWith("public/")) {
1110
- relativeDir = targetPath.replace("public/", "");
1111
- }
1112
- if (relativeDir === "images" || relativeDir.startsWith("images/")) {
1113
- return jsonResponse(
1114
- { error: "Cannot upload to images/ folder. Upload to public/ instead - thumbnails are generated automatically." },
1115
- { status: 400 }
1116
- );
1117
- }
1118
- let imageKey = "/" + (relativeDir ? `${relativeDir}/${fileName}` : fileName);
1119
- if (meta[imageKey]) {
1120
- const baseName = path6.basename(fileName, ext);
1121
- let counter = 1;
1122
- let newFileName = `${baseName}-${counter}${ext}`;
1123
- let newKey = "/" + (relativeDir ? `${relativeDir}/${newFileName}` : newFileName);
1124
- while (meta[newKey]) {
1125
- counter++;
1126
- newFileName = `${baseName}-${counter}${ext}`;
1127
- newKey = "/" + (relativeDir ? `${relativeDir}/${newFileName}` : newFileName);
1123
+ const cdnUrls = getCdnUrls(meta);
1124
+ const cdnIndex = getOrAddCdnIndex(meta, publicUrl);
1125
+ const r2 = new S3Client2({
1126
+ region: "auto",
1127
+ endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
1128
+ credentials: { accessKeyId, secretAccessKey }
1129
+ });
1130
+ const pushed = [];
1131
+ const alreadyPushed = [];
1132
+ const errors = [];
1133
+ const sourceFolders = /* @__PURE__ */ new Set();
1134
+ for (let imageKey of imageKeys) {
1135
+ if (!imageKey.startsWith("/")) {
1136
+ imageKey = `/${imageKey}`;
1128
1137
  }
1129
- imageKey = newKey;
1130
- }
1131
- const actualFileName = path6.basename(imageKey);
1132
- const uploadDir = getPublicPath(relativeDir);
1133
- await fs6.mkdir(uploadDir, { recursive: true });
1134
- await fs6.writeFile(path6.join(uploadDir, actualFileName), buffer);
1135
- if (!isMedia) {
1136
- return jsonResponse({
1137
- success: true,
1138
- message: "File uploaded (not a media file)",
1139
- path: `public/${relativeDir ? relativeDir + "/" : ""}${actualFileName}`
1140
- });
1141
- }
1142
- if (isImage && ext !== ".svg") {
1138
+ const entry = getMetaEntry(meta, imageKey);
1139
+ if (!entry) {
1140
+ errors.push(`Image not found in meta: ${imageKey}. Run Scan first.`);
1141
+ continue;
1142
+ }
1143
+ const existingCdnUrl = entry.c !== void 0 ? cdnUrls[entry.c] : void 0;
1144
+ const isAlreadyInOurR2 = existingCdnUrl === publicUrl;
1145
+ if (isAlreadyInOurR2) {
1146
+ alreadyPushed.push(imageKey);
1147
+ continue;
1148
+ }
1149
+ const isRemote = entry.c !== void 0 && existingCdnUrl !== publicUrl;
1143
1150
  try {
1144
- const metadata = await sharp2(buffer).metadata();
1145
- meta[imageKey] = {
1146
- o: { w: metadata.width || 0, h: metadata.height || 0 }
1147
- };
1148
- } catch {
1149
- meta[imageKey] = { o: { w: 0, h: 0 } };
1151
+ let originalBuffer;
1152
+ if (isRemote) {
1153
+ const remoteUrl = `${existingCdnUrl}${imageKey}`;
1154
+ originalBuffer = await downloadFromRemoteUrl(remoteUrl);
1155
+ } else {
1156
+ const originalLocalPath = getPublicPath(imageKey);
1157
+ try {
1158
+ originalBuffer = await fs6.readFile(originalLocalPath);
1159
+ } catch {
1160
+ errors.push(`Original file not found: ${imageKey}`);
1161
+ continue;
1162
+ }
1163
+ }
1164
+ await r2.send(
1165
+ new PutObjectCommand2({
1166
+ Bucket: bucketName,
1167
+ Key: imageKey.replace(/^\//, ""),
1168
+ Body: originalBuffer,
1169
+ ContentType: getContentType(imageKey)
1170
+ })
1171
+ );
1172
+ if (!isRemote && isProcessed(entry)) {
1173
+ for (const thumbPath of getAllThumbnailPaths(imageKey)) {
1174
+ const localPath = getPublicPath(thumbPath);
1175
+ try {
1176
+ const fileBuffer = await fs6.readFile(localPath);
1177
+ await r2.send(
1178
+ new PutObjectCommand2({
1179
+ Bucket: bucketName,
1180
+ Key: thumbPath.replace(/^\//, ""),
1181
+ Body: fileBuffer,
1182
+ ContentType: getContentType(thumbPath)
1183
+ })
1184
+ );
1185
+ } catch {
1186
+ }
1187
+ }
1188
+ }
1189
+ entry.c = cdnIndex;
1190
+ if (!isRemote) {
1191
+ const originalLocalPath = getPublicPath(imageKey);
1192
+ sourceFolders.add(path6.dirname(originalLocalPath));
1193
+ for (const thumbPath of getAllThumbnailPaths(imageKey)) {
1194
+ const localPath = getPublicPath(thumbPath);
1195
+ sourceFolders.add(path6.dirname(localPath));
1196
+ try {
1197
+ await fs6.unlink(localPath);
1198
+ } catch {
1199
+ }
1200
+ }
1201
+ try {
1202
+ await fs6.unlink(originalLocalPath);
1203
+ } catch {
1204
+ }
1205
+ }
1206
+ pushed.push(imageKey);
1207
+ } catch (error) {
1208
+ console.error(`Failed to push ${imageKey}:`, error);
1209
+ errors.push(`Failed to push: ${imageKey}`);
1150
1210
  }
1151
- } else {
1152
- meta[imageKey] = {};
1153
1211
  }
1154
1212
  await saveMeta(meta);
1213
+ for (const folder of sourceFolders) {
1214
+ await deleteEmptyFolders(folder);
1215
+ }
1155
1216
  return jsonResponse({
1156
1217
  success: true,
1157
- imageKey,
1158
- message: 'File uploaded. Run "Process Images" to generate thumbnails.'
1218
+ pushed,
1219
+ alreadyPushed: alreadyPushed.length > 0 ? alreadyPushed : void 0,
1220
+ errors: errors.length > 0 ? errors : void 0
1159
1221
  });
1160
1222
  } catch (error) {
1161
- console.error("Failed to upload:", error);
1162
- const message = error instanceof Error ? error.message : "Unknown error";
1163
- return jsonResponse({ error: `Failed to upload file: ${message}` }, { status: 500 });
1223
+ console.error("Failed to push:", error);
1224
+ return jsonResponse({ error: "Failed to push to CDN" }, { status: 500 });
1164
1225
  }
1165
1226
  }
1166
- async function handleDelete(request) {
1227
+ async function handleUnprocessStream(request) {
1228
+ const publicUrl = process.env.CLOUDFLARE_R2_PUBLIC_URL?.replace(/\/\s*$/, "");
1229
+ const encoder = new TextEncoder();
1230
+ let imageKeys;
1167
1231
  try {
1168
- const { paths } = await request.json();
1169
- if (!paths || !Array.isArray(paths) || paths.length === 0) {
1170
- return jsonResponse({ error: "No paths provided" }, { status: 400 });
1232
+ const body = await request.json();
1233
+ imageKeys = body.imageKeys;
1234
+ if (!imageKeys || !Array.isArray(imageKeys) || imageKeys.length === 0) {
1235
+ return jsonResponse({ error: "No image keys provided" }, { status: 400 });
1171
1236
  }
1172
- const meta = await loadMeta();
1173
- const deleted = [];
1174
- const errors = [];
1175
- const sourceFolders = /* @__PURE__ */ new Set();
1176
- for (const itemPath of paths) {
1237
+ } catch {
1238
+ return jsonResponse({ error: "Invalid request body" }, { status: 400 });
1239
+ }
1240
+ const stream = new ReadableStream({
1241
+ async start(controller) {
1242
+ const sendEvent = (data) => {
1243
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}
1244
+
1245
+ `));
1246
+ };
1177
1247
  try {
1178
- if (!itemPath.startsWith("public/")) {
1179
- errors.push(`Invalid path: ${itemPath}`);
1180
- continue;
1181
- }
1182
- const absolutePath = getWorkspacePath(itemPath);
1183
- const imageKey = "/" + itemPath.replace(/^public\//, "");
1184
- sourceFolders.add(path6.dirname(absolutePath));
1185
- const entry = meta[imageKey];
1186
- const isPushedToCloud = entry?.c !== void 0;
1187
- const hasThumbnails = entry ? isProcessed(entry) : false;
1188
- try {
1189
- const stats = await fs6.stat(absolutePath);
1190
- if (stats.isDirectory()) {
1191
- await fs6.rm(absolutePath, { recursive: true });
1192
- const prefix = imageKey + "/";
1193
- for (const key of Object.keys(meta)) {
1194
- if (key.startsWith(prefix) || key === imageKey) {
1195
- const keyEntry = meta[key];
1196
- const keyHasThumbnails = keyEntry ? isProcessed(keyEntry) : false;
1197
- if (keyEntry?.c !== void 0) {
1198
- try {
1199
- await deleteFromCdn(key, keyHasThumbnails);
1200
- } catch {
1201
- }
1202
- } else {
1203
- for (const thumbPath of getAllThumbnailPaths(key)) {
1204
- const absoluteThumbPath = getPublicPath(thumbPath);
1205
- try {
1206
- await fs6.unlink(absoluteThumbPath);
1207
- } catch {
1208
- }
1209
- }
1210
- }
1211
- delete meta[key];
1212
- }
1213
- }
1214
- } else {
1215
- await fs6.unlink(absolutePath);
1216
- const isInImagesFolder = itemPath.startsWith("public/images/");
1217
- if (!isInImagesFolder && entry) {
1218
- if (isPushedToCloud) {
1219
- try {
1220
- await deleteFromCdn(imageKey, hasThumbnails);
1221
- } catch {
1222
- }
1223
- } else {
1224
- for (const thumbPath of getAllThumbnailPaths(imageKey)) {
1225
- const absoluteThumbPath = getPublicPath(thumbPath);
1226
- try {
1227
- await fs6.unlink(absoluteThumbPath);
1228
- } catch {
1229
- }
1230
- }
1231
- }
1232
- delete meta[imageKey];
1233
- }
1234
- }
1235
- } catch {
1236
- if (entry) {
1237
- if (isPushedToCloud) {
1238
- try {
1239
- await deleteFromCdn(imageKey, hasThumbnails);
1240
- } catch {
1241
- }
1242
- }
1243
- delete meta[imageKey];
1244
- } else {
1245
- const prefix = imageKey + "/";
1246
- let foundAny = false;
1247
- for (const key of Object.keys(meta)) {
1248
- if (key.startsWith(prefix)) {
1249
- const keyEntry = meta[key];
1250
- const keyHasThumbnails = keyEntry ? isProcessed(keyEntry) : false;
1251
- if (keyEntry?.c !== void 0) {
1252
- try {
1253
- await deleteFromCdn(key, keyHasThumbnails);
1254
- } catch {
1255
- }
1256
- }
1257
- delete meta[key];
1258
- foundAny = true;
1259
- }
1248
+ const meta = await loadMeta();
1249
+ const cdnUrls = getCdnUrls(meta);
1250
+ const removed = [];
1251
+ const skipped = [];
1252
+ const errors = [];
1253
+ const total = imageKeys.length;
1254
+ sendEvent({ type: "start", total });
1255
+ for (let i = 0; i < imageKeys.length; i++) {
1256
+ let imageKey = imageKeys[i];
1257
+ if (!imageKey.startsWith("/")) {
1258
+ imageKey = `/${imageKey}`;
1259
+ }
1260
+ try {
1261
+ const entry = getMetaEntry(meta, imageKey);
1262
+ if (!entry) {
1263
+ errors.push(imageKey);
1264
+ sendEvent({
1265
+ type: "progress",
1266
+ current: i + 1,
1267
+ total,
1268
+ processed: removed.length,
1269
+ percent: Math.round((i + 1) / total * 100),
1270
+ message: `Error: ${imageKey.slice(1)}`
1271
+ });
1272
+ continue;
1260
1273
  }
1261
- if (!foundAny) {
1262
- errors.push(`Not found: ${itemPath}`);
1274
+ const hasThumbnails = entry.sm || entry.md || entry.lg || entry.f;
1275
+ if (!hasThumbnails) {
1276
+ skipped.push(imageKey);
1277
+ sendEvent({
1278
+ type: "progress",
1279
+ current: i + 1,
1280
+ total,
1281
+ processed: removed.length,
1282
+ percent: Math.round((i + 1) / total * 100),
1283
+ message: `Skipped ${imageKey.slice(1)} (no thumbnails)`
1284
+ });
1263
1285
  continue;
1264
1286
  }
1287
+ const existingCdnIndex = entry.c;
1288
+ const existingCdnUrl = existingCdnIndex !== void 0 ? cdnUrls[existingCdnIndex] : void 0;
1289
+ const isInOurR2 = existingCdnUrl === publicUrl;
1290
+ await deleteLocalThumbnails(imageKey);
1291
+ if (isInOurR2) {
1292
+ await deleteThumbnailsFromCdn(imageKey);
1293
+ }
1294
+ meta[imageKey] = {
1295
+ o: entry.o,
1296
+ b: entry.b,
1297
+ ...entry.c !== void 0 ? { c: entry.c } : {}
1298
+ };
1299
+ removed.push(imageKey);
1300
+ sendEvent({
1301
+ type: "progress",
1302
+ current: i + 1,
1303
+ total,
1304
+ processed: removed.length,
1305
+ percent: Math.round((i + 1) / total * 100),
1306
+ message: `Removed thumbnails for ${imageKey.slice(1)}`
1307
+ });
1308
+ } catch (error) {
1309
+ console.error(`Failed to unprocess ${imageKey}:`, error);
1310
+ errors.push(imageKey);
1311
+ sendEvent({
1312
+ type: "progress",
1313
+ current: i + 1,
1314
+ total,
1315
+ processed: removed.length,
1316
+ percent: Math.round((i + 1) / total * 100),
1317
+ message: `Failed: ${imageKey.slice(1)}`
1318
+ });
1265
1319
  }
1266
1320
  }
1267
- deleted.push(itemPath);
1268
- } catch (error) {
1269
- console.error(`Failed to delete ${itemPath}:`, error);
1270
- errors.push(itemPath);
1271
- }
1272
- }
1273
- await saveMeta(meta);
1274
- for (const folder of sourceFolders) {
1275
- await deleteEmptyFolders(folder);
1276
- }
1277
- return jsonResponse({
1278
- success: true,
1279
- deleted,
1280
- errors: errors.length > 0 ? errors : void 0
1281
- });
1282
- } catch (error) {
1283
- console.error("Failed to delete:", error);
1284
- return jsonResponse({ error: "Failed to delete files" }, { status: 500 });
1285
- }
1286
- }
1287
- async function handleCreateFolder(request) {
1288
- try {
1289
- const { parentPath, name } = await request.json();
1290
- if (!name || typeof name !== "string") {
1291
- return jsonResponse({ error: "Folder name is required" }, { status: 400 });
1292
- }
1293
- const sanitizedName = slugifyFolderName(name);
1294
- if (!sanitizedName) {
1295
- return jsonResponse({ error: "Invalid folder name" }, { status: 400 });
1296
- }
1297
- const safePath = (parentPath || "public").replace(/\.\./g, "");
1298
- const folderPath = getWorkspacePath(safePath, sanitizedName);
1299
- if (!folderPath.startsWith(getPublicPath())) {
1300
- return jsonResponse({ error: "Invalid path" }, { status: 400 });
1301
- }
1302
- try {
1303
- await fs6.access(folderPath);
1304
- return jsonResponse({ error: "A folder with this name already exists" }, { status: 400 });
1305
- } catch {
1306
- }
1307
- await fs6.mkdir(folderPath, { recursive: true });
1308
- return jsonResponse({ success: true, path: path6.join(safePath, sanitizedName) });
1309
- } catch (error) {
1310
- console.error("Failed to create folder:", error);
1311
- return jsonResponse({ error: "Failed to create folder" }, { status: 500 });
1312
- }
1313
- }
1314
- async function handleRename(request) {
1315
- const publicUrl = process.env.CLOUDFLARE_R2_PUBLIC_URL?.replace(/\/$/, "");
1316
- try {
1317
- const { oldPath, newName } = await request.json();
1318
- if (!oldPath || !newName) {
1319
- return jsonResponse({ error: "Path and new name are required" }, { status: 400 });
1320
- }
1321
- const safePath = oldPath.replace(/\.\./g, "");
1322
- const absoluteOldPath = getWorkspacePath(safePath);
1323
- if (!absoluteOldPath.startsWith(getPublicPath())) {
1324
- return jsonResponse({ error: "Invalid path" }, { status: 400 });
1325
- }
1326
- const oldRelativePath = safePath.replace(/^public\//, "");
1327
- const oldKey = "/" + oldRelativePath;
1328
- const isImage = isImageFile(path6.basename(oldPath));
1329
- const meta = await loadMeta();
1330
- const cdnUrls = getCdnUrls(meta);
1331
- const entry = meta[oldKey];
1332
- const isInCloud = entry?.c !== void 0;
1333
- const fileCdnUrl = isInCloud && entry.c !== void 0 ? cdnUrls[entry.c] : void 0;
1334
- const isInOurR2 = isInCloud && fileCdnUrl === publicUrl;
1335
- const hasThumbnails = entry ? isProcessed(entry) : false;
1336
- let hasLocalFile = false;
1337
- let isFile = true;
1338
- try {
1339
- const stats = await fs6.stat(absoluteOldPath);
1340
- hasLocalFile = true;
1341
- isFile = stats.isFile();
1342
- } catch {
1343
- if (!isInCloud) {
1344
- return jsonResponse({ error: "File or folder not found" }, { status: 404 });
1345
- }
1346
- }
1347
- const sanitizedName = isFile ? slugifyFilename(newName) : slugifyFolderName(newName);
1348
- if (!sanitizedName) {
1349
- return jsonResponse({ error: "Invalid name" }, { status: 400 });
1350
- }
1351
- const parentDir = path6.dirname(absoluteOldPath);
1352
- const absoluteNewPath = path6.join(parentDir, sanitizedName);
1353
- const newRelativePath = path6.join(path6.dirname(oldRelativePath), sanitizedName);
1354
- const newKey = "/" + newRelativePath;
1355
- if (meta[newKey]) {
1356
- return jsonResponse({ error: "An item with this name already exists" }, { status: 400 });
1357
- }
1358
- try {
1359
- await fs6.access(absoluteNewPath);
1360
- return jsonResponse({ error: "An item with this name already exists" }, { status: 400 });
1361
- } catch {
1362
- }
1363
- if (isInOurR2 && !hasLocalFile && isImage) {
1364
- await moveInCdn(oldKey, newKey, hasThumbnails);
1365
- delete meta[oldKey];
1366
- meta[newKey] = entry;
1367
- await saveMeta(meta);
1368
- const newPath2 = path6.join(path6.dirname(safePath), sanitizedName);
1369
- return jsonResponse({ success: true, newPath: newPath2 });
1370
- }
1371
- if (hasLocalFile) {
1372
- await fs6.rename(absoluteOldPath, absoluteNewPath);
1373
- }
1374
- if (isImage && entry) {
1375
- const oldThumbPaths = getAllThumbnailPaths(oldKey);
1376
- const newThumbPaths = getAllThumbnailPaths(newKey);
1377
- for (let i = 0; i < oldThumbPaths.length; i++) {
1378
- const oldThumbPath = getPublicPath(oldThumbPaths[i]);
1379
- const newThumbPath = getPublicPath(newThumbPaths[i]);
1380
- await fs6.mkdir(path6.dirname(newThumbPath), { recursive: true });
1321
+ sendEvent({ type: "cleanup", message: "Saving metadata..." });
1322
+ await saveMeta(meta);
1323
+ sendEvent({ type: "cleanup", message: "Cleaning up empty folders..." });
1324
+ const imagesDir = getPublicPath("images");
1381
1325
  try {
1382
- await fs6.rename(oldThumbPath, newThumbPath);
1326
+ await cleanupEmptyFoldersRecursive(imagesDir);
1383
1327
  } catch {
1384
1328
  }
1385
- }
1386
- if (isInOurR2) {
1387
- await moveInCdn(oldKey, newKey, hasThumbnails);
1388
- try {
1389
- await fs6.unlink(absoluteNewPath);
1390
- } catch {
1329
+ let message = `Removed thumbnails from ${removed.length} image${removed.length !== 1 ? "s" : ""}.`;
1330
+ if (skipped.length > 0) {
1331
+ message += ` ${skipped.length} image${skipped.length !== 1 ? "s" : ""} had no thumbnails.`;
1391
1332
  }
1392
- await deleteLocalThumbnails(newKey);
1333
+ if (errors.length > 0) {
1334
+ message += ` ${errors.length} image${errors.length !== 1 ? "s" : ""} failed.`;
1335
+ }
1336
+ sendEvent({
1337
+ type: "complete",
1338
+ processed: removed.length,
1339
+ skipped: skipped.length,
1340
+ errors: errors.length,
1341
+ message
1342
+ });
1343
+ controller.close();
1344
+ } catch (error) {
1345
+ console.error("Unprocess stream error:", error);
1346
+ sendEvent({ type: "error", message: "Failed to remove thumbnails" });
1347
+ controller.close();
1393
1348
  }
1394
- delete meta[oldKey];
1395
- meta[newKey] = entry;
1396
- await saveMeta(meta);
1397
1349
  }
1398
- const newPath = path6.join(path6.dirname(safePath), sanitizedName);
1399
- return jsonResponse({ success: true, newPath });
1400
- } catch (error) {
1401
- console.error("Failed to rename:", error);
1402
- return jsonResponse({ error: "Failed to rename" }, { status: 500 });
1403
- }
1350
+ });
1351
+ return new Response(stream, {
1352
+ headers: {
1353
+ "Content-Type": "text/event-stream",
1354
+ "Cache-Control": "no-cache",
1355
+ Connection: "keep-alive"
1356
+ }
1357
+ });
1404
1358
  }
1405
- async function handleRenameStream(request) {
1359
+ async function handleReprocessStream(request) {
1360
+ const publicUrl = process.env.CLOUDFLARE_R2_PUBLIC_URL?.replace(/\/\s*$/, "");
1406
1361
  const encoder = new TextEncoder();
1407
- const publicUrl = process.env.CLOUDFLARE_R2_PUBLIC_URL?.replace(/\/$/, "");
1362
+ let imageKeys;
1363
+ try {
1364
+ const body = await request.json();
1365
+ imageKeys = body.imageKeys;
1366
+ if (!imageKeys || !Array.isArray(imageKeys) || imageKeys.length === 0) {
1367
+ return jsonResponse({ error: "No image keys provided" }, { status: 400 });
1368
+ }
1369
+ } catch {
1370
+ return jsonResponse({ error: "Invalid request body" }, { status: 400 });
1371
+ }
1408
1372
  const stream = new ReadableStream({
1409
1373
  async start(controller) {
1410
1374
  const sendEvent = (data) => {
@@ -1413,642 +1377,239 @@ async function handleRenameStream(request) {
1413
1377
  `));
1414
1378
  };
1415
1379
  try {
1416
- const { oldPath, newName } = await request.json();
1417
- if (!oldPath || !newName) {
1418
- sendEvent({ type: "error", message: "Path and new name are required" });
1419
- controller.close();
1420
- return;
1421
- }
1422
- const safePath = oldPath.replace(/\.\./g, "");
1423
- const absoluteOldPath = getWorkspacePath(safePath);
1424
- if (!absoluteOldPath.startsWith(getPublicPath())) {
1425
- sendEvent({ type: "error", message: "Invalid path" });
1426
- controller.close();
1427
- return;
1428
- }
1429
- const oldRelativePath = safePath.replace(/^public\//, "");
1430
- const isImagePath = isImageFile(path6.basename(oldPath));
1431
- let hasLocalItem = false;
1432
- let isFile = true;
1433
- let isVirtualFolder = false;
1434
- try {
1435
- const stats = await fs6.stat(absoluteOldPath);
1436
- hasLocalItem = true;
1437
- isFile = stats.isFile();
1438
- } catch {
1439
- const meta2 = await loadMeta();
1440
- const oldKey2 = "/" + oldRelativePath;
1441
- const entry2 = meta2[oldKey2];
1442
- if (entry2) {
1443
- isFile = true;
1444
- } else {
1445
- const folderPrefix = oldKey2 + "/";
1446
- const hasChildrenInMeta = Object.keys(meta2).some((key) => key.startsWith(folderPrefix));
1447
- if (hasChildrenInMeta) {
1448
- isFile = false;
1449
- isVirtualFolder = true;
1450
- } else {
1451
- sendEvent({ type: "error", message: "File or folder not found" });
1452
- controller.close();
1453
- return;
1454
- }
1455
- }
1456
- }
1457
- const sanitizedName = isFile ? slugifyFilename(newName) : slugifyFolderName(newName);
1458
- if (!sanitizedName) {
1459
- sendEvent({ type: "error", message: "Invalid name" });
1460
- controller.close();
1461
- return;
1462
- }
1463
- const parentDir = path6.dirname(absoluteOldPath);
1464
- const absoluteNewPath = path6.join(parentDir, sanitizedName);
1465
- const newRelativePath = path6.join(path6.dirname(oldRelativePath), sanitizedName);
1466
- const newPath = path6.join(path6.dirname(safePath), sanitizedName);
1467
1380
  const meta = await loadMeta();
1468
1381
  const cdnUrls = getCdnUrls(meta);
1469
- if (isFile) {
1470
- const newKey2 = "/" + newRelativePath;
1471
- if (meta[newKey2]) {
1472
- sendEvent({ type: "error", message: "An item with this name already exists" });
1473
- controller.close();
1474
- return;
1382
+ const processed = [];
1383
+ const errors = [];
1384
+ const total = imageKeys.length;
1385
+ sendEvent({ type: "start", total });
1386
+ for (let i = 0; i < imageKeys.length; i++) {
1387
+ let imageKey = imageKeys[i];
1388
+ if (!imageKey.startsWith("/")) {
1389
+ imageKey = `/${imageKey}`;
1475
1390
  }
1476
- }
1477
- if (!isVirtualFolder) {
1478
1391
  try {
1479
- await fs6.access(absoluteNewPath);
1480
- sendEvent({ type: "error", message: "An item with this name already exists" });
1481
- controller.close();
1482
- return;
1483
- } catch {
1484
- }
1485
- }
1486
- if (isVirtualFolder) {
1487
- const newPrefix = "/" + newRelativePath + "/";
1488
- const hasConflict = Object.keys(meta).some((key) => key.startsWith(newPrefix));
1489
- if (hasConflict) {
1490
- sendEvent({ type: "error", message: "A folder with this name already exists" });
1491
- controller.close();
1492
- return;
1493
- }
1494
- }
1495
- if (!isFile) {
1496
- const oldPrefix = "/" + oldRelativePath + "/";
1497
- const newPrefix = "/" + newRelativePath + "/";
1498
- const itemsToUpdate = [];
1499
- for (const [key, entry2] of Object.entries(meta)) {
1500
- if (key.startsWith(oldPrefix) && entry2 && typeof entry2 === "object") {
1501
- const newKey2 = key.replace(oldPrefix, newPrefix);
1502
- itemsToUpdate.push({ oldKey: key, newKey: newKey2, entry: entry2 });
1503
- }
1504
- }
1505
- const total = itemsToUpdate.length + 1;
1506
- sendEvent({ type: "start", total, message: `Renaming folder with ${itemsToUpdate.length} item(s)...` });
1507
- if (hasLocalItem) {
1508
- await fs6.rename(absoluteOldPath, absoluteNewPath);
1509
- const imagesDir = getPublicPath("/images");
1510
- const oldThumbFolder2 = path6.join(imagesDir, oldRelativePath);
1511
- const newThumbFolder = path6.join(imagesDir, newRelativePath);
1392
+ let buffer;
1393
+ const entry = getMetaEntry(meta, imageKey);
1394
+ const existingCdnIndex = entry?.c;
1395
+ const existingCdnUrl = existingCdnIndex !== void 0 ? cdnUrls[existingCdnIndex] : void 0;
1396
+ const isInOurR2 = existingCdnUrl === publicUrl;
1397
+ const isRemote = existingCdnIndex !== void 0 && !isInOurR2;
1398
+ const originalPath = getPublicPath(imageKey);
1512
1399
  try {
1513
- await fs6.access(oldThumbFolder2);
1514
- await fs6.mkdir(path6.dirname(newThumbFolder), { recursive: true });
1515
- await fs6.rename(oldThumbFolder2, newThumbFolder);
1400
+ buffer = await fs6.readFile(originalPath);
1516
1401
  } catch {
1402
+ if (isInOurR2) {
1403
+ buffer = await downloadFromCdn(imageKey);
1404
+ const dir = path6.dirname(originalPath);
1405
+ await fs6.mkdir(dir, { recursive: true });
1406
+ await fs6.writeFile(originalPath, buffer);
1407
+ } else if (isRemote && existingCdnUrl) {
1408
+ const remoteUrl = `${existingCdnUrl}${imageKey}`;
1409
+ buffer = await downloadFromRemoteUrl(remoteUrl);
1410
+ const dir = path6.dirname(originalPath);
1411
+ await fs6.mkdir(dir, { recursive: true });
1412
+ await fs6.writeFile(originalPath, buffer);
1413
+ } else {
1414
+ throw new Error(`File not found: ${imageKey}`);
1415
+ }
1517
1416
  }
1518
- }
1519
- sendEvent({ type: "progress", current: 1, total, renamed: 1, message: "Renamed folder" });
1520
- let renamed = 1;
1521
- for (const item of itemsToUpdate) {
1522
- const { oldKey: oldKey2, newKey: newKey2, entry: entry2 } = item;
1523
- const isInCloud2 = entry2.c !== void 0;
1524
- const fileCdnUrl2 = isInCloud2 ? cdnUrls[entry2.c] : void 0;
1525
- const isInOurR22 = isInCloud2 && fileCdnUrl2 === publicUrl;
1526
- const hasThumbnails2 = isProcessed(entry2);
1527
- if (isInOurR22) {
1528
- try {
1529
- await moveInCdn(oldKey2, newKey2, hasThumbnails2);
1530
- const localFilePath = getPublicPath(newKey2);
1417
+ const ext = path6.extname(imageKey).toLowerCase();
1418
+ const isSvg = ext === ".svg";
1419
+ if (isSvg) {
1420
+ const imageDir = path6.dirname(imageKey.slice(1));
1421
+ const imagesPath = getPublicPath("images", imageDir === "." ? "" : imageDir);
1422
+ await fs6.mkdir(imagesPath, { recursive: true });
1423
+ const fileName = path6.basename(imageKey);
1424
+ const destPath = path6.join(imagesPath, fileName);
1425
+ await fs6.writeFile(destPath, buffer);
1426
+ meta[imageKey] = {
1427
+ ...entry,
1428
+ o: { w: 0, h: 0 },
1429
+ b: "",
1430
+ f: { w: 0, h: 0 }
1431
+ };
1432
+ if (isRemote) {
1433
+ delete meta[imageKey].c;
1434
+ }
1435
+ } else {
1436
+ const updatedEntry = await processImage(buffer, imageKey);
1437
+ if (isInOurR2) {
1438
+ updatedEntry.c = existingCdnIndex;
1439
+ await deleteOriginalFromCdn(imageKey);
1440
+ await deleteThumbnailsFromCdn(imageKey);
1441
+ await uploadOriginalToCdn(imageKey);
1442
+ await uploadToCdn(imageKey);
1443
+ await deleteLocalThumbnails(imageKey);
1531
1444
  try {
1532
- await fs6.unlink(localFilePath);
1445
+ await fs6.unlink(originalPath);
1533
1446
  } catch {
1534
1447
  }
1535
- if (hasThumbnails2) {
1536
- await deleteLocalThumbnails(newKey2);
1537
- }
1538
- } catch (err) {
1539
- console.error(`Failed to rename in CDN ${oldKey2}:`, err);
1540
1448
  }
1449
+ meta[imageKey] = updatedEntry;
1541
1450
  }
1542
- delete meta[oldKey2];
1543
- meta[newKey2] = entry2;
1544
- renamed++;
1451
+ processed.push(imageKey);
1545
1452
  sendEvent({
1546
1453
  type: "progress",
1547
- current: renamed,
1454
+ current: i + 1,
1548
1455
  total,
1549
- renamed,
1550
- message: `Renamed ${path6.basename(newKey2)}`
1456
+ processed: processed.length,
1457
+ percent: Math.round((i + 1) / total * 100),
1458
+ message: `Processed ${imageKey.slice(1)}`
1459
+ });
1460
+ } catch (error) {
1461
+ console.error(`Failed to reprocess ${imageKey}:`, error);
1462
+ errors.push(imageKey);
1463
+ sendEvent({
1464
+ type: "progress",
1465
+ current: i + 1,
1466
+ total,
1467
+ processed: processed.length,
1468
+ percent: Math.round((i + 1) / total * 100),
1469
+ message: `Failed: ${imageKey.slice(1)}`
1551
1470
  });
1552
1471
  }
1553
- await saveMeta(meta);
1554
- await deleteEmptyFolders(absoluteOldPath);
1555
- const oldThumbFolder = path6.join(getPublicPath("/images"), oldRelativePath);
1556
- await deleteEmptyFolders(oldThumbFolder);
1557
- sendEvent({ type: "complete", renamed, newPath });
1558
- controller.close();
1559
- return;
1560
- }
1561
- const oldKey = "/" + oldRelativePath;
1562
- const newKey = "/" + newRelativePath;
1563
- const entry = meta[oldKey];
1564
- const isInCloud = entry?.c !== void 0;
1565
- const fileCdnUrl = isInCloud && entry?.c !== void 0 ? cdnUrls[entry.c] : void 0;
1566
- const isInOurR2 = isInCloud && fileCdnUrl === publicUrl;
1567
- const hasThumbnails = entry ? isProcessed(entry) : false;
1568
- sendEvent({ type: "start", total: 1, message: "Renaming file..." });
1569
- if (isInOurR2 && !hasLocalItem && isImagePath) {
1570
- await moveInCdn(oldKey, newKey, hasThumbnails);
1571
- delete meta[oldKey];
1572
- if (entry) meta[newKey] = entry;
1573
- await saveMeta(meta);
1574
- sendEvent({ type: "complete", renamed: 1, newPath });
1575
- controller.close();
1576
- return;
1577
1472
  }
1578
- if (hasLocalItem) {
1579
- await fs6.rename(absoluteOldPath, absoluteNewPath);
1580
- }
1581
- if (isImagePath && entry) {
1582
- const oldThumbPaths = getAllThumbnailPaths(oldKey);
1583
- const newThumbPaths = getAllThumbnailPaths(newKey);
1584
- for (let i = 0; i < oldThumbPaths.length; i++) {
1585
- const oldThumbPath = getPublicPath(oldThumbPaths[i]);
1586
- const newThumbPath = getPublicPath(newThumbPaths[i]);
1587
- await fs6.mkdir(path6.dirname(newThumbPath), { recursive: true });
1588
- try {
1589
- await fs6.rename(oldThumbPath, newThumbPath);
1590
- } catch {
1591
- }
1592
- }
1593
- if (isInOurR2) {
1594
- await moveInCdn(oldKey, newKey, hasThumbnails);
1595
- try {
1596
- await fs6.unlink(absoluteNewPath);
1597
- } catch {
1598
- }
1599
- await deleteLocalThumbnails(newKey);
1600
- }
1601
- delete meta[oldKey];
1602
- meta[newKey] = entry;
1603
- await saveMeta(meta);
1473
+ sendEvent({ type: "cleanup", message: "Saving metadata..." });
1474
+ await saveMeta(meta);
1475
+ let message = `Generated thumbnails for ${processed.length} image${processed.length !== 1 ? "s" : ""}.`;
1476
+ if (errors.length > 0) {
1477
+ message += ` ${errors.length} image${errors.length !== 1 ? "s" : ""} failed.`;
1604
1478
  }
1605
- sendEvent({ type: "complete", renamed: 1, newPath });
1479
+ sendEvent({
1480
+ type: "complete",
1481
+ processed: processed.length,
1482
+ errors: errors.length,
1483
+ message
1484
+ });
1606
1485
  controller.close();
1607
1486
  } catch (error) {
1608
- console.error("Rename stream error:", error);
1609
- sendEvent({ type: "error", message: "Failed to rename" });
1487
+ console.error("Reprocess stream error:", error);
1488
+ sendEvent({ type: "error", message: "Failed to generate thumbnails" });
1610
1489
  controller.close();
1611
1490
  }
1612
1491
  }
1613
1492
  });
1614
- return streamResponse(stream);
1493
+ return new Response(stream, {
1494
+ headers: {
1495
+ "Content-Type": "text/event-stream",
1496
+ "Cache-Control": "no-cache",
1497
+ Connection: "keep-alive"
1498
+ }
1499
+ });
1615
1500
  }
1616
- async function handleMoveStream(request) {
1617
- const encoder = new TextEncoder();
1501
+ async function handleDownloadStream(request) {
1502
+ const { imageKeys, operationId } = await request.json();
1503
+ if (!imageKeys || !Array.isArray(imageKeys) || imageKeys.length === 0) {
1504
+ return jsonResponse({ error: "No image keys provided" }, { status: 400 });
1505
+ }
1618
1506
  const stream = new ReadableStream({
1619
1507
  async start(controller) {
1508
+ const encoder = new TextEncoder();
1620
1509
  const sendEvent = (data) => {
1621
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}
1510
+ try {
1511
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}
1622
1512
 
1623
1513
  `));
1514
+ } catch {
1515
+ }
1624
1516
  };
1517
+ sendEvent({ type: "start", total: imageKeys.length });
1518
+ const downloaded = [];
1519
+ const skipped = [];
1520
+ const errors = [];
1521
+ const isCancelled = () => operationId ? isOperationCancelled(operationId) : false;
1625
1522
  try {
1626
- const { paths, destination } = await request.json();
1627
- if (!paths || !Array.isArray(paths) || paths.length === 0) {
1628
- sendEvent({ type: "error", message: "Paths are required" });
1629
- controller.close();
1630
- return;
1631
- }
1632
- if (!destination || typeof destination !== "string") {
1633
- sendEvent({ type: "error", message: "Destination is required" });
1634
- controller.close();
1635
- return;
1636
- }
1637
- const safeDestination = destination.replace(/\.\./g, "");
1638
- const absoluteDestination = getWorkspacePath(safeDestination);
1639
- if (!absoluteDestination.startsWith(getPublicPath())) {
1640
- sendEvent({ type: "error", message: "Invalid destination" });
1641
- controller.close();
1642
- return;
1643
- }
1644
- await fs6.mkdir(absoluteDestination, { recursive: true });
1645
1523
  const meta = await loadMeta();
1646
- const cdnUrls = getCdnUrls(meta);
1647
- const r2PublicUrl = process.env.CLOUDFLARE_R2_PUBLIC_URL?.replace(/\/$/, "") || "";
1648
- const moved = [];
1649
- const errors = [];
1650
- const sourceFolders = /* @__PURE__ */ new Set();
1651
- let totalFiles = 0;
1652
- const expandedItems = [];
1653
- for (const itemPath of paths) {
1654
- const safePath = itemPath.replace(/\.\./g, "");
1655
- const itemName = path6.basename(safePath);
1656
- const oldRelativePath = safePath.replace(/^public\/?/, "");
1657
- const destWithoutPublic = safeDestination.replace(/^public\/?/, "");
1658
- const newRelativePath = destWithoutPublic ? path6.join(destWithoutPublic, itemName) : itemName;
1659
- const oldKey = "/" + oldRelativePath;
1660
- const newKey = "/" + newRelativePath;
1661
- const newAbsolutePath = path6.join(absoluteDestination, itemName);
1662
- const absolutePath = getWorkspacePath(safePath);
1663
- let hasLocalItem = false;
1664
- let isDirectory = false;
1665
- try {
1666
- const stats = await fs6.stat(absolutePath);
1667
- hasLocalItem = true;
1668
- isDirectory = stats.isDirectory();
1669
- } catch {
1670
- }
1671
- if (hasLocalItem && isDirectory) {
1672
- const countFilesRecursive = async (dir) => {
1673
- let count = 0;
1674
- const entries = await fs6.readdir(dir, { withFileTypes: true });
1675
- for (const entry of entries) {
1676
- if (entry.isDirectory()) {
1677
- count += await countFilesRecursive(path6.join(dir, entry.name));
1678
- } else {
1679
- count++;
1680
- }
1681
- }
1682
- return count;
1683
- };
1684
- const localFileCount = await countFilesRecursive(absolutePath);
1685
- const folderPrefix = oldKey + "/";
1686
- let cloudOnlyCount = 0;
1687
- for (const metaKey of Object.keys(meta)) {
1688
- if (metaKey.startsWith(folderPrefix)) {
1689
- const relPath = metaKey.slice(folderPrefix.length);
1690
- const localPath = path6.join(absolutePath, relPath);
1691
- try {
1692
- await fs6.access(localPath);
1693
- } catch {
1694
- cloudOnlyCount++;
1695
- }
1696
- }
1697
- }
1698
- totalFiles += localFileCount + cloudOnlyCount;
1699
- expandedItems.push({ itemPath, safePath, itemName, oldKey, newKey, newAbsolutePath, isVirtualFolder: false });
1700
- } else if (!hasLocalItem) {
1701
- const folderPrefix = oldKey + "/";
1702
- const virtualItems = [];
1703
- for (const [key, metaEntry] of Object.entries(meta)) {
1704
- if (key.startsWith(folderPrefix) && metaEntry && typeof metaEntry === "object") {
1705
- const relativePath = key.slice(folderPrefix.length);
1706
- const destNewKey = newKey + "/" + relativePath;
1707
- virtualItems.push({ oldKey: key, newKey: destNewKey, entry: metaEntry });
1708
- }
1709
- }
1710
- if (virtualItems.length > 0) {
1711
- totalFiles += virtualItems.length;
1712
- expandedItems.push({ itemPath, safePath, itemName, oldKey, newKey, newAbsolutePath, isVirtualFolder: true, virtualFolderItems: virtualItems });
1713
- sourceFolders.add(absolutePath);
1714
- } else {
1715
- totalFiles++;
1716
- expandedItems.push({ itemPath, safePath, itemName, oldKey, newKey, newAbsolutePath, isVirtualFolder: false });
1717
- }
1718
- } else {
1719
- totalFiles++;
1720
- expandedItems.push({ itemPath, safePath, itemName, oldKey, newKey, newAbsolutePath, isVirtualFolder: false });
1721
- }
1722
- }
1723
- sendEvent({ type: "start", total: totalFiles });
1724
- let processedFiles = 0;
1725
- for (const expandedItem of expandedItems) {
1726
- const { itemPath, safePath, itemName, oldKey, newKey, newAbsolutePath, isVirtualFolder, virtualFolderItems } = expandedItem;
1727
- if (isVirtualFolder && virtualFolderItems) {
1728
- for (const vItem of virtualFolderItems) {
1729
- const itemEntry = vItem.entry;
1730
- const isItemInCloud = itemEntry.c !== void 0;
1731
- const itemCdnUrl = isItemInCloud ? cdnUrls[itemEntry.c] : void 0;
1732
- const isItemInR2 = isItemInCloud && itemCdnUrl === r2PublicUrl;
1733
- const itemHasThumbnails = isProcessed(itemEntry);
1734
- let vItemMoved = false;
1735
- if (isItemInR2) {
1736
- try {
1737
- await moveInCdn(vItem.oldKey, vItem.newKey, itemHasThumbnails);
1738
- vItemMoved = true;
1739
- } catch (err) {
1740
- console.error(`Failed to move cloud item ${vItem.oldKey}:`, err);
1741
- delete meta[vItem.oldKey];
1742
- }
1743
- }
1744
- if (vItemMoved) {
1745
- delete meta[vItem.oldKey];
1746
- meta[vItem.newKey] = itemEntry;
1747
- }
1748
- processedFiles++;
1749
- sendEvent({
1750
- type: "progress",
1751
- current: processedFiles,
1752
- total: totalFiles,
1753
- moved: moved.length,
1754
- percent: Math.round(processedFiles / totalFiles * 100),
1755
- currentFile: path6.basename(vItem.newKey)
1756
- });
1757
- }
1758
- const newFolderPath = getPublicPath(newKey);
1759
- await deleteEmptyFolders(newFolderPath);
1760
- const newThumbFolder = path6.join(getPublicPath("images"), newKey.slice(1));
1761
- await deleteEmptyFolders(newThumbFolder);
1762
- moved.push(itemPath);
1763
- continue;
1524
+ for (let i = 0; i < imageKeys.length; i++) {
1525
+ if (isCancelled()) {
1526
+ await saveMeta(meta);
1527
+ if (operationId) clearCancelledOperation(operationId);
1528
+ sendEvent({
1529
+ type: "stopped",
1530
+ downloaded: downloaded.length,
1531
+ message: `Stopped. ${downloaded.length} image${downloaded.length !== 1 ? "s" : ""} downloaded.`
1532
+ });
1533
+ controller.close();
1534
+ return;
1764
1535
  }
1765
- if (meta[newKey]) {
1766
- errors.push(`${itemName} already exists in destination`);
1767
- processedFiles++;
1536
+ const imageKey = imageKeys[i];
1537
+ const entry = getMetaEntry(meta, imageKey);
1538
+ if (!entry || entry.c === void 0) {
1539
+ skipped.push(imageKey);
1768
1540
  sendEvent({
1769
1541
  type: "progress",
1770
- current: processedFiles,
1771
- total: totalFiles,
1772
- moved: moved.length,
1773
- percent: Math.round(processedFiles / totalFiles * 100),
1774
- currentFile: itemName
1542
+ current: i + 1,
1543
+ total: imageKeys.length,
1544
+ downloaded: downloaded.length,
1545
+ message: `Skipped ${imageKey} (not on cloud)`
1775
1546
  });
1776
1547
  continue;
1777
1548
  }
1778
- const entry = meta[oldKey];
1779
- const isImage = isImageFile(itemName);
1780
- const isInCloud = entry?.c !== void 0;
1781
- const fileCdnUrl = isInCloud && entry.c !== void 0 ? cdnUrls[entry.c] : void 0;
1782
- const isRemote = isInCloud && (!r2PublicUrl || fileCdnUrl !== r2PublicUrl);
1783
- const isPushedToR2 = isInCloud && r2PublicUrl && fileCdnUrl === r2PublicUrl;
1784
- const hasProcessedThumbnails = isProcessed(entry);
1785
1549
  try {
1786
- const sourceFolder = path6.dirname(getWorkspacePath(safePath));
1787
- sourceFolders.add(sourceFolder);
1788
- if (isRemote && isImage) {
1789
- const remoteUrl = `${fileCdnUrl}${oldKey}`;
1790
- const buffer = await downloadFromRemoteUrl(remoteUrl);
1791
- await fs6.mkdir(path6.dirname(newAbsolutePath), { recursive: true });
1792
- await fs6.writeFile(newAbsolutePath, buffer);
1793
- const newEntry = {
1794
- o: entry?.o,
1795
- b: entry?.b
1796
- };
1797
- delete meta[oldKey];
1798
- meta[newKey] = newEntry;
1799
- moved.push(itemPath);
1800
- processedFiles++;
1801
- sendEvent({
1802
- type: "progress",
1803
- current: processedFiles,
1804
- total: totalFiles,
1805
- moved: moved.length,
1806
- percent: Math.round(processedFiles / totalFiles * 100),
1807
- currentFile: itemName
1808
- });
1809
- } else if (isPushedToR2 && isImage) {
1810
- await moveInCdn(oldKey, newKey, hasProcessedThumbnails);
1811
- delete meta[oldKey];
1812
- if (entry) {
1813
- meta[newKey] = entry;
1814
- }
1815
- moved.push(itemPath);
1816
- processedFiles++;
1550
+ const imageBuffer = await downloadFromCdn(imageKey);
1551
+ if (isCancelled()) {
1552
+ await saveMeta(meta);
1553
+ if (operationId) clearCancelledOperation(operationId);
1817
1554
  sendEvent({
1818
- type: "progress",
1819
- current: processedFiles,
1820
- total: totalFiles,
1821
- moved: moved.length,
1822
- percent: Math.round(processedFiles / totalFiles * 100),
1823
- currentFile: itemName
1555
+ type: "stopped",
1556
+ downloaded: downloaded.length,
1557
+ message: `Stopped. ${downloaded.length} image${downloaded.length !== 1 ? "s" : ""} downloaded.`
1824
1558
  });
1825
- } else {
1826
- const absolutePath = getWorkspacePath(safePath);
1827
- if (absoluteDestination.startsWith(absolutePath + path6.sep)) {
1828
- errors.push(`Cannot move ${itemName} into itself`);
1829
- processedFiles++;
1830
- sendEvent({
1831
- type: "progress",
1832
- current: processedFiles,
1833
- total: totalFiles,
1834
- moved: moved.length,
1835
- percent: Math.round(processedFiles / totalFiles * 100),
1836
- currentFile: itemName
1837
- });
1838
- continue;
1839
- }
1840
- try {
1841
- await fs6.access(absolutePath);
1842
- } catch {
1843
- errors.push(`${itemName} not found`);
1844
- processedFiles++;
1845
- sendEvent({
1846
- type: "progress",
1847
- current: processedFiles,
1848
- total: totalFiles,
1849
- moved: moved.length,
1850
- percent: Math.round(processedFiles / totalFiles * 100),
1851
- currentFile: itemName
1852
- });
1853
- continue;
1854
- }
1855
- try {
1856
- await fs6.access(newAbsolutePath);
1857
- errors.push(`${itemName} already exists in destination`);
1858
- processedFiles++;
1859
- sendEvent({
1860
- type: "progress",
1861
- current: processedFiles,
1862
- total: totalFiles,
1863
- moved: moved.length,
1864
- percent: Math.round(processedFiles / totalFiles * 100),
1865
- currentFile: itemName
1866
- });
1867
- continue;
1868
- } catch {
1869
- }
1870
- const stats = await fs6.stat(absolutePath);
1871
- if (stats.isFile()) {
1872
- await fs6.mkdir(path6.dirname(newAbsolutePath), { recursive: true });
1873
- await fs6.rename(absolutePath, newAbsolutePath);
1874
- if (isImage && entry) {
1875
- const oldThumbPaths = getAllThumbnailPaths(oldKey);
1876
- const newThumbPaths = getAllThumbnailPaths(newKey);
1877
- for (let j = 0; j < oldThumbPaths.length; j++) {
1878
- const oldThumbPath = getPublicPath(oldThumbPaths[j]);
1879
- const newThumbPath = getPublicPath(newThumbPaths[j]);
1880
- try {
1881
- await fs6.access(oldThumbPath);
1882
- sourceFolders.add(path6.dirname(oldThumbPath));
1883
- await fs6.mkdir(path6.dirname(newThumbPath), { recursive: true });
1884
- await fs6.rename(oldThumbPath, newThumbPath);
1885
- } catch {
1886
- }
1887
- }
1888
- const fileIsInCloud = entry.c !== void 0;
1889
- const fileCdnUrl2 = fileIsInCloud ? cdnUrls[entry.c] : void 0;
1890
- const fileIsInR2 = fileIsInCloud && fileCdnUrl2 === r2PublicUrl;
1891
- const fileHasThumbs = isProcessed(entry);
1892
- if (fileIsInR2) {
1893
- await deleteFromCdn(oldKey, fileHasThumbs);
1894
- await uploadOriginalToCdn(newKey);
1895
- if (fileHasThumbs) {
1896
- await uploadToCdn(newKey);
1897
- }
1898
- }
1899
- delete meta[oldKey];
1900
- meta[newKey] = entry;
1901
- }
1902
- processedFiles++;
1903
- sendEvent({
1904
- type: "progress",
1905
- current: processedFiles,
1906
- total: totalFiles,
1907
- moved: moved.length,
1908
- percent: Math.round(processedFiles / totalFiles * 100),
1909
- currentFile: itemName
1910
- });
1911
- moved.push(itemPath);
1912
- } else if (stats.isDirectory()) {
1913
- const oldPrefix = oldKey + "/";
1914
- const newPrefix = newKey + "/";
1915
- const localFiles = [];
1916
- const collectLocalFiles = async (dir, relativeDir) => {
1917
- const entries = await fs6.readdir(dir, { withFileTypes: true });
1918
- for (const dirEntry of entries) {
1919
- const entryRelPath = relativeDir ? `${relativeDir}/${dirEntry.name}` : dirEntry.name;
1920
- if (dirEntry.isDirectory()) {
1921
- await collectLocalFiles(path6.join(dir, dirEntry.name), entryRelPath);
1922
- } else {
1923
- localFiles.push({ relativePath: entryRelPath, isImage: isImageFile(dirEntry.name) });
1924
- }
1925
- }
1926
- };
1927
- await collectLocalFiles(absolutePath, "");
1928
- const cloudOnlyFiles = [];
1929
- for (const [metaKey, metaEntry] of Object.entries(meta)) {
1930
- if (metaKey.startsWith(oldPrefix) && metaEntry && typeof metaEntry === "object") {
1931
- const relPath = metaKey.slice(oldPrefix.length);
1932
- const localPath = path6.join(absolutePath, relPath);
1933
- try {
1934
- await fs6.access(localPath);
1935
- } catch {
1936
- cloudOnlyFiles.push({
1937
- oldKey: metaKey,
1938
- newKey: newPrefix + relPath,
1939
- entry: metaEntry
1940
- });
1941
- }
1942
- }
1943
- }
1944
- for (const localFile of localFiles) {
1945
- const fileOldPath = path6.join(absolutePath, localFile.relativePath);
1946
- const fileNewPath = path6.join(newAbsolutePath, localFile.relativePath);
1947
- const fileOldKey = oldPrefix + localFile.relativePath;
1948
- const fileNewKey = newPrefix + localFile.relativePath;
1949
- const fileEntry = meta[fileOldKey];
1950
- sourceFolders.add(path6.dirname(fileOldPath));
1951
- await fs6.mkdir(path6.dirname(fileNewPath), { recursive: true });
1952
- await fs6.rename(fileOldPath, fileNewPath);
1953
- if (localFile.isImage && fileEntry) {
1954
- const oldThumbPaths = getAllThumbnailPaths(fileOldKey);
1955
- const newThumbPaths = getAllThumbnailPaths(fileNewKey);
1956
- for (let t = 0; t < oldThumbPaths.length; t++) {
1957
- const oldThumbPath = getPublicPath(oldThumbPaths[t]);
1958
- const newThumbPath = getPublicPath(newThumbPaths[t]);
1959
- try {
1960
- await fs6.access(oldThumbPath);
1961
- sourceFolders.add(path6.dirname(oldThumbPath));
1962
- await fs6.mkdir(path6.dirname(newThumbPath), { recursive: true });
1963
- await fs6.rename(oldThumbPath, newThumbPath);
1964
- } catch {
1965
- }
1966
- }
1967
- const fileIsInCloud = fileEntry.c !== void 0;
1968
- const fileCdnUrl2 = fileIsInCloud ? cdnUrls[fileEntry.c] : void 0;
1969
- const fileIsInR2 = fileIsInCloud && fileCdnUrl2 === r2PublicUrl;
1970
- const fileHasThumbs = isProcessed(fileEntry);
1971
- if (fileIsInR2) {
1972
- await moveInCdn(fileOldKey, fileNewKey, fileHasThumbs);
1973
- }
1974
- delete meta[fileOldKey];
1975
- meta[fileNewKey] = fileEntry;
1976
- }
1977
- processedFiles++;
1978
- sendEvent({
1979
- type: "progress",
1980
- current: processedFiles,
1981
- total: totalFiles,
1982
- moved: moved.length,
1983
- percent: Math.round(processedFiles / totalFiles * 100),
1984
- currentFile: path6.basename(localFile.relativePath)
1985
- });
1986
- }
1987
- for (const cloudFile of cloudOnlyFiles) {
1988
- const cloudEntry = cloudFile.entry;
1989
- const cloudIsInCloud = cloudEntry.c !== void 0;
1990
- const cloudCdnUrl = cloudIsInCloud ? cdnUrls[cloudEntry.c] : void 0;
1991
- const cloudIsInR2 = cloudIsInCloud && cloudCdnUrl === r2PublicUrl;
1992
- const cloudHasThumbs = isProcessed(cloudEntry);
1993
- let cloudFileMoved = false;
1994
- if (cloudIsInR2) {
1995
- try {
1996
- await moveInCdn(cloudFile.oldKey, cloudFile.newKey, cloudHasThumbs);
1997
- cloudFileMoved = true;
1998
- } catch (err) {
1999
- console.error(`Failed to move cloud file ${cloudFile.oldKey}:`, err);
2000
- delete meta[cloudFile.oldKey];
2001
- }
2002
- }
2003
- if (cloudFileMoved) {
2004
- delete meta[cloudFile.oldKey];
2005
- meta[cloudFile.newKey] = cloudEntry;
2006
- }
2007
- processedFiles++;
2008
- sendEvent({
2009
- type: "progress",
2010
- current: processedFiles,
2011
- total: totalFiles,
2012
- moved: moved.length,
2013
- percent: Math.round(processedFiles / totalFiles * 100),
2014
- currentFile: path6.basename(cloudFile.newKey)
2015
- });
2016
- }
2017
- sourceFolders.add(absolutePath);
2018
- const oldThumbRelPath = oldKey.slice(1);
2019
- const oldThumbFolder = path6.join(getPublicPath("images"), oldThumbRelPath);
2020
- sourceFolders.add(oldThumbFolder);
2021
- moved.push(itemPath);
2022
- }
1559
+ controller.close();
1560
+ return;
1561
+ }
1562
+ const localPath = getPublicPath(imageKey.replace(/^\//, ""));
1563
+ await fs6.mkdir(path6.dirname(localPath), { recursive: true });
1564
+ await fs6.writeFile(localPath, imageBuffer);
1565
+ await deleteThumbnailsFromCdn(imageKey);
1566
+ const wasProcessed = isProcessed(entry);
1567
+ delete entry.c;
1568
+ if (wasProcessed) {
1569
+ const processedEntry = await processImage(imageBuffer, imageKey);
1570
+ entry.sm = processedEntry.sm;
1571
+ entry.md = processedEntry.md;
1572
+ entry.lg = processedEntry.lg;
1573
+ entry.f = processedEntry.f;
2023
1574
  }
2024
- } catch (err) {
2025
- console.error(`Failed to move ${itemName}:`, err);
2026
- errors.push(`Failed to move ${itemName}`);
2027
- processedFiles++;
1575
+ downloaded.push(imageKey);
2028
1576
  sendEvent({
2029
1577
  type: "progress",
2030
- current: processedFiles,
2031
- total: totalFiles,
2032
- moved: moved.length,
2033
- percent: Math.round(processedFiles / totalFiles * 100),
2034
- currentFile: itemName
1578
+ current: i + 1,
1579
+ total: imageKeys.length,
1580
+ downloaded: downloaded.length,
1581
+ message: `Downloaded ${imageKey}`
1582
+ });
1583
+ } catch (error) {
1584
+ console.error(`Failed to download ${imageKey}:`, error);
1585
+ errors.push(imageKey);
1586
+ sendEvent({
1587
+ type: "progress",
1588
+ current: i + 1,
1589
+ total: imageKeys.length,
1590
+ downloaded: downloaded.length,
1591
+ message: `Failed to download ${imageKey}`
2035
1592
  });
2036
1593
  }
2037
1594
  }
2038
1595
  await saveMeta(meta);
2039
- for (const folder of sourceFolders) {
2040
- await deleteEmptyFolders(folder);
1596
+ let message = `Downloaded ${downloaded.length} image${downloaded.length !== 1 ? "s" : ""}.`;
1597
+ if (skipped.length > 0) {
1598
+ message += ` ${skipped.length} image${skipped.length !== 1 ? "s were" : " was"} not on cloud.`;
1599
+ }
1600
+ if (errors.length > 0) {
1601
+ message += ` ${errors.length} image${errors.length !== 1 ? "s" : ""} failed.`;
2041
1602
  }
2042
- await deleteEmptyFolders(absoluteDestination);
2043
1603
  sendEvent({
2044
1604
  type: "complete",
2045
- moved: moved.length,
1605
+ downloaded: downloaded.length,
1606
+ skipped: skipped.length,
2046
1607
  errors: errors.length,
2047
- errorMessages: errors
1608
+ message
2048
1609
  });
2049
1610
  } catch (error) {
2050
- console.error("Failed to move:", error);
2051
- sendEvent({ type: "error", message: "Failed to move items" });
1611
+ console.error("Download stream error:", error);
1612
+ sendEvent({ type: "error", message: "Failed to download images" });
2052
1613
  } finally {
2053
1614
  controller.close();
2054
1615
  }
@@ -2062,157 +1623,13 @@ async function handleMoveStream(request) {
2062
1623
  }
2063
1624
  });
2064
1625
  }
2065
-
2066
- // src/handlers/images.ts
2067
- import { promises as fs7 } from "fs";
2068
- import path7 from "path";
2069
- import { S3Client as S3Client2, PutObjectCommand as PutObjectCommand2, DeleteObjectCommand as DeleteObjectCommand2 } from "@aws-sdk/client-s3";
2070
- var cancelledOperations = /* @__PURE__ */ new Set();
2071
- function cancelOperation(operationId) {
2072
- cancelledOperations.add(operationId);
2073
- setTimeout(() => cancelledOperations.delete(operationId), 6e4);
2074
- }
2075
- function isOperationCancelled(operationId) {
2076
- return cancelledOperations.has(operationId);
2077
- }
2078
- function clearCancelledOperation(operationId) {
2079
- cancelledOperations.delete(operationId);
2080
- }
2081
- async function handleSync(request) {
1626
+ async function handlePushUpdatesStream(request) {
2082
1627
  const accountId = process.env.CLOUDFLARE_R2_ACCOUNT_ID;
2083
1628
  const accessKeyId = process.env.CLOUDFLARE_R2_ACCESS_KEY_ID;
2084
1629
  const secretAccessKey = process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY;
2085
1630
  const bucketName = process.env.CLOUDFLARE_R2_BUCKET_NAME;
2086
- const publicUrl = process.env.CLOUDFLARE_R2_PUBLIC_URL?.replace(/\/\s*$/, "");
2087
- if (!accountId || !accessKeyId || !secretAccessKey || !bucketName || !publicUrl) {
2088
- return jsonResponse(
2089
- { error: "R2 not configured. Set CLOUDFLARE_R2_* environment variables." },
2090
- { status: 400 }
2091
- );
2092
- }
2093
- try {
2094
- const { imageKeys } = await request.json();
2095
- if (!imageKeys || !Array.isArray(imageKeys) || imageKeys.length === 0) {
2096
- return jsonResponse({ error: "No image keys provided" }, { status: 400 });
2097
- }
2098
- const meta = await loadMeta();
2099
- const cdnUrls = getCdnUrls(meta);
2100
- const cdnIndex = getOrAddCdnIndex(meta, publicUrl);
2101
- const r2 = new S3Client2({
2102
- region: "auto",
2103
- endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
2104
- credentials: { accessKeyId, secretAccessKey }
2105
- });
2106
- const pushed = [];
2107
- const alreadyPushed = [];
2108
- const errors = [];
2109
- const sourceFolders = /* @__PURE__ */ new Set();
2110
- for (let imageKey of imageKeys) {
2111
- if (!imageKey.startsWith("/")) {
2112
- imageKey = `/${imageKey}`;
2113
- }
2114
- const entry = getMetaEntry(meta, imageKey);
2115
- if (!entry) {
2116
- errors.push(`Image not found in meta: ${imageKey}. Run Scan first.`);
2117
- continue;
2118
- }
2119
- const existingCdnUrl = entry.c !== void 0 ? cdnUrls[entry.c] : void 0;
2120
- const isAlreadyInOurR2 = existingCdnUrl === publicUrl;
2121
- if (isAlreadyInOurR2) {
2122
- alreadyPushed.push(imageKey);
2123
- continue;
2124
- }
2125
- const isRemote = entry.c !== void 0 && existingCdnUrl !== publicUrl;
2126
- try {
2127
- let originalBuffer;
2128
- if (isRemote) {
2129
- const remoteUrl = `${existingCdnUrl}${imageKey}`;
2130
- originalBuffer = await downloadFromRemoteUrl(remoteUrl);
2131
- } else {
2132
- const originalLocalPath = getPublicPath(imageKey);
2133
- try {
2134
- originalBuffer = await fs7.readFile(originalLocalPath);
2135
- } catch {
2136
- errors.push(`Original file not found: ${imageKey}`);
2137
- continue;
2138
- }
2139
- }
2140
- await r2.send(
2141
- new PutObjectCommand2({
2142
- Bucket: bucketName,
2143
- Key: imageKey.replace(/^\//, ""),
2144
- Body: originalBuffer,
2145
- ContentType: getContentType(imageKey)
2146
- })
2147
- );
2148
- if (!isRemote && isProcessed(entry)) {
2149
- for (const thumbPath of getAllThumbnailPaths(imageKey)) {
2150
- const localPath = getPublicPath(thumbPath);
2151
- try {
2152
- const fileBuffer = await fs7.readFile(localPath);
2153
- await r2.send(
2154
- new PutObjectCommand2({
2155
- Bucket: bucketName,
2156
- Key: thumbPath.replace(/^\//, ""),
2157
- Body: fileBuffer,
2158
- ContentType: getContentType(thumbPath)
2159
- })
2160
- );
2161
- } catch {
2162
- }
2163
- }
2164
- }
2165
- entry.c = cdnIndex;
2166
- if (!isRemote) {
2167
- const originalLocalPath = getPublicPath(imageKey);
2168
- sourceFolders.add(path7.dirname(originalLocalPath));
2169
- for (const thumbPath of getAllThumbnailPaths(imageKey)) {
2170
- const localPath = getPublicPath(thumbPath);
2171
- sourceFolders.add(path7.dirname(localPath));
2172
- try {
2173
- await fs7.unlink(localPath);
2174
- } catch {
2175
- }
2176
- }
2177
- try {
2178
- await fs7.unlink(originalLocalPath);
2179
- } catch {
2180
- }
2181
- }
2182
- pushed.push(imageKey);
2183
- } catch (error) {
2184
- console.error(`Failed to push ${imageKey}:`, error);
2185
- errors.push(`Failed to push: ${imageKey}`);
2186
- }
2187
- }
2188
- await saveMeta(meta);
2189
- for (const folder of sourceFolders) {
2190
- await deleteEmptyFolders(folder);
2191
- }
2192
- return jsonResponse({
2193
- success: true,
2194
- pushed,
2195
- alreadyPushed: alreadyPushed.length > 0 ? alreadyPushed : void 0,
2196
- errors: errors.length > 0 ? errors : void 0
2197
- });
2198
- } catch (error) {
2199
- console.error("Failed to push:", error);
2200
- return jsonResponse({ error: "Failed to push to CDN" }, { status: 500 });
2201
- }
2202
- }
2203
- async function handleUnprocessStream(request) {
2204
- const publicUrl = process.env.CLOUDFLARE_R2_PUBLIC_URL?.replace(/\/\s*$/, "");
1631
+ const publicUrl = process.env.CLOUDFLARE_R2_PUBLIC_URL?.replace(/\/$/, "");
2205
1632
  const encoder = new TextEncoder();
2206
- let imageKeys;
2207
- try {
2208
- const body = await request.json();
2209
- imageKeys = body.imageKeys;
2210
- if (!imageKeys || !Array.isArray(imageKeys) || imageKeys.length === 0) {
2211
- return jsonResponse({ error: "No image keys provided" }, { status: 400 });
2212
- }
2213
- } catch {
2214
- return jsonResponse({ error: "Invalid request body" }, { status: 400 });
2215
- }
2216
1633
  const stream = new ReadableStream({
2217
1634
  async start(controller) {
2218
1635
  const sendEvent = (data) => {
@@ -2221,105 +1638,133 @@ async function handleUnprocessStream(request) {
2221
1638
  `));
2222
1639
  };
2223
1640
  try {
1641
+ if (!accountId || !accessKeyId || !secretAccessKey || !bucketName || !publicUrl) {
1642
+ sendEvent({ type: "error", message: "R2 not configured" });
1643
+ controller.close();
1644
+ return;
1645
+ }
1646
+ const { paths } = await request.json();
1647
+ if (!paths || !Array.isArray(paths) || paths.length === 0) {
1648
+ sendEvent({ type: "error", message: "No paths provided" });
1649
+ controller.close();
1650
+ return;
1651
+ }
1652
+ const s3 = new S3Client2({
1653
+ region: "auto",
1654
+ endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
1655
+ credentials: { accessKeyId, secretAccessKey }
1656
+ });
2224
1657
  const meta = await loadMeta();
2225
1658
  const cdnUrls = getCdnUrls(meta);
2226
- const removed = [];
1659
+ const r2PublicUrl = publicUrl.replace(/\/$/, "");
1660
+ const pushed = [];
2227
1661
  const skipped = [];
2228
1662
  const errors = [];
2229
- const total = imageKeys.length;
1663
+ const total = paths.length;
2230
1664
  sendEvent({ type: "start", total });
2231
- for (let i = 0; i < imageKeys.length; i++) {
2232
- let imageKey = imageKeys[i];
2233
- if (!imageKey.startsWith("/")) {
2234
- imageKey = `/${imageKey}`;
1665
+ for (let i = 0; i < paths.length; i++) {
1666
+ const itemPath = paths[i];
1667
+ const key = itemPath.startsWith("public/") ? "/" + itemPath.slice(7) : itemPath;
1668
+ const entry = meta[key];
1669
+ if (!entry || entry.u !== 1) {
1670
+ skipped.push(key);
1671
+ sendEvent({
1672
+ type: "progress",
1673
+ current: i + 1,
1674
+ total,
1675
+ pushed: pushed.length,
1676
+ percent: Math.round((i + 1) / total * 100),
1677
+ currentFile: path6.basename(key)
1678
+ });
1679
+ continue;
2235
1680
  }
2236
- try {
2237
- const entry = getMetaEntry(meta, imageKey);
2238
- if (!entry) {
2239
- errors.push(imageKey);
2240
- sendEvent({
2241
- type: "progress",
2242
- current: i + 1,
2243
- total,
2244
- processed: removed.length,
2245
- percent: Math.round((i + 1) / total * 100),
2246
- message: `Error: ${imageKey.slice(1)}`
2247
- });
2248
- continue;
2249
- }
2250
- const hasThumbnails = entry.sm || entry.md || entry.lg || entry.f;
2251
- if (!hasThumbnails) {
2252
- skipped.push(imageKey);
2253
- sendEvent({
2254
- type: "progress",
2255
- current: i + 1,
2256
- total,
2257
- processed: removed.length,
2258
- percent: Math.round((i + 1) / total * 100),
2259
- message: `Skipped ${imageKey.slice(1)} (no thumbnails)`
2260
- });
2261
- continue;
1681
+ const fileCdnUrl = entry.c !== void 0 ? cdnUrls[entry.c]?.replace(/\/$/, "") : void 0;
1682
+ if (!fileCdnUrl || fileCdnUrl !== r2PublicUrl) {
1683
+ skipped.push(key);
1684
+ sendEvent({
1685
+ type: "progress",
1686
+ current: i + 1,
1687
+ total,
1688
+ pushed: pushed.length,
1689
+ percent: Math.round((i + 1) / total * 100),
1690
+ currentFile: path6.basename(key)
1691
+ });
1692
+ continue;
1693
+ }
1694
+ try {
1695
+ const localPath = getPublicPath(key);
1696
+ const buffer = await fs6.readFile(localPath);
1697
+ const contentType = getContentType(path6.basename(key));
1698
+ const uploadKey = key.startsWith("/") ? key.slice(1) : key;
1699
+ try {
1700
+ await s3.send(new DeleteObjectCommand2({
1701
+ Bucket: bucketName,
1702
+ Key: uploadKey
1703
+ }));
1704
+ } catch {
2262
1705
  }
2263
- const existingCdnIndex = entry.c;
2264
- const existingCdnUrl = existingCdnIndex !== void 0 ? cdnUrls[existingCdnIndex] : void 0;
2265
- const isInOurR2 = existingCdnUrl === publicUrl;
2266
- await deleteLocalThumbnails(imageKey);
2267
- if (isInOurR2) {
2268
- await deleteThumbnailsFromCdn(imageKey);
1706
+ await s3.send(new PutObjectCommand2({
1707
+ Bucket: bucketName,
1708
+ Key: uploadKey,
1709
+ Body: buffer,
1710
+ ContentType: contentType
1711
+ }));
1712
+ if (isProcessed(entry)) {
1713
+ await deleteThumbnailsFromCdn(key);
1714
+ const processedEntry = await processImage(buffer, key);
1715
+ Object.assign(entry, processedEntry);
1716
+ await uploadToCdn(key);
1717
+ await deleteLocalThumbnails(key);
2269
1718
  }
2270
- meta[imageKey] = {
2271
- o: entry.o,
2272
- b: entry.b,
2273
- ...entry.c !== void 0 ? { c: entry.c } : {}
2274
- };
2275
- removed.push(imageKey);
1719
+ await fs6.unlink(localPath);
1720
+ delete entry.u;
1721
+ pushed.push(key);
2276
1722
  sendEvent({
2277
1723
  type: "progress",
2278
1724
  current: i + 1,
2279
1725
  total,
2280
- processed: removed.length,
1726
+ pushed: pushed.length,
2281
1727
  percent: Math.round((i + 1) / total * 100),
2282
- message: `Removed thumbnails for ${imageKey.slice(1)}`
1728
+ currentFile: path6.basename(key)
2283
1729
  });
2284
1730
  } catch (error) {
2285
- console.error(`Failed to unprocess ${imageKey}:`, error);
2286
- errors.push(imageKey);
1731
+ console.error(`Failed to push update for ${key}:`, error);
1732
+ errors.push(key);
2287
1733
  sendEvent({
2288
1734
  type: "progress",
2289
1735
  current: i + 1,
2290
1736
  total,
2291
- processed: removed.length,
1737
+ pushed: pushed.length,
2292
1738
  percent: Math.round((i + 1) / total * 100),
2293
- message: `Failed: ${imageKey.slice(1)}`
1739
+ currentFile: path6.basename(key),
1740
+ message: `Failed: ${path6.basename(key)}`
2294
1741
  });
2295
1742
  }
2296
1743
  }
2297
- sendEvent({ type: "cleanup", message: "Saving metadata..." });
2298
- await saveMeta(meta);
2299
- sendEvent({ type: "cleanup", message: "Cleaning up empty folders..." });
2300
- const imagesDir = getPublicPath("images");
2301
- try {
2302
- await cleanupEmptyFoldersRecursive(imagesDir);
2303
- } catch {
1744
+ sendEvent({ type: "cleanup", message: "Cleaning up..." });
1745
+ for (const itemPath of pushed) {
1746
+ const localPath = getPublicPath(itemPath);
1747
+ await deleteEmptyFolders(path6.dirname(localPath));
2304
1748
  }
2305
- let message = `Removed thumbnails from ${removed.length} image${removed.length !== 1 ? "s" : ""}.`;
1749
+ await saveMeta(meta);
1750
+ let message = `Pushed ${pushed.length} update${pushed.length !== 1 ? "s" : ""} to cloud.`;
2306
1751
  if (skipped.length > 0) {
2307
- message += ` ${skipped.length} image${skipped.length !== 1 ? "s" : ""} had no thumbnails.`;
1752
+ message += ` ${skipped.length} file${skipped.length !== 1 ? "s" : ""} skipped.`;
2308
1753
  }
2309
1754
  if (errors.length > 0) {
2310
- message += ` ${errors.length} image${errors.length !== 1 ? "s" : ""} failed.`;
1755
+ message += ` ${errors.length} file${errors.length !== 1 ? "s" : ""} failed.`;
2311
1756
  }
2312
1757
  sendEvent({
2313
1758
  type: "complete",
2314
- processed: removed.length,
1759
+ pushed: pushed.length,
2315
1760
  skipped: skipped.length,
2316
1761
  errors: errors.length,
2317
1762
  message
2318
1763
  });
2319
- controller.close();
2320
1764
  } catch (error) {
2321
- console.error("Unprocess stream error:", error);
2322
- sendEvent({ type: "error", message: "Failed to remove thumbnails" });
1765
+ console.error("Push updates error:", error);
1766
+ sendEvent({ type: "error", message: "Failed to push updates" });
1767
+ } finally {
2323
1768
  controller.close();
2324
1769
  }
2325
1770
  }
@@ -2328,283 +1773,602 @@ async function handleUnprocessStream(request) {
2328
1773
  headers: {
2329
1774
  "Content-Type": "text/event-stream",
2330
1775
  "Cache-Control": "no-cache",
2331
- Connection: "keep-alive"
1776
+ "Connection": "keep-alive"
2332
1777
  }
2333
1778
  });
2334
1779
  }
2335
- async function handleReprocessStream(request) {
2336
- const publicUrl = process.env.CLOUDFLARE_R2_PUBLIC_URL?.replace(/\/\s*$/, "");
2337
- const encoder = new TextEncoder();
2338
- let imageKeys;
1780
+ async function handleCancelStreamOperation(request) {
2339
1781
  try {
2340
- const body = await request.json();
2341
- imageKeys = body.imageKeys;
2342
- if (!imageKeys || !Array.isArray(imageKeys) || imageKeys.length === 0) {
2343
- return jsonResponse({ error: "No image keys provided" }, { status: 400 });
1782
+ const { operationId } = await request.json();
1783
+ if (!operationId || typeof operationId !== "string") {
1784
+ return jsonResponse({ error: "No operation ID provided" }, { status: 400 });
2344
1785
  }
2345
- } catch {
2346
- return jsonResponse({ error: "Invalid request body" }, { status: 400 });
1786
+ cancelOperation(operationId);
1787
+ return jsonResponse({ success: true, operationId });
1788
+ } catch (error) {
1789
+ console.error("Failed to cancel operation:", error);
1790
+ return jsonResponse({ error: "Failed to cancel operation" }, { status: 500 });
2347
1791
  }
2348
- const stream = new ReadableStream({
2349
- async start(controller) {
2350
- const sendEvent = (data) => {
2351
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}
1792
+ }
1793
+ async function handleCancelUpdates(request) {
1794
+ try {
1795
+ const { paths } = await request.json();
1796
+ if (!paths || !Array.isArray(paths) || paths.length === 0) {
1797
+ return jsonResponse({ error: "No paths provided" }, { status: 400 });
1798
+ }
1799
+ const meta = await loadMeta();
1800
+ const cancelled = [];
1801
+ const skipped = [];
1802
+ const errors = [];
1803
+ const foldersToClean = /* @__PURE__ */ new Set();
1804
+ for (const itemPath of paths) {
1805
+ const key = itemPath.startsWith("public/") ? "/" + itemPath.slice(7) : itemPath;
1806
+ const entry = meta[key];
1807
+ if (!entry || entry.u !== 1) {
1808
+ skipped.push(key);
1809
+ continue;
1810
+ }
1811
+ try {
1812
+ const localPath = getPublicPath(key);
1813
+ await fs6.unlink(localPath);
1814
+ foldersToClean.add(path6.dirname(localPath));
1815
+ delete entry.u;
1816
+ cancelled.push(key);
1817
+ } catch (error) {
1818
+ console.error(`Failed to cancel update for ${key}:`, error);
1819
+ errors.push(key);
1820
+ }
1821
+ }
1822
+ for (const folder of foldersToClean) {
1823
+ await deleteEmptyFolders(folder);
1824
+ }
1825
+ await saveMeta(meta);
1826
+ return jsonResponse({
1827
+ success: true,
1828
+ cancelled: cancelled.length,
1829
+ skipped: skipped.length,
1830
+ errors: errors.length
1831
+ });
1832
+ } catch (error) {
1833
+ console.error("Cancel updates error:", error);
1834
+ return jsonResponse({ error: "Failed to cancel updates" }, { status: 500 });
1835
+ }
1836
+ }
2352
1837
 
2353
- `));
2354
- };
1838
+ // src/handlers/files.ts
1839
+ async function handleUpload(request) {
1840
+ try {
1841
+ const formData = await request.formData();
1842
+ const file = formData.get("file");
1843
+ const targetPath = formData.get("path") || "public";
1844
+ if (!file) {
1845
+ return jsonResponse({ error: "No file provided" }, { status: 400 });
1846
+ }
1847
+ const bytes = await file.arrayBuffer();
1848
+ const buffer = Buffer.from(bytes);
1849
+ const fileName = slugifyFilename(file.name);
1850
+ const ext = path7.extname(fileName).toLowerCase();
1851
+ const isImage = isImageFile(fileName);
1852
+ const isMedia = isMediaFile(fileName);
1853
+ const meta = await loadMeta();
1854
+ let relativeDir = "";
1855
+ if (targetPath === "public") {
1856
+ relativeDir = "";
1857
+ } else if (targetPath.startsWith("public/")) {
1858
+ relativeDir = targetPath.replace("public/", "");
1859
+ }
1860
+ if (relativeDir === "images" || relativeDir.startsWith("images/")) {
1861
+ return jsonResponse(
1862
+ { error: "Cannot upload to images/ folder. Upload to public/ instead - thumbnails are generated automatically." },
1863
+ { status: 400 }
1864
+ );
1865
+ }
1866
+ let imageKey = "/" + (relativeDir ? `${relativeDir}/${fileName}` : fileName);
1867
+ if (meta[imageKey]) {
1868
+ const baseName = path7.basename(fileName, ext);
1869
+ let counter = 1;
1870
+ let newFileName = `${baseName}-${counter}${ext}`;
1871
+ let newKey = "/" + (relativeDir ? `${relativeDir}/${newFileName}` : newFileName);
1872
+ while (meta[newKey]) {
1873
+ counter++;
1874
+ newFileName = `${baseName}-${counter}${ext}`;
1875
+ newKey = "/" + (relativeDir ? `${relativeDir}/${newFileName}` : newFileName);
1876
+ }
1877
+ imageKey = newKey;
1878
+ }
1879
+ const actualFileName = path7.basename(imageKey);
1880
+ const uploadDir = getPublicPath(relativeDir);
1881
+ await fs7.mkdir(uploadDir, { recursive: true });
1882
+ await fs7.writeFile(path7.join(uploadDir, actualFileName), buffer);
1883
+ if (!isMedia) {
1884
+ return jsonResponse({
1885
+ success: true,
1886
+ message: "File uploaded (not a media file)",
1887
+ path: `public/${relativeDir ? relativeDir + "/" : ""}${actualFileName}`
1888
+ });
1889
+ }
1890
+ if (isImage && ext !== ".svg") {
2355
1891
  try {
2356
- const meta = await loadMeta();
2357
- const cdnUrls = getCdnUrls(meta);
2358
- const processed = [];
2359
- const errors = [];
2360
- const total = imageKeys.length;
2361
- sendEvent({ type: "start", total });
2362
- for (let i = 0; i < imageKeys.length; i++) {
2363
- let imageKey = imageKeys[i];
2364
- if (!imageKey.startsWith("/")) {
2365
- imageKey = `/${imageKey}`;
2366
- }
2367
- try {
2368
- let buffer;
2369
- const entry = getMetaEntry(meta, imageKey);
2370
- const existingCdnIndex = entry?.c;
2371
- const existingCdnUrl = existingCdnIndex !== void 0 ? cdnUrls[existingCdnIndex] : void 0;
2372
- const isInOurR2 = existingCdnUrl === publicUrl;
2373
- const isRemote = existingCdnIndex !== void 0 && !isInOurR2;
2374
- const originalPath = getPublicPath(imageKey);
2375
- try {
2376
- buffer = await fs7.readFile(originalPath);
2377
- } catch {
2378
- if (isInOurR2) {
2379
- buffer = await downloadFromCdn(imageKey);
2380
- const dir = path7.dirname(originalPath);
2381
- await fs7.mkdir(dir, { recursive: true });
2382
- await fs7.writeFile(originalPath, buffer);
2383
- } else if (isRemote && existingCdnUrl) {
2384
- const remoteUrl = `${existingCdnUrl}${imageKey}`;
2385
- buffer = await downloadFromRemoteUrl(remoteUrl);
2386
- const dir = path7.dirname(originalPath);
2387
- await fs7.mkdir(dir, { recursive: true });
2388
- await fs7.writeFile(originalPath, buffer);
2389
- } else {
2390
- throw new Error(`File not found: ${imageKey}`);
1892
+ const metadata = await sharp2(buffer).metadata();
1893
+ meta[imageKey] = {
1894
+ o: { w: metadata.width || 0, h: metadata.height || 0 }
1895
+ };
1896
+ } catch {
1897
+ meta[imageKey] = { o: { w: 0, h: 0 } };
1898
+ }
1899
+ } else {
1900
+ meta[imageKey] = {};
1901
+ }
1902
+ await saveMeta(meta);
1903
+ return jsonResponse({
1904
+ success: true,
1905
+ imageKey,
1906
+ message: 'File uploaded. Run "Process Images" to generate thumbnails.'
1907
+ });
1908
+ } catch (error) {
1909
+ console.error("Failed to upload:", error);
1910
+ const message = error instanceof Error ? error.message : "Unknown error";
1911
+ return jsonResponse({ error: `Failed to upload file: ${message}` }, { status: 500 });
1912
+ }
1913
+ }
1914
+ async function handleDelete(request) {
1915
+ try {
1916
+ const { paths } = await request.json();
1917
+ if (!paths || !Array.isArray(paths) || paths.length === 0) {
1918
+ return jsonResponse({ error: "No paths provided" }, { status: 400 });
1919
+ }
1920
+ const meta = await loadMeta();
1921
+ const deleted = [];
1922
+ const errors = [];
1923
+ const sourceFolders = /* @__PURE__ */ new Set();
1924
+ for (const itemPath of paths) {
1925
+ try {
1926
+ if (!itemPath.startsWith("public/")) {
1927
+ errors.push(`Invalid path: ${itemPath}`);
1928
+ continue;
1929
+ }
1930
+ const absolutePath = getWorkspacePath(itemPath);
1931
+ const imageKey = "/" + itemPath.replace(/^public\//, "");
1932
+ sourceFolders.add(path7.dirname(absolutePath));
1933
+ const entry = meta[imageKey];
1934
+ const isPushedToCloud = entry?.c !== void 0;
1935
+ const hasThumbnails = entry ? isProcessed(entry) : false;
1936
+ try {
1937
+ const stats = await fs7.stat(absolutePath);
1938
+ if (stats.isDirectory()) {
1939
+ await fs7.rm(absolutePath, { recursive: true });
1940
+ const prefix = imageKey + "/";
1941
+ for (const key of Object.keys(meta)) {
1942
+ if (key.startsWith(prefix) || key === imageKey) {
1943
+ const keyEntry = meta[key];
1944
+ const keyHasThumbnails = keyEntry ? isProcessed(keyEntry) : false;
1945
+ if (keyEntry?.c !== void 0) {
1946
+ try {
1947
+ await deleteFromCdn(key, keyHasThumbnails);
1948
+ } catch {
1949
+ }
1950
+ } else {
1951
+ for (const thumbPath of getAllThumbnailPaths(key)) {
1952
+ const absoluteThumbPath = getPublicPath(thumbPath);
1953
+ try {
1954
+ await fs7.unlink(absoluteThumbPath);
1955
+ } catch {
1956
+ }
1957
+ }
1958
+ }
1959
+ delete meta[key];
2391
1960
  }
2392
1961
  }
2393
- const ext = path7.extname(imageKey).toLowerCase();
2394
- const isSvg = ext === ".svg";
2395
- if (isSvg) {
2396
- const imageDir = path7.dirname(imageKey.slice(1));
2397
- const imagesPath = getPublicPath("images", imageDir === "." ? "" : imageDir);
2398
- await fs7.mkdir(imagesPath, { recursive: true });
2399
- const fileName = path7.basename(imageKey);
2400
- const destPath = path7.join(imagesPath, fileName);
2401
- await fs7.writeFile(destPath, buffer);
2402
- meta[imageKey] = {
2403
- ...entry,
2404
- o: { w: 0, h: 0 },
2405
- b: "",
2406
- f: { w: 0, h: 0 }
2407
- };
2408
- if (isRemote) {
2409
- delete meta[imageKey].c;
2410
- }
2411
- } else {
2412
- const updatedEntry = await processImage(buffer, imageKey);
2413
- if (isInOurR2) {
2414
- updatedEntry.c = existingCdnIndex;
2415
- await deleteOriginalFromCdn(imageKey);
2416
- await deleteThumbnailsFromCdn(imageKey);
2417
- await uploadOriginalToCdn(imageKey);
2418
- await uploadToCdn(imageKey);
2419
- await deleteLocalThumbnails(imageKey);
1962
+ } else {
1963
+ await fs7.unlink(absolutePath);
1964
+ const isInImagesFolder = itemPath.startsWith("public/images/");
1965
+ if (!isInImagesFolder && entry) {
1966
+ if (isPushedToCloud) {
2420
1967
  try {
2421
- await fs7.unlink(originalPath);
1968
+ await deleteFromCdn(imageKey, hasThumbnails);
2422
1969
  } catch {
2423
1970
  }
1971
+ } else {
1972
+ for (const thumbPath of getAllThumbnailPaths(imageKey)) {
1973
+ const absoluteThumbPath = getPublicPath(thumbPath);
1974
+ try {
1975
+ await fs7.unlink(absoluteThumbPath);
1976
+ } catch {
1977
+ }
1978
+ }
2424
1979
  }
2425
- meta[imageKey] = updatedEntry;
1980
+ delete meta[imageKey];
1981
+ }
1982
+ }
1983
+ } catch {
1984
+ if (entry) {
1985
+ if (isPushedToCloud) {
1986
+ try {
1987
+ await deleteFromCdn(imageKey, hasThumbnails);
1988
+ } catch {
1989
+ }
1990
+ }
1991
+ delete meta[imageKey];
1992
+ } else {
1993
+ const prefix = imageKey + "/";
1994
+ let foundAny = false;
1995
+ for (const key of Object.keys(meta)) {
1996
+ if (key.startsWith(prefix)) {
1997
+ const keyEntry = meta[key];
1998
+ const keyHasThumbnails = keyEntry ? isProcessed(keyEntry) : false;
1999
+ if (keyEntry?.c !== void 0) {
2000
+ try {
2001
+ await deleteFromCdn(key, keyHasThumbnails);
2002
+ } catch {
2003
+ }
2004
+ }
2005
+ delete meta[key];
2006
+ foundAny = true;
2007
+ }
2008
+ }
2009
+ if (!foundAny) {
2010
+ errors.push(`Not found: ${itemPath}`);
2011
+ continue;
2426
2012
  }
2427
- processed.push(imageKey);
2428
- sendEvent({
2429
- type: "progress",
2430
- current: i + 1,
2431
- total,
2432
- processed: processed.length,
2433
- percent: Math.round((i + 1) / total * 100),
2434
- message: `Processed ${imageKey.slice(1)}`
2435
- });
2436
- } catch (error) {
2437
- console.error(`Failed to reprocess ${imageKey}:`, error);
2438
- errors.push(imageKey);
2439
- sendEvent({
2440
- type: "progress",
2441
- current: i + 1,
2442
- total,
2443
- processed: processed.length,
2444
- percent: Math.round((i + 1) / total * 100),
2445
- message: `Failed: ${imageKey.slice(1)}`
2446
- });
2447
2013
  }
2448
2014
  }
2449
- sendEvent({ type: "cleanup", message: "Saving metadata..." });
2450
- await saveMeta(meta);
2451
- let message = `Generated thumbnails for ${processed.length} image${processed.length !== 1 ? "s" : ""}.`;
2452
- if (errors.length > 0) {
2453
- message += ` ${errors.length} image${errors.length !== 1 ? "s" : ""} failed.`;
2454
- }
2455
- sendEvent({
2456
- type: "complete",
2457
- processed: processed.length,
2458
- errors: errors.length,
2459
- message
2460
- });
2461
- controller.close();
2015
+ deleted.push(itemPath);
2462
2016
  } catch (error) {
2463
- console.error("Reprocess stream error:", error);
2464
- sendEvent({ type: "error", message: "Failed to generate thumbnails" });
2465
- controller.close();
2017
+ console.error(`Failed to delete ${itemPath}:`, error);
2018
+ errors.push(itemPath);
2466
2019
  }
2467
2020
  }
2468
- });
2469
- return new Response(stream, {
2470
- headers: {
2471
- "Content-Type": "text/event-stream",
2472
- "Cache-Control": "no-cache",
2473
- Connection: "keep-alive"
2021
+ await saveMeta(meta);
2022
+ for (const folder of sourceFolders) {
2023
+ await deleteEmptyFolders(folder);
2474
2024
  }
2475
- });
2025
+ return jsonResponse({
2026
+ success: true,
2027
+ deleted,
2028
+ errors: errors.length > 0 ? errors : void 0
2029
+ });
2030
+ } catch (error) {
2031
+ console.error("Failed to delete:", error);
2032
+ return jsonResponse({ error: "Failed to delete files" }, { status: 500 });
2033
+ }
2476
2034
  }
2477
- async function handleDownloadStream(request) {
2478
- const { imageKeys, operationId } = await request.json();
2479
- if (!imageKeys || !Array.isArray(imageKeys) || imageKeys.length === 0) {
2480
- return jsonResponse({ error: "No image keys provided" }, { status: 400 });
2035
+ async function handleCreateFolder(request) {
2036
+ try {
2037
+ const { parentPath, name } = await request.json();
2038
+ if (!name || typeof name !== "string") {
2039
+ return jsonResponse({ error: "Folder name is required" }, { status: 400 });
2040
+ }
2041
+ const sanitizedName = slugifyFolderName(name);
2042
+ if (!sanitizedName) {
2043
+ return jsonResponse({ error: "Invalid folder name" }, { status: 400 });
2044
+ }
2045
+ const safePath = (parentPath || "public").replace(/\.\./g, "");
2046
+ const folderPath = getWorkspacePath(safePath, sanitizedName);
2047
+ if (!folderPath.startsWith(getPublicPath())) {
2048
+ return jsonResponse({ error: "Invalid path" }, { status: 400 });
2049
+ }
2050
+ try {
2051
+ await fs7.access(folderPath);
2052
+ return jsonResponse({ error: "A folder with this name already exists" }, { status: 400 });
2053
+ } catch {
2054
+ }
2055
+ await fs7.mkdir(folderPath, { recursive: true });
2056
+ return jsonResponse({ success: true, path: path7.join(safePath, sanitizedName) });
2057
+ } catch (error) {
2058
+ console.error("Failed to create folder:", error);
2059
+ return jsonResponse({ error: "Failed to create folder" }, { status: 500 });
2060
+ }
2061
+ }
2062
+ async function handleRename(request) {
2063
+ const publicUrl = process.env.CLOUDFLARE_R2_PUBLIC_URL?.replace(/\/$/, "");
2064
+ try {
2065
+ const { oldPath, newName } = await request.json();
2066
+ if (!oldPath || !newName) {
2067
+ return jsonResponse({ error: "Path and new name are required" }, { status: 400 });
2068
+ }
2069
+ const safePath = oldPath.replace(/\.\./g, "");
2070
+ const absoluteOldPath = getWorkspacePath(safePath);
2071
+ if (!absoluteOldPath.startsWith(getPublicPath())) {
2072
+ return jsonResponse({ error: "Invalid path" }, { status: 400 });
2073
+ }
2074
+ const oldRelativePath = safePath.replace(/^public\//, "");
2075
+ const oldKey = "/" + oldRelativePath;
2076
+ const isImage = isImageFile(path7.basename(oldPath));
2077
+ const meta = await loadMeta();
2078
+ const cdnUrls = getCdnUrls(meta);
2079
+ const entry = meta[oldKey];
2080
+ const isInCloud = entry?.c !== void 0;
2081
+ const fileCdnUrl = isInCloud && entry.c !== void 0 ? cdnUrls[entry.c] : void 0;
2082
+ const isInOurR2 = isInCloud && fileCdnUrl === publicUrl;
2083
+ const hasThumbnails = entry ? isProcessed(entry) : false;
2084
+ let hasLocalFile = false;
2085
+ let isFile = true;
2086
+ try {
2087
+ const stats = await fs7.stat(absoluteOldPath);
2088
+ hasLocalFile = true;
2089
+ isFile = stats.isFile();
2090
+ } catch {
2091
+ if (!isInCloud) {
2092
+ return jsonResponse({ error: "File or folder not found" }, { status: 404 });
2093
+ }
2094
+ }
2095
+ const sanitizedName = isFile ? slugifyFilename(newName) : slugifyFolderName(newName);
2096
+ if (!sanitizedName) {
2097
+ return jsonResponse({ error: "Invalid name" }, { status: 400 });
2098
+ }
2099
+ const parentDir = path7.dirname(absoluteOldPath);
2100
+ const absoluteNewPath = path7.join(parentDir, sanitizedName);
2101
+ const newRelativePath = path7.join(path7.dirname(oldRelativePath), sanitizedName);
2102
+ const newKey = "/" + newRelativePath;
2103
+ if (meta[newKey]) {
2104
+ return jsonResponse({ error: "An item with this name already exists" }, { status: 400 });
2105
+ }
2106
+ try {
2107
+ await fs7.access(absoluteNewPath);
2108
+ return jsonResponse({ error: "An item with this name already exists" }, { status: 400 });
2109
+ } catch {
2110
+ }
2111
+ if (isInOurR2 && !hasLocalFile && isImage) {
2112
+ await moveInCdn(oldKey, newKey, hasThumbnails);
2113
+ delete meta[oldKey];
2114
+ meta[newKey] = entry;
2115
+ await saveMeta(meta);
2116
+ const newPath2 = path7.join(path7.dirname(safePath), sanitizedName);
2117
+ return jsonResponse({ success: true, newPath: newPath2 });
2118
+ }
2119
+ if (hasLocalFile) {
2120
+ await fs7.rename(absoluteOldPath, absoluteNewPath);
2121
+ }
2122
+ if (isImage && entry) {
2123
+ const oldThumbPaths = getAllThumbnailPaths(oldKey);
2124
+ const newThumbPaths = getAllThumbnailPaths(newKey);
2125
+ for (let i = 0; i < oldThumbPaths.length; i++) {
2126
+ const oldThumbPath = getPublicPath(oldThumbPaths[i]);
2127
+ const newThumbPath = getPublicPath(newThumbPaths[i]);
2128
+ await fs7.mkdir(path7.dirname(newThumbPath), { recursive: true });
2129
+ try {
2130
+ await fs7.rename(oldThumbPath, newThumbPath);
2131
+ } catch {
2132
+ }
2133
+ }
2134
+ if (isInOurR2) {
2135
+ await moveInCdn(oldKey, newKey, hasThumbnails);
2136
+ try {
2137
+ await fs7.unlink(absoluteNewPath);
2138
+ } catch {
2139
+ }
2140
+ await deleteLocalThumbnails(newKey);
2141
+ }
2142
+ delete meta[oldKey];
2143
+ meta[newKey] = entry;
2144
+ await saveMeta(meta);
2145
+ }
2146
+ const newPath = path7.join(path7.dirname(safePath), sanitizedName);
2147
+ return jsonResponse({ success: true, newPath });
2148
+ } catch (error) {
2149
+ console.error("Failed to rename:", error);
2150
+ return jsonResponse({ error: "Failed to rename" }, { status: 500 });
2481
2151
  }
2152
+ }
2153
+ async function handleRenameStream(request) {
2154
+ const encoder = new TextEncoder();
2155
+ const publicUrl = process.env.CLOUDFLARE_R2_PUBLIC_URL?.replace(/\/$/, "");
2482
2156
  const stream = new ReadableStream({
2483
2157
  async start(controller) {
2484
- const encoder = new TextEncoder();
2485
2158
  const sendEvent = (data) => {
2486
- try {
2487
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}
2159
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}
2488
2160
 
2489
2161
  `));
2490
- } catch {
2491
- }
2492
2162
  };
2493
- sendEvent({ type: "start", total: imageKeys.length });
2494
- const downloaded = [];
2495
- const skipped = [];
2496
- const errors = [];
2497
- const isCancelled = () => operationId ? isOperationCancelled(operationId) : false;
2498
2163
  try {
2164
+ const { oldPath, newName, operationId } = await request.json();
2165
+ if (!oldPath || !newName) {
2166
+ sendEvent({ type: "error", message: "Path and new name are required" });
2167
+ controller.close();
2168
+ return;
2169
+ }
2170
+ const isCancelled = () => operationId ? isOperationCancelled(operationId) : false;
2171
+ const safePath = oldPath.replace(/\.\./g, "");
2172
+ const absoluteOldPath = getWorkspacePath(safePath);
2173
+ if (!absoluteOldPath.startsWith(getPublicPath())) {
2174
+ sendEvent({ type: "error", message: "Invalid path" });
2175
+ controller.close();
2176
+ return;
2177
+ }
2178
+ const oldRelativePath = safePath.replace(/^public\//, "");
2179
+ const isImagePath = isImageFile(path7.basename(oldPath));
2180
+ let hasLocalItem = false;
2181
+ let isFile = true;
2182
+ let isVirtualFolder = false;
2183
+ try {
2184
+ const stats = await fs7.stat(absoluteOldPath);
2185
+ hasLocalItem = true;
2186
+ isFile = stats.isFile();
2187
+ } catch {
2188
+ const meta2 = await loadMeta();
2189
+ const oldKey2 = "/" + oldRelativePath;
2190
+ const entry2 = meta2[oldKey2];
2191
+ if (entry2) {
2192
+ isFile = true;
2193
+ } else {
2194
+ const folderPrefix = oldKey2 + "/";
2195
+ const hasChildrenInMeta = Object.keys(meta2).some((key) => key.startsWith(folderPrefix));
2196
+ if (hasChildrenInMeta) {
2197
+ isFile = false;
2198
+ isVirtualFolder = true;
2199
+ } else {
2200
+ sendEvent({ type: "error", message: "File or folder not found" });
2201
+ controller.close();
2202
+ return;
2203
+ }
2204
+ }
2205
+ }
2206
+ const sanitizedName = isFile ? slugifyFilename(newName) : slugifyFolderName(newName);
2207
+ if (!sanitizedName) {
2208
+ sendEvent({ type: "error", message: "Invalid name" });
2209
+ controller.close();
2210
+ return;
2211
+ }
2212
+ const parentDir = path7.dirname(absoluteOldPath);
2213
+ const absoluteNewPath = path7.join(parentDir, sanitizedName);
2214
+ const newRelativePath = path7.join(path7.dirname(oldRelativePath), sanitizedName);
2215
+ const newPath = path7.join(path7.dirname(safePath), sanitizedName);
2499
2216
  const meta = await loadMeta();
2500
- for (let i = 0; i < imageKeys.length; i++) {
2501
- if (isCancelled()) {
2502
- await saveMeta(meta);
2503
- if (operationId) clearCancelledOperation(operationId);
2504
- sendEvent({
2505
- type: "stopped",
2506
- downloaded: downloaded.length,
2507
- message: `Stopped. ${downloaded.length} image${downloaded.length !== 1 ? "s" : ""} downloaded.`
2508
- });
2217
+ const cdnUrls = getCdnUrls(meta);
2218
+ if (isFile) {
2219
+ const newKey2 = "/" + newRelativePath;
2220
+ if (meta[newKey2]) {
2221
+ sendEvent({ type: "error", message: "An item with this name already exists" });
2509
2222
  controller.close();
2510
2223
  return;
2511
2224
  }
2512
- const imageKey = imageKeys[i];
2513
- const entry = getMetaEntry(meta, imageKey);
2514
- if (!entry || entry.c === void 0) {
2515
- skipped.push(imageKey);
2516
- sendEvent({
2517
- type: "progress",
2518
- current: i + 1,
2519
- total: imageKeys.length,
2520
- downloaded: downloaded.length,
2521
- message: `Skipped ${imageKey} (not on cloud)`
2522
- });
2523
- continue;
2524
- }
2225
+ }
2226
+ if (!isVirtualFolder) {
2525
2227
  try {
2526
- const imageBuffer = await downloadFromCdn(imageKey);
2228
+ await fs7.access(absoluteNewPath);
2229
+ sendEvent({ type: "error", message: "An item with this name already exists" });
2230
+ controller.close();
2231
+ return;
2232
+ } catch {
2233
+ }
2234
+ }
2235
+ if (isVirtualFolder) {
2236
+ const newPrefix = "/" + newRelativePath + "/";
2237
+ const hasConflict = Object.keys(meta).some((key) => key.startsWith(newPrefix));
2238
+ if (hasConflict) {
2239
+ sendEvent({ type: "error", message: "A folder with this name already exists" });
2240
+ controller.close();
2241
+ return;
2242
+ }
2243
+ }
2244
+ if (!isFile) {
2245
+ const oldPrefix = "/" + oldRelativePath + "/";
2246
+ const newPrefix = "/" + newRelativePath + "/";
2247
+ const itemsToUpdate = [];
2248
+ for (const [key, entry2] of Object.entries(meta)) {
2249
+ if (key.startsWith(oldPrefix) && entry2 && typeof entry2 === "object") {
2250
+ const newKey2 = key.replace(oldPrefix, newPrefix);
2251
+ itemsToUpdate.push({ oldKey: key, newKey: newKey2, entry: entry2 });
2252
+ }
2253
+ }
2254
+ const total = itemsToUpdate.length + 1;
2255
+ sendEvent({ type: "start", total, message: `Renaming folder with ${itemsToUpdate.length} item(s)...` });
2256
+ if (hasLocalItem) {
2257
+ await fs7.rename(absoluteOldPath, absoluteNewPath);
2258
+ const imagesDir = getPublicPath("/images");
2259
+ const oldThumbFolder2 = path7.join(imagesDir, oldRelativePath);
2260
+ const newThumbFolder = path7.join(imagesDir, newRelativePath);
2261
+ try {
2262
+ await fs7.access(oldThumbFolder2);
2263
+ await fs7.mkdir(path7.dirname(newThumbFolder), { recursive: true });
2264
+ await fs7.rename(oldThumbFolder2, newThumbFolder);
2265
+ } catch {
2266
+ }
2267
+ }
2268
+ sendEvent({ type: "progress", current: 1, total, renamed: 1, message: "Renamed folder" });
2269
+ let renamed = 1;
2270
+ for (const item of itemsToUpdate) {
2527
2271
  if (isCancelled()) {
2528
2272
  await saveMeta(meta);
2529
- if (operationId) clearCancelledOperation(operationId);
2530
- sendEvent({
2531
- type: "stopped",
2532
- downloaded: downloaded.length,
2533
- message: `Stopped. ${downloaded.length} image${downloaded.length !== 1 ? "s" : ""} downloaded.`
2534
- });
2273
+ sendEvent({ type: "complete", renamed, newPath, cancelled: true });
2535
2274
  controller.close();
2536
2275
  return;
2537
2276
  }
2538
- const localPath = getPublicPath(imageKey.replace(/^\//, ""));
2539
- await fs7.mkdir(path7.dirname(localPath), { recursive: true });
2540
- await fs7.writeFile(localPath, imageBuffer);
2541
- await deleteThumbnailsFromCdn(imageKey);
2542
- const wasProcessed = isProcessed(entry);
2543
- delete entry.c;
2544
- if (wasProcessed) {
2545
- const processedEntry = await processImage(imageBuffer, imageKey);
2546
- entry.sm = processedEntry.sm;
2547
- entry.md = processedEntry.md;
2548
- entry.lg = processedEntry.lg;
2549
- entry.f = processedEntry.f;
2277
+ const { oldKey: oldKey2, newKey: newKey2, entry: entry2 } = item;
2278
+ const isInCloud2 = entry2.c !== void 0;
2279
+ const fileCdnUrl2 = isInCloud2 ? cdnUrls[entry2.c] : void 0;
2280
+ const isInOurR22 = isInCloud2 && fileCdnUrl2 === publicUrl;
2281
+ const hasThumbnails2 = isProcessed(entry2);
2282
+ if (isInOurR22) {
2283
+ try {
2284
+ await moveInCdn(oldKey2, newKey2, hasThumbnails2);
2285
+ const localFilePath = getPublicPath(newKey2);
2286
+ try {
2287
+ await fs7.unlink(localFilePath);
2288
+ } catch {
2289
+ }
2290
+ if (hasThumbnails2) {
2291
+ await deleteLocalThumbnails(newKey2);
2292
+ }
2293
+ } catch (err) {
2294
+ console.error(`Failed to rename in CDN ${oldKey2}:`, err);
2295
+ }
2550
2296
  }
2551
- downloaded.push(imageKey);
2552
- sendEvent({
2553
- type: "progress",
2554
- current: i + 1,
2555
- total: imageKeys.length,
2556
- downloaded: downloaded.length,
2557
- message: `Downloaded ${imageKey}`
2558
- });
2559
- } catch (error) {
2560
- console.error(`Failed to download ${imageKey}:`, error);
2561
- errors.push(imageKey);
2297
+ delete meta[oldKey2];
2298
+ meta[newKey2] = entry2;
2299
+ renamed++;
2562
2300
  sendEvent({
2563
2301
  type: "progress",
2564
- current: i + 1,
2565
- total: imageKeys.length,
2566
- downloaded: downloaded.length,
2567
- message: `Failed to download ${imageKey}`
2302
+ current: renamed,
2303
+ total,
2304
+ renamed,
2305
+ message: `Renamed ${path7.basename(newKey2)}`
2568
2306
  });
2569
2307
  }
2308
+ await saveMeta(meta);
2309
+ await deleteEmptyFolders(absoluteOldPath);
2310
+ const oldThumbFolder = path7.join(getPublicPath("/images"), oldRelativePath);
2311
+ await deleteEmptyFolders(oldThumbFolder);
2312
+ sendEvent({ type: "complete", renamed, newPath });
2313
+ controller.close();
2314
+ return;
2570
2315
  }
2571
- await saveMeta(meta);
2572
- let message = `Downloaded ${downloaded.length} image${downloaded.length !== 1 ? "s" : ""}.`;
2573
- if (skipped.length > 0) {
2574
- message += ` ${skipped.length} image${skipped.length !== 1 ? "s were" : " was"} not on cloud.`;
2316
+ const oldKey = "/" + oldRelativePath;
2317
+ const newKey = "/" + newRelativePath;
2318
+ const entry = meta[oldKey];
2319
+ const isInCloud = entry?.c !== void 0;
2320
+ const fileCdnUrl = isInCloud && entry?.c !== void 0 ? cdnUrls[entry.c] : void 0;
2321
+ const isInOurR2 = isInCloud && fileCdnUrl === publicUrl;
2322
+ const hasThumbnails = entry ? isProcessed(entry) : false;
2323
+ sendEvent({ type: "start", total: 1, message: "Renaming file..." });
2324
+ if (isInOurR2 && !hasLocalItem && isImagePath) {
2325
+ await moveInCdn(oldKey, newKey, hasThumbnails);
2326
+ delete meta[oldKey];
2327
+ if (entry) meta[newKey] = entry;
2328
+ await saveMeta(meta);
2329
+ sendEvent({ type: "complete", renamed: 1, newPath });
2330
+ controller.close();
2331
+ return;
2575
2332
  }
2576
- if (errors.length > 0) {
2577
- message += ` ${errors.length} image${errors.length !== 1 ? "s" : ""} failed.`;
2333
+ if (hasLocalItem) {
2334
+ await fs7.rename(absoluteOldPath, absoluteNewPath);
2578
2335
  }
2579
- sendEvent({
2580
- type: "complete",
2581
- downloaded: downloaded.length,
2582
- skipped: skipped.length,
2583
- errors: errors.length,
2584
- message
2585
- });
2336
+ if (isImagePath && entry) {
2337
+ const oldThumbPaths = getAllThumbnailPaths(oldKey);
2338
+ const newThumbPaths = getAllThumbnailPaths(newKey);
2339
+ for (let i = 0; i < oldThumbPaths.length; i++) {
2340
+ const oldThumbPath = getPublicPath(oldThumbPaths[i]);
2341
+ const newThumbPath = getPublicPath(newThumbPaths[i]);
2342
+ await fs7.mkdir(path7.dirname(newThumbPath), { recursive: true });
2343
+ try {
2344
+ await fs7.rename(oldThumbPath, newThumbPath);
2345
+ } catch {
2346
+ }
2347
+ }
2348
+ if (isInOurR2) {
2349
+ await moveInCdn(oldKey, newKey, hasThumbnails);
2350
+ try {
2351
+ await fs7.unlink(absoluteNewPath);
2352
+ } catch {
2353
+ }
2354
+ await deleteLocalThumbnails(newKey);
2355
+ }
2356
+ delete meta[oldKey];
2357
+ meta[newKey] = entry;
2358
+ await saveMeta(meta);
2359
+ }
2360
+ sendEvent({ type: "complete", renamed: 1, newPath });
2361
+ controller.close();
2586
2362
  } catch (error) {
2587
- console.error("Download stream error:", error);
2588
- sendEvent({ type: "error", message: "Failed to download images" });
2589
- } finally {
2363
+ console.error("Rename stream error:", error);
2364
+ sendEvent({ type: "error", message: "Failed to rename" });
2590
2365
  controller.close();
2591
2366
  }
2592
2367
  }
2593
2368
  });
2594
- return new Response(stream, {
2595
- headers: {
2596
- "Content-Type": "text/event-stream",
2597
- "Cache-Control": "no-cache",
2598
- "Connection": "keep-alive"
2599
- }
2600
- });
2369
+ return streamResponse(stream);
2601
2370
  }
2602
- async function handlePushUpdatesStream(request) {
2603
- const accountId = process.env.CLOUDFLARE_R2_ACCOUNT_ID;
2604
- const accessKeyId = process.env.CLOUDFLARE_R2_ACCESS_KEY_ID;
2605
- const secretAccessKey = process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY;
2606
- const bucketName = process.env.CLOUDFLARE_R2_BUCKET_NAME;
2607
- const publicUrl = process.env.CLOUDFLARE_R2_PUBLIC_URL?.replace(/\/$/, "");
2371
+ async function handleMoveStream(request) {
2608
2372
  const encoder = new TextEncoder();
2609
2373
  const stream = new ReadableStream({
2610
2374
  async start(controller) {
@@ -2614,132 +2378,455 @@ async function handlePushUpdatesStream(request) {
2614
2378
  `));
2615
2379
  };
2616
2380
  try {
2617
- if (!accountId || !accessKeyId || !secretAccessKey || !bucketName || !publicUrl) {
2618
- sendEvent({ type: "error", message: "R2 not configured" });
2381
+ const { paths, destination, operationId } = await request.json();
2382
+ if (!paths || !Array.isArray(paths) || paths.length === 0) {
2383
+ sendEvent({ type: "error", message: "Paths are required" });
2619
2384
  controller.close();
2620
2385
  return;
2621
2386
  }
2622
- const { paths } = await request.json();
2623
- if (!paths || !Array.isArray(paths) || paths.length === 0) {
2624
- sendEvent({ type: "error", message: "No paths provided" });
2387
+ if (!destination || typeof destination !== "string") {
2388
+ sendEvent({ type: "error", message: "Destination is required" });
2625
2389
  controller.close();
2626
2390
  return;
2627
2391
  }
2628
- const s3 = new S3Client2({
2629
- region: "auto",
2630
- endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
2631
- credentials: { accessKeyId, secretAccessKey }
2632
- });
2392
+ const isCancelled = () => operationId ? isOperationCancelled(operationId) : false;
2393
+ const safeDestination = destination.replace(/\.\./g, "");
2394
+ const absoluteDestination = getWorkspacePath(safeDestination);
2395
+ if (!absoluteDestination.startsWith(getPublicPath())) {
2396
+ sendEvent({ type: "error", message: "Invalid destination" });
2397
+ controller.close();
2398
+ return;
2399
+ }
2400
+ await fs7.mkdir(absoluteDestination, { recursive: true });
2633
2401
  const meta = await loadMeta();
2634
2402
  const cdnUrls = getCdnUrls(meta);
2635
- const r2PublicUrl = publicUrl.replace(/\/$/, "");
2636
- const pushed = [];
2637
- const skipped = [];
2403
+ const r2PublicUrl = process.env.CLOUDFLARE_R2_PUBLIC_URL?.replace(/\/$/, "") || "";
2404
+ const moved = [];
2638
2405
  const errors = [];
2639
- const total = paths.length;
2640
- sendEvent({ type: "start", total });
2641
- for (let i = 0; i < paths.length; i++) {
2642
- const itemPath = paths[i];
2643
- const key = itemPath.startsWith("public/") ? "/" + itemPath.slice(7) : itemPath;
2644
- const entry = meta[key];
2645
- if (!entry || entry.u !== 1) {
2646
- skipped.push(key);
2647
- sendEvent({
2648
- type: "progress",
2649
- current: i + 1,
2650
- total,
2651
- pushed: pushed.length,
2652
- percent: Math.round((i + 1) / total * 100),
2653
- currentFile: path7.basename(key)
2654
- });
2406
+ const sourceFolders = /* @__PURE__ */ new Set();
2407
+ let totalFiles = 0;
2408
+ const expandedItems = [];
2409
+ for (const itemPath of paths) {
2410
+ const safePath = itemPath.replace(/\.\./g, "");
2411
+ const itemName = path7.basename(safePath);
2412
+ const oldRelativePath = safePath.replace(/^public\/?/, "");
2413
+ const destWithoutPublic = safeDestination.replace(/^public\/?/, "");
2414
+ const newRelativePath = destWithoutPublic ? path7.join(destWithoutPublic, itemName) : itemName;
2415
+ const oldKey = "/" + oldRelativePath;
2416
+ const newKey = "/" + newRelativePath;
2417
+ const newAbsolutePath = path7.join(absoluteDestination, itemName);
2418
+ const absolutePath = getWorkspacePath(safePath);
2419
+ let hasLocalItem = false;
2420
+ let isDirectory = false;
2421
+ try {
2422
+ const stats = await fs7.stat(absolutePath);
2423
+ hasLocalItem = true;
2424
+ isDirectory = stats.isDirectory();
2425
+ } catch {
2426
+ }
2427
+ if (hasLocalItem && isDirectory) {
2428
+ const countFilesRecursive = async (dir) => {
2429
+ let count = 0;
2430
+ const entries = await fs7.readdir(dir, { withFileTypes: true });
2431
+ for (const entry of entries) {
2432
+ if (entry.isDirectory()) {
2433
+ count += await countFilesRecursive(path7.join(dir, entry.name));
2434
+ } else {
2435
+ count++;
2436
+ }
2437
+ }
2438
+ return count;
2439
+ };
2440
+ const localFileCount = await countFilesRecursive(absolutePath);
2441
+ const folderPrefix = oldKey + "/";
2442
+ let cloudOnlyCount = 0;
2443
+ for (const metaKey of Object.keys(meta)) {
2444
+ if (metaKey.startsWith(folderPrefix)) {
2445
+ const relPath = metaKey.slice(folderPrefix.length);
2446
+ const localPath = path7.join(absolutePath, relPath);
2447
+ try {
2448
+ await fs7.access(localPath);
2449
+ } catch {
2450
+ cloudOnlyCount++;
2451
+ }
2452
+ }
2453
+ }
2454
+ totalFiles += localFileCount + cloudOnlyCount;
2455
+ expandedItems.push({ itemPath, safePath, itemName, oldKey, newKey, newAbsolutePath, isVirtualFolder: false });
2456
+ } else if (!hasLocalItem) {
2457
+ const folderPrefix = oldKey + "/";
2458
+ const virtualItems = [];
2459
+ for (const [key, metaEntry] of Object.entries(meta)) {
2460
+ if (key.startsWith(folderPrefix) && metaEntry && typeof metaEntry === "object") {
2461
+ const relativePath = key.slice(folderPrefix.length);
2462
+ const destNewKey = newKey + "/" + relativePath;
2463
+ virtualItems.push({ oldKey: key, newKey: destNewKey, entry: metaEntry });
2464
+ }
2465
+ }
2466
+ if (virtualItems.length > 0) {
2467
+ totalFiles += virtualItems.length;
2468
+ expandedItems.push({ itemPath, safePath, itemName, oldKey, newKey, newAbsolutePath, isVirtualFolder: true, virtualFolderItems: virtualItems });
2469
+ sourceFolders.add(absolutePath);
2470
+ } else {
2471
+ totalFiles++;
2472
+ expandedItems.push({ itemPath, safePath, itemName, oldKey, newKey, newAbsolutePath, isVirtualFolder: false });
2473
+ }
2474
+ } else {
2475
+ totalFiles++;
2476
+ expandedItems.push({ itemPath, safePath, itemName, oldKey, newKey, newAbsolutePath, isVirtualFolder: false });
2477
+ }
2478
+ }
2479
+ sendEvent({ type: "start", total: totalFiles });
2480
+ let processedFiles = 0;
2481
+ for (const expandedItem of expandedItems) {
2482
+ if (isCancelled()) {
2483
+ sendEvent({ type: "complete", moved: moved.length, errors: errors.length, errorMessages: errors, cancelled: true });
2484
+ controller.close();
2485
+ return;
2486
+ }
2487
+ const { itemPath, safePath, itemName, oldKey, newKey, newAbsolutePath, isVirtualFolder, virtualFolderItems } = expandedItem;
2488
+ if (isVirtualFolder && virtualFolderItems) {
2489
+ for (const vItem of virtualFolderItems) {
2490
+ if (isCancelled()) {
2491
+ sendEvent({ type: "complete", moved: moved.length, errors: errors.length, errorMessages: errors, cancelled: true });
2492
+ controller.close();
2493
+ return;
2494
+ }
2495
+ const itemEntry = vItem.entry;
2496
+ const isItemInCloud = itemEntry.c !== void 0;
2497
+ const itemCdnUrl = isItemInCloud ? cdnUrls[itemEntry.c] : void 0;
2498
+ const isItemInR2 = isItemInCloud && itemCdnUrl === r2PublicUrl;
2499
+ const itemHasThumbnails = isProcessed(itemEntry);
2500
+ let vItemMoved = false;
2501
+ if (isItemInR2) {
2502
+ try {
2503
+ await moveInCdn(vItem.oldKey, vItem.newKey, itemHasThumbnails);
2504
+ vItemMoved = true;
2505
+ } catch (err) {
2506
+ console.error(`Failed to move cloud item ${vItem.oldKey}:`, err);
2507
+ delete meta[vItem.oldKey];
2508
+ }
2509
+ }
2510
+ if (vItemMoved) {
2511
+ delete meta[vItem.oldKey];
2512
+ meta[vItem.newKey] = itemEntry;
2513
+ }
2514
+ processedFiles++;
2515
+ sendEvent({
2516
+ type: "progress",
2517
+ current: processedFiles,
2518
+ total: totalFiles,
2519
+ moved: moved.length,
2520
+ percent: Math.round(processedFiles / totalFiles * 100),
2521
+ currentFile: path7.basename(vItem.newKey)
2522
+ });
2523
+ }
2524
+ const newFolderPath = getPublicPath(newKey);
2525
+ await deleteEmptyFolders(newFolderPath);
2526
+ const newThumbFolder = path7.join(getPublicPath("images"), newKey.slice(1));
2527
+ await deleteEmptyFolders(newThumbFolder);
2528
+ moved.push(itemPath);
2655
2529
  continue;
2656
2530
  }
2657
- const fileCdnUrl = entry.c !== void 0 ? cdnUrls[entry.c]?.replace(/\/$/, "") : void 0;
2658
- if (!fileCdnUrl || fileCdnUrl !== r2PublicUrl) {
2659
- skipped.push(key);
2531
+ if (meta[newKey]) {
2532
+ errors.push(`${itemName} already exists in destination`);
2533
+ processedFiles++;
2660
2534
  sendEvent({
2661
2535
  type: "progress",
2662
- current: i + 1,
2663
- total,
2664
- pushed: pushed.length,
2665
- percent: Math.round((i + 1) / total * 100),
2666
- currentFile: path7.basename(key)
2536
+ current: processedFiles,
2537
+ total: totalFiles,
2538
+ moved: moved.length,
2539
+ percent: Math.round(processedFiles / totalFiles * 100),
2540
+ currentFile: itemName
2667
2541
  });
2668
2542
  continue;
2669
2543
  }
2544
+ const entry = meta[oldKey];
2545
+ const isImage = isImageFile(itemName);
2546
+ const isInCloud = entry?.c !== void 0;
2547
+ const fileCdnUrl = isInCloud && entry.c !== void 0 ? cdnUrls[entry.c] : void 0;
2548
+ const isRemote = isInCloud && (!r2PublicUrl || fileCdnUrl !== r2PublicUrl);
2549
+ const isPushedToR2 = isInCloud && r2PublicUrl && fileCdnUrl === r2PublicUrl;
2550
+ const hasProcessedThumbnails = isProcessed(entry);
2670
2551
  try {
2671
- const localPath = getPublicPath(key);
2672
- const buffer = await fs7.readFile(localPath);
2673
- const contentType = getContentType(path7.basename(key));
2674
- const uploadKey = key.startsWith("/") ? key.slice(1) : key;
2675
- try {
2676
- await s3.send(new DeleteObjectCommand2({
2677
- Bucket: bucketName,
2678
- Key: uploadKey
2679
- }));
2680
- } catch {
2681
- }
2682
- await s3.send(new PutObjectCommand2({
2683
- Bucket: bucketName,
2684
- Key: uploadKey,
2685
- Body: buffer,
2686
- ContentType: contentType
2687
- }));
2688
- if (isProcessed(entry)) {
2689
- await deleteThumbnailsFromCdn(key);
2690
- const processedEntry = await processImage(buffer, key);
2691
- Object.assign(entry, processedEntry);
2692
- await uploadToCdn(key);
2693
- await deleteLocalThumbnails(key);
2552
+ const sourceFolder = path7.dirname(getWorkspacePath(safePath));
2553
+ sourceFolders.add(sourceFolder);
2554
+ if (isRemote && isImage) {
2555
+ const remoteUrl = `${fileCdnUrl}${oldKey}`;
2556
+ const buffer = await downloadFromRemoteUrl(remoteUrl);
2557
+ await fs7.mkdir(path7.dirname(newAbsolutePath), { recursive: true });
2558
+ await fs7.writeFile(newAbsolutePath, buffer);
2559
+ const newEntry = {
2560
+ o: entry?.o,
2561
+ b: entry?.b
2562
+ };
2563
+ delete meta[oldKey];
2564
+ meta[newKey] = newEntry;
2565
+ moved.push(itemPath);
2566
+ processedFiles++;
2567
+ sendEvent({
2568
+ type: "progress",
2569
+ current: processedFiles,
2570
+ total: totalFiles,
2571
+ moved: moved.length,
2572
+ percent: Math.round(processedFiles / totalFiles * 100),
2573
+ currentFile: itemName
2574
+ });
2575
+ } else if (isPushedToR2 && isImage) {
2576
+ await moveInCdn(oldKey, newKey, hasProcessedThumbnails);
2577
+ delete meta[oldKey];
2578
+ if (entry) {
2579
+ meta[newKey] = entry;
2580
+ }
2581
+ moved.push(itemPath);
2582
+ processedFiles++;
2583
+ sendEvent({
2584
+ type: "progress",
2585
+ current: processedFiles,
2586
+ total: totalFiles,
2587
+ moved: moved.length,
2588
+ percent: Math.round(processedFiles / totalFiles * 100),
2589
+ currentFile: itemName
2590
+ });
2591
+ } else {
2592
+ const absolutePath = getWorkspacePath(safePath);
2593
+ if (absoluteDestination.startsWith(absolutePath + path7.sep)) {
2594
+ errors.push(`Cannot move ${itemName} into itself`);
2595
+ processedFiles++;
2596
+ sendEvent({
2597
+ type: "progress",
2598
+ current: processedFiles,
2599
+ total: totalFiles,
2600
+ moved: moved.length,
2601
+ percent: Math.round(processedFiles / totalFiles * 100),
2602
+ currentFile: itemName
2603
+ });
2604
+ continue;
2605
+ }
2606
+ try {
2607
+ await fs7.access(absolutePath);
2608
+ } catch {
2609
+ errors.push(`${itemName} not found`);
2610
+ processedFiles++;
2611
+ sendEvent({
2612
+ type: "progress",
2613
+ current: processedFiles,
2614
+ total: totalFiles,
2615
+ moved: moved.length,
2616
+ percent: Math.round(processedFiles / totalFiles * 100),
2617
+ currentFile: itemName
2618
+ });
2619
+ continue;
2620
+ }
2621
+ try {
2622
+ await fs7.access(newAbsolutePath);
2623
+ errors.push(`${itemName} already exists in destination`);
2624
+ processedFiles++;
2625
+ sendEvent({
2626
+ type: "progress",
2627
+ current: processedFiles,
2628
+ total: totalFiles,
2629
+ moved: moved.length,
2630
+ percent: Math.round(processedFiles / totalFiles * 100),
2631
+ currentFile: itemName
2632
+ });
2633
+ continue;
2634
+ } catch {
2635
+ }
2636
+ const stats = await fs7.stat(absolutePath);
2637
+ if (stats.isFile()) {
2638
+ await fs7.mkdir(path7.dirname(newAbsolutePath), { recursive: true });
2639
+ await fs7.rename(absolutePath, newAbsolutePath);
2640
+ if (isImage && entry) {
2641
+ const oldThumbPaths = getAllThumbnailPaths(oldKey);
2642
+ const newThumbPaths = getAllThumbnailPaths(newKey);
2643
+ for (let j = 0; j < oldThumbPaths.length; j++) {
2644
+ const oldThumbPath = getPublicPath(oldThumbPaths[j]);
2645
+ const newThumbPath = getPublicPath(newThumbPaths[j]);
2646
+ try {
2647
+ await fs7.access(oldThumbPath);
2648
+ sourceFolders.add(path7.dirname(oldThumbPath));
2649
+ await fs7.mkdir(path7.dirname(newThumbPath), { recursive: true });
2650
+ await fs7.rename(oldThumbPath, newThumbPath);
2651
+ } catch {
2652
+ }
2653
+ }
2654
+ const fileIsInCloud = entry.c !== void 0;
2655
+ const fileCdnUrl2 = fileIsInCloud ? cdnUrls[entry.c] : void 0;
2656
+ const fileIsInR2 = fileIsInCloud && fileCdnUrl2 === r2PublicUrl;
2657
+ const fileHasThumbs = isProcessed(entry);
2658
+ if (fileIsInR2) {
2659
+ await deleteFromCdn(oldKey, fileHasThumbs);
2660
+ await uploadOriginalToCdn(newKey);
2661
+ if (fileHasThumbs) {
2662
+ await uploadToCdn(newKey);
2663
+ }
2664
+ }
2665
+ delete meta[oldKey];
2666
+ meta[newKey] = entry;
2667
+ }
2668
+ processedFiles++;
2669
+ sendEvent({
2670
+ type: "progress",
2671
+ current: processedFiles,
2672
+ total: totalFiles,
2673
+ moved: moved.length,
2674
+ percent: Math.round(processedFiles / totalFiles * 100),
2675
+ currentFile: itemName
2676
+ });
2677
+ moved.push(itemPath);
2678
+ } else if (stats.isDirectory()) {
2679
+ const oldPrefix = oldKey + "/";
2680
+ const newPrefix = newKey + "/";
2681
+ const localFiles = [];
2682
+ const collectLocalFiles = async (dir, relativeDir) => {
2683
+ const entries = await fs7.readdir(dir, { withFileTypes: true });
2684
+ for (const dirEntry of entries) {
2685
+ const entryRelPath = relativeDir ? `${relativeDir}/${dirEntry.name}` : dirEntry.name;
2686
+ if (dirEntry.isDirectory()) {
2687
+ await collectLocalFiles(path7.join(dir, dirEntry.name), entryRelPath);
2688
+ } else {
2689
+ localFiles.push({ relativePath: entryRelPath, isImage: isImageFile(dirEntry.name) });
2690
+ }
2691
+ }
2692
+ };
2693
+ await collectLocalFiles(absolutePath, "");
2694
+ const cloudOnlyFiles = [];
2695
+ for (const [metaKey, metaEntry] of Object.entries(meta)) {
2696
+ if (metaKey.startsWith(oldPrefix) && metaEntry && typeof metaEntry === "object") {
2697
+ const relPath = metaKey.slice(oldPrefix.length);
2698
+ const localPath = path7.join(absolutePath, relPath);
2699
+ try {
2700
+ await fs7.access(localPath);
2701
+ } catch {
2702
+ cloudOnlyFiles.push({
2703
+ oldKey: metaKey,
2704
+ newKey: newPrefix + relPath,
2705
+ entry: metaEntry
2706
+ });
2707
+ }
2708
+ }
2709
+ }
2710
+ for (const localFile of localFiles) {
2711
+ if (isCancelled()) {
2712
+ await saveMeta(meta);
2713
+ sendEvent({ type: "complete", moved: moved.length, errors: errors.length, errorMessages: errors, cancelled: true });
2714
+ controller.close();
2715
+ return;
2716
+ }
2717
+ const fileOldPath = path7.join(absolutePath, localFile.relativePath);
2718
+ const fileNewPath = path7.join(newAbsolutePath, localFile.relativePath);
2719
+ const fileOldKey = oldPrefix + localFile.relativePath;
2720
+ const fileNewKey = newPrefix + localFile.relativePath;
2721
+ const fileEntry = meta[fileOldKey];
2722
+ sourceFolders.add(path7.dirname(fileOldPath));
2723
+ await fs7.mkdir(path7.dirname(fileNewPath), { recursive: true });
2724
+ await fs7.rename(fileOldPath, fileNewPath);
2725
+ if (localFile.isImage && fileEntry) {
2726
+ const oldThumbPaths = getAllThumbnailPaths(fileOldKey);
2727
+ const newThumbPaths = getAllThumbnailPaths(fileNewKey);
2728
+ for (let t = 0; t < oldThumbPaths.length; t++) {
2729
+ const oldThumbPath = getPublicPath(oldThumbPaths[t]);
2730
+ const newThumbPath = getPublicPath(newThumbPaths[t]);
2731
+ try {
2732
+ await fs7.access(oldThumbPath);
2733
+ sourceFolders.add(path7.dirname(oldThumbPath));
2734
+ await fs7.mkdir(path7.dirname(newThumbPath), { recursive: true });
2735
+ await fs7.rename(oldThumbPath, newThumbPath);
2736
+ } catch {
2737
+ }
2738
+ }
2739
+ const fileIsInCloud = fileEntry.c !== void 0;
2740
+ const fileCdnUrl2 = fileIsInCloud ? cdnUrls[fileEntry.c] : void 0;
2741
+ const fileIsInR2 = fileIsInCloud && fileCdnUrl2 === r2PublicUrl;
2742
+ const fileHasThumbs = isProcessed(fileEntry);
2743
+ if (fileIsInR2) {
2744
+ await moveInCdn(fileOldKey, fileNewKey, fileHasThumbs);
2745
+ }
2746
+ delete meta[fileOldKey];
2747
+ meta[fileNewKey] = fileEntry;
2748
+ }
2749
+ processedFiles++;
2750
+ sendEvent({
2751
+ type: "progress",
2752
+ current: processedFiles,
2753
+ total: totalFiles,
2754
+ moved: moved.length,
2755
+ percent: Math.round(processedFiles / totalFiles * 100),
2756
+ currentFile: path7.basename(localFile.relativePath)
2757
+ });
2758
+ }
2759
+ for (const cloudFile of cloudOnlyFiles) {
2760
+ if (isCancelled()) {
2761
+ await saveMeta(meta);
2762
+ sendEvent({ type: "complete", moved: moved.length, errors: errors.length, errorMessages: errors, cancelled: true });
2763
+ controller.close();
2764
+ return;
2765
+ }
2766
+ const cloudEntry = cloudFile.entry;
2767
+ const cloudIsInCloud = cloudEntry.c !== void 0;
2768
+ const cloudCdnUrl = cloudIsInCloud ? cdnUrls[cloudEntry.c] : void 0;
2769
+ const cloudIsInR2 = cloudIsInCloud && cloudCdnUrl === r2PublicUrl;
2770
+ const cloudHasThumbs = isProcessed(cloudEntry);
2771
+ let cloudFileMoved = false;
2772
+ if (cloudIsInR2) {
2773
+ try {
2774
+ await moveInCdn(cloudFile.oldKey, cloudFile.newKey, cloudHasThumbs);
2775
+ cloudFileMoved = true;
2776
+ } catch (err) {
2777
+ console.error(`Failed to move cloud file ${cloudFile.oldKey}:`, err);
2778
+ delete meta[cloudFile.oldKey];
2779
+ }
2780
+ }
2781
+ if (cloudFileMoved) {
2782
+ delete meta[cloudFile.oldKey];
2783
+ meta[cloudFile.newKey] = cloudEntry;
2784
+ }
2785
+ processedFiles++;
2786
+ sendEvent({
2787
+ type: "progress",
2788
+ current: processedFiles,
2789
+ total: totalFiles,
2790
+ moved: moved.length,
2791
+ percent: Math.round(processedFiles / totalFiles * 100),
2792
+ currentFile: path7.basename(cloudFile.newKey)
2793
+ });
2794
+ }
2795
+ sourceFolders.add(absolutePath);
2796
+ const oldThumbRelPath = oldKey.slice(1);
2797
+ const oldThumbFolder = path7.join(getPublicPath("images"), oldThumbRelPath);
2798
+ sourceFolders.add(oldThumbFolder);
2799
+ moved.push(itemPath);
2800
+ }
2694
2801
  }
2695
- await fs7.unlink(localPath);
2696
- delete entry.u;
2697
- pushed.push(key);
2698
- sendEvent({
2699
- type: "progress",
2700
- current: i + 1,
2701
- total,
2702
- pushed: pushed.length,
2703
- percent: Math.round((i + 1) / total * 100),
2704
- currentFile: path7.basename(key)
2705
- });
2706
- } catch (error) {
2707
- console.error(`Failed to push update for ${key}:`, error);
2708
- errors.push(key);
2802
+ } catch (err) {
2803
+ console.error(`Failed to move ${itemName}:`, err);
2804
+ errors.push(`Failed to move ${itemName}`);
2805
+ processedFiles++;
2709
2806
  sendEvent({
2710
2807
  type: "progress",
2711
- current: i + 1,
2712
- total,
2713
- pushed: pushed.length,
2714
- percent: Math.round((i + 1) / total * 100),
2715
- currentFile: path7.basename(key),
2716
- message: `Failed: ${path7.basename(key)}`
2808
+ current: processedFiles,
2809
+ total: totalFiles,
2810
+ moved: moved.length,
2811
+ percent: Math.round(processedFiles / totalFiles * 100),
2812
+ currentFile: itemName
2717
2813
  });
2718
2814
  }
2719
2815
  }
2720
- sendEvent({ type: "cleanup", message: "Cleaning up..." });
2721
- for (const itemPath of pushed) {
2722
- const localPath = getPublicPath(itemPath);
2723
- await deleteEmptyFolders(path7.dirname(localPath));
2724
- }
2725
2816
  await saveMeta(meta);
2726
- let message = `Pushed ${pushed.length} update${pushed.length !== 1 ? "s" : ""} to cloud.`;
2727
- if (skipped.length > 0) {
2728
- message += ` ${skipped.length} file${skipped.length !== 1 ? "s" : ""} skipped.`;
2729
- }
2730
- if (errors.length > 0) {
2731
- message += ` ${errors.length} file${errors.length !== 1 ? "s" : ""} failed.`;
2817
+ for (const folder of sourceFolders) {
2818
+ await deleteEmptyFolders(folder);
2732
2819
  }
2820
+ await deleteEmptyFolders(absoluteDestination);
2733
2821
  sendEvent({
2734
2822
  type: "complete",
2735
- pushed: pushed.length,
2736
- skipped: skipped.length,
2823
+ moved: moved.length,
2737
2824
  errors: errors.length,
2738
- message
2825
+ errorMessages: errors
2739
2826
  });
2740
2827
  } catch (error) {
2741
- console.error("Push updates error:", error);
2742
- sendEvent({ type: "error", message: "Failed to push updates" });
2828
+ console.error("Failed to move:", error);
2829
+ sendEvent({ type: "error", message: "Failed to move items" });
2743
2830
  } finally {
2744
2831
  controller.close();
2745
2832
  }
@@ -2753,63 +2840,6 @@ async function handlePushUpdatesStream(request) {
2753
2840
  }
2754
2841
  });
2755
2842
  }
2756
- async function handleCancelStreamOperation(request) {
2757
- try {
2758
- const { operationId } = await request.json();
2759
- if (!operationId || typeof operationId !== "string") {
2760
- return jsonResponse({ error: "No operation ID provided" }, { status: 400 });
2761
- }
2762
- cancelOperation(operationId);
2763
- return jsonResponse({ success: true, operationId });
2764
- } catch (error) {
2765
- console.error("Failed to cancel operation:", error);
2766
- return jsonResponse({ error: "Failed to cancel operation" }, { status: 500 });
2767
- }
2768
- }
2769
- async function handleCancelUpdates(request) {
2770
- try {
2771
- const { paths } = await request.json();
2772
- if (!paths || !Array.isArray(paths) || paths.length === 0) {
2773
- return jsonResponse({ error: "No paths provided" }, { status: 400 });
2774
- }
2775
- const meta = await loadMeta();
2776
- const cancelled = [];
2777
- const skipped = [];
2778
- const errors = [];
2779
- const foldersToClean = /* @__PURE__ */ new Set();
2780
- for (const itemPath of paths) {
2781
- const key = itemPath.startsWith("public/") ? "/" + itemPath.slice(7) : itemPath;
2782
- const entry = meta[key];
2783
- if (!entry || entry.u !== 1) {
2784
- skipped.push(key);
2785
- continue;
2786
- }
2787
- try {
2788
- const localPath = getPublicPath(key);
2789
- await fs7.unlink(localPath);
2790
- foldersToClean.add(path7.dirname(localPath));
2791
- delete entry.u;
2792
- cancelled.push(key);
2793
- } catch (error) {
2794
- console.error(`Failed to cancel update for ${key}:`, error);
2795
- errors.push(key);
2796
- }
2797
- }
2798
- for (const folder of foldersToClean) {
2799
- await deleteEmptyFolders(folder);
2800
- }
2801
- await saveMeta(meta);
2802
- return jsonResponse({
2803
- success: true,
2804
- cancelled: cancelled.length,
2805
- skipped: skipped.length,
2806
- errors: errors.length
2807
- });
2808
- } catch (error) {
2809
- console.error("Cancel updates error:", error);
2810
- return jsonResponse({ error: "Failed to cancel updates" }, { status: 500 });
2811
- }
2812
- }
2813
2843
 
2814
2844
  // src/handlers/scan.ts
2815
2845
  import { promises as fs8 } from "fs";