feat(files): add save-as-new vs overwrite choice for library file edits (#564)

Editing a file from the library used to silently supersede it: the worker auto-saved every result as a new version and the leaf-only listing hid the original, which read as a destructive overwrite. Tool pages now show a per-edit choice for library-sourced files. The default saves the result as an independent new file and keeps the original; picking overwrite keeps the old superseding-version behavior.

The client sends a saveMode multipart field next to fileId, validated with a 400 on unknown values, and autoSaveToLibrary branches on it. Every hand-written route that honors fileId parses the field the same way as the factory. The review panel shows where an auto-saved result went instead of offering a second, duplicate save. Tools whose route or submitter ignores fileId keep the selector hidden via a shared unsupported-tools set, and the choice resets to the non-destructive default whenever a new file is staged.

Closes #495
This commit is contained in:
SnapOtter
2026-07-18 11:36:08 +08:00
committed by GitHub
parent e113684ddb
commit a23158d968
63 changed files with 1721 additions and 19 deletions
+12 -5
View File
@@ -11,6 +11,7 @@ import { mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { extname, join } from "node:path";
import { probeMedia } from "@snapotter/media-engine";
import type { LibrarySaveMode } from "@snapotter/shared";
import { eq } from "drizzle-orm";
import sharp from "sharp";
import { db, schema } from "../db/index.js";
@@ -173,6 +174,8 @@ export async function generatePreview(
export interface AutoSaveOpts {
fileId?: string;
/** "overwrite" supersedes the original with a linked version; "new" (default) keeps it. */
saveMode?: LibrarySaveMode;
userId: string | null;
buffer: Buffer;
outName: string;
@@ -182,8 +185,12 @@ export interface AutoSaveOpts {
/**
* Auto-save a processed output to the persistent user file library when a
* fileId is provided. Creates a new version linked to the parent file with
* the tool appended to the toolChain.
* fileId is provided. The tool is appended to the parent's toolChain either
* way; saveMode decides the linkage (issue #495):
* - "new" (default): independent root row (version 1, no parentId), so the
* original stays visible in the leaf-only library list.
* - "overwrite": new version linked to the parent (version + 1, parentId),
* which supersedes the original in the list.
*
* Returns the new file ID on success, undefined when no fileId or on error.
*/
@@ -201,7 +208,7 @@ export async function autoSaveToLibrary(opts: AutoSaveOpts): Promise<string | un
// another user's file via a known fileId.
if (parent.userId !== opts.userId) return undefined;
const newVersion = parent.version + 1;
const overwrite = opts.saveMode === "overwrite";
const parentChain: string[] = parent.toolChain ?? [];
const newToolChain = [...parentChain, opts.toolId];
const storedName = await saveFile(opts.buffer, opts.outName);
@@ -245,8 +252,8 @@ export async function autoSaveToLibrary(opts: AutoSaveOpts): Promise<string | un
size: opts.buffer.length,
width,
height,
version: newVersion,
parentId: opts.fileId,
version: overwrite ? parent.version + 1 : 1,
parentId: overwrite ? opts.fileId : null,
toolChain: newToolChain,
});
return newId;
+19 -1
View File
@@ -1,7 +1,11 @@
/**
* Shared types and naming helpers for the BullMQ job system.
* Shared types and naming helpers for the BullMQ job system, plus the
* saveMode multipart-field validation shared by tool-factory and the
* hand-written tool routes.
*/
import { LIBRARY_SAVE_MODES, type LibrarySaveMode } from "@snapotter/shared";
/** The five processing pools that partition work by resource profile. */
export const POOLS = ["image", "media", "ai", "docs", "system"] as const;
export type Pool = (typeof POOLS)[number];
@@ -32,6 +36,8 @@ export interface ToolJobData {
*/
dbSettings?: Record<string, unknown>;
fileId?: string;
/** How to save the result to the library when fileId is set; defaults to "new". */
saveMode?: LibrarySaveMode;
clientJobId?: string;
kind:
| "tool"
@@ -50,6 +56,18 @@ export interface ToolJobData {
_otel?: { traceparent: string; tracestate?: string };
}
/** Error message for the { error } 400 response when a multipart saveMode field has an unknown value. */
export const INVALID_SAVE_MODE_ERROR = 'Invalid saveMode (expected "new" or "overwrite")';
/**
* Validate a client-supplied multipart saveMode field.
* Returns undefined when the field was absent, null when the value is invalid.
*/
export function parseSaveModeField(raw: string | null): LibrarySaveMode | undefined | null {
if (raw === null) return undefined;
return (LIBRARY_SAVE_MODES as readonly string[]).includes(raw) ? (raw as LibrarySaveMode) : null;
}
/** Result returned by a completed BullMQ job. */
export interface ToolJobResult {
outputRefs: string[];
+6 -4
View File
@@ -417,12 +417,14 @@ async function processToolJob(job: Job<ToolJobData>): Promise<ToolJobResult> {
// Generate preview for non-browser-previewable formats
const previewRef = await generatePreview(resultBuffer, resultContentType, jobId, inputBuffer);
// Auto-save a new version when the input came from the user's library
// (data.fileId is set by tool-factory when the upload referenced a
// library file). Without a fileId this is a no-op, so tool-first uploads
// are not auto-saved.
// Auto-save when the input came from the user's library (data.fileId is
// set by the route when the upload referenced a library file). saveMode
// picks between an independent new file (default) and a superseding
// version. Without a fileId this is a no-op, so tool-first uploads are
// not auto-saved.
const savedFileId = await autoSaveToLibrary({
fileId: data.fileId,
saveMode: data.saveMode,
userId: data.userId,
buffer: resultBuffer,
outName,
+11
View File
@@ -15,6 +15,7 @@ import type { z } from "zod";
import { env } from "../config.js";
import { db, schema } from "../db/index.js";
import { enqueueToolJob, waitForJob } from "../jobs/enqueue.js";
import { INVALID_SAVE_MODE_ERROR, parseSaveModeField } from "../jobs/types.js";
import { formatZodErrors, friendlyError, stripInternalPaths } from "../lib/errors.js";
import { getFirstMissingBundleForTool, isToolInstalled } from "../lib/feature-status.js";
import { getObjectBuffer, putObject } from "../lib/object-storage.js";
@@ -246,6 +247,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
let filename = "file";
let settingsRaw: string | null = null;
let fileId: string | null = null;
let saveModeRaw: string | null = null;
let clientJobId: string | null = null;
let fileCount = 0;
const received: ReceivedUpload[] = [];
@@ -283,6 +285,9 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
if (part.fieldname === "fileId") {
fileId = part.value as string;
}
if (part.fieldname === "saveMode") {
saveModeRaw = part.value as string;
}
if (part.fieldname === "clientJobId") {
const raw = part.value as string;
if (typeof raw === "string" && raw.length > 0 && raw.length <= 128) {
@@ -304,6 +309,11 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
});
}
const saveMode = parseSaveModeField(saveModeRaw);
if (saveMode === null) {
return reply.status(400).send({ error: INVALID_SAVE_MODE_ERROR });
}
// Require at least one file
if (received.length === 0) {
return reply.status(400).send({ error: "No file provided" });
@@ -522,6 +532,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
settings,
dbSettings,
fileId: fileId ?? undefined,
saveMode,
clientJobId: clientJobId ?? undefined,
kind: "tool",
analyticsDistinctId: request.headers["x-posthog-distinct-id"] as string | undefined,
@@ -9,6 +9,7 @@ import sharp from "sharp";
import { z } from "zod";
import { registerAiJobHandler } from "../../jobs/ai-handlers.js";
import { enqueueToolJob } from "../../jobs/enqueue.js";
import { INVALID_SAVE_MODE_ERROR, parseSaveModeField } from "../../jobs/types.js";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
import { isToolInstalled } from "../../lib/feature-status.js";
@@ -153,6 +154,7 @@ export function registerAiCanvasExpand(app: FastifyInstance) {
let settingsRaw: string | null = null;
let clientJobId: string | null = null;
let fileId: string | null = null;
let saveModeRaw: string | null = null;
let inputKey: string | null = null;
try {
@@ -171,6 +173,8 @@ export function registerAiCanvasExpand(app: FastifyInstance) {
}
} else if (part.fieldname === "fileId") {
fileId = part.value as string;
} else if (part.fieldname === "saveMode") {
saveModeRaw = part.value as string;
}
}
} catch (err) {
@@ -180,6 +184,11 @@ export function registerAiCanvasExpand(app: FastifyInstance) {
});
}
const saveMode = parseSaveModeField(saveModeRaw);
if (saveMode === null) {
return reply.status(400).send({ error: INVALID_SAVE_MODE_ERROR });
}
if (!inputKey) {
return reply.status(400).send({ error: "No image file provided" });
}
@@ -252,6 +261,7 @@ export function registerAiCanvasExpand(app: FastifyInstance) {
settings,
clientJobId: clientJobId ?? undefined,
fileId: fileId ?? undefined,
saveMode,
kind: "ai-tool",
});
@@ -8,6 +8,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { registerAiJobHandler } from "../../jobs/ai-handlers.js";
import { enqueueToolJob } from "../../jobs/enqueue.js";
import { INVALID_SAVE_MODE_ERROR, parseSaveModeField } from "../../jobs/types.js";
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
import { isToolInstalled } from "../../lib/feature-status.js";
import { type TranscriptSegment, toSrt, toVtt } from "../../lib/subtitle-format.js";
@@ -112,6 +113,7 @@ export function registerAutoSubtitles(app: FastifyInstance) {
let settingsRaw: string | null = null;
let clientJobId: string | null = null;
let fileId: string | null = null;
let saveModeRaw: string | null = null;
let inputKey: string | null = null;
try {
@@ -130,6 +132,8 @@ export function registerAutoSubtitles(app: FastifyInstance) {
}
} else if (part.fieldname === "fileId") {
fileId = part.value as string;
} else if (part.fieldname === "saveMode") {
saveModeRaw = part.value as string;
}
}
} catch (err) {
@@ -139,6 +143,11 @@ export function registerAutoSubtitles(app: FastifyInstance) {
});
}
const saveMode = parseSaveModeField(saveModeRaw);
if (saveMode === null) {
return reply.status(400).send({ error: INVALID_SAVE_MODE_ERROR });
}
if (!inputKey) {
return reply.status(400).send({ error: "No video file provided" });
}
@@ -168,6 +177,7 @@ export function registerAutoSubtitles(app: FastifyInstance) {
settings,
clientJobId: clientJobId ?? undefined,
fileId: fileId ?? undefined,
saveMode,
kind: "ai-tool",
});
@@ -6,6 +6,7 @@ import sharp from "sharp";
import { z } from "zod";
import { registerAiJobHandler } from "../../jobs/ai-handlers.js";
import { enqueueToolJob } from "../../jobs/enqueue.js";
import { INVALID_SAVE_MODE_ERROR, parseSaveModeField } from "../../jobs/types.js";
import { autoOrient } from "../../lib/auto-orient.js";
import { compositeOnColor, createGradientBackground } from "../../lib/bg-effects.js";
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
@@ -132,6 +133,7 @@ export function registerBackgroundReplace(app: FastifyInstance) {
let settingsRaw: string | null = null;
let clientJobId: string | null = null;
let fileId: string | null = null;
let saveModeRaw: string | null = null;
let inputKey: string | null = null;
try {
@@ -150,6 +152,8 @@ export function registerBackgroundReplace(app: FastifyInstance) {
}
} else if (part.fieldname === "fileId") {
fileId = part.value as string;
} else if (part.fieldname === "saveMode") {
saveModeRaw = part.value as string;
}
}
} catch (err) {
@@ -159,6 +163,11 @@ export function registerBackgroundReplace(app: FastifyInstance) {
});
}
const saveMode = parseSaveModeField(saveModeRaw);
if (saveMode === null) {
return reply.status(400).send({ error: INVALID_SAVE_MODE_ERROR });
}
if (!inputKey) {
return reply.status(400).send({ error: "No image file provided" });
}
@@ -227,6 +236,7 @@ export function registerBackgroundReplace(app: FastifyInstance) {
settings,
clientJobId: clientJobId ?? undefined,
fileId: fileId ?? undefined,
saveMode,
kind: "ai-tool",
});
@@ -6,6 +6,7 @@ import sharp from "sharp";
import { z } from "zod";
import { registerAiJobHandler } from "../../jobs/ai-handlers.js";
import { enqueueToolJob } from "../../jobs/enqueue.js";
import { INVALID_SAVE_MODE_ERROR, parseSaveModeField } from "../../jobs/types.js";
import { autoOrient } from "../../lib/auto-orient.js";
import { blurBackground } from "../../lib/bg-effects.js";
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
@@ -102,6 +103,7 @@ export function registerBlurBackground(app: FastifyInstance) {
let settingsRaw: string | null = null;
let clientJobId: string | null = null;
let fileId: string | null = null;
let saveModeRaw: string | null = null;
let inputKey: string | null = null;
try {
@@ -120,6 +122,8 @@ export function registerBlurBackground(app: FastifyInstance) {
}
} else if (part.fieldname === "fileId") {
fileId = part.value as string;
} else if (part.fieldname === "saveMode") {
saveModeRaw = part.value as string;
}
}
} catch (err) {
@@ -129,6 +133,11 @@ export function registerBlurBackground(app: FastifyInstance) {
});
}
const saveMode = parseSaveModeField(saveModeRaw);
if (saveMode === null) {
return reply.status(400).send({ error: INVALID_SAVE_MODE_ERROR });
}
if (!inputKey) {
return reply.status(400).send({ error: "No image file provided" });
}
@@ -197,6 +206,7 @@ export function registerBlurBackground(app: FastifyInstance) {
settings,
clientJobId: clientJobId ?? undefined,
fileId: fileId ?? undefined,
saveMode,
kind: "ai-tool",
});
+10
View File
@@ -9,6 +9,7 @@ import sharp from "sharp";
import { z } from "zod";
import { registerAiJobHandler } from "../../jobs/ai-handlers.js";
import { enqueueToolJob } from "../../jobs/enqueue.js";
import { INVALID_SAVE_MODE_ERROR, parseSaveModeField } from "../../jobs/types.js";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
import { isToolInstalled } from "../../lib/feature-status.js";
@@ -88,6 +89,7 @@ export function registerBlurFaces(app: FastifyInstance) {
let settingsRaw: string | null = null;
let clientJobId: string | null = null;
let fileId: string | null = null;
let saveModeRaw: string | null = null;
let inputKey: string | null = null;
try {
@@ -106,6 +108,8 @@ export function registerBlurFaces(app: FastifyInstance) {
}
} else if (part.fieldname === "fileId") {
fileId = part.value as string;
} else if (part.fieldname === "saveMode") {
saveModeRaw = part.value as string;
}
}
} catch (err) {
@@ -115,6 +119,11 @@ export function registerBlurFaces(app: FastifyInstance) {
});
}
const saveMode = parseSaveModeField(saveModeRaw);
if (saveMode === null) {
return reply.status(400).send({ error: INVALID_SAVE_MODE_ERROR });
}
if (!inputKey) {
return reply.status(400).send({ error: "No image file provided" });
}
@@ -174,6 +183,7 @@ export function registerBlurFaces(app: FastifyInstance) {
settings,
clientJobId: clientJobId ?? undefined,
fileId: fileId ?? undefined,
saveMode,
kind: "ai-tool",
});
+10
View File
@@ -9,6 +9,7 @@ import sharp from "sharp";
import { z } from "zod";
import { registerAiJobHandler } from "../../jobs/ai-handlers.js";
import { enqueueToolJob } from "../../jobs/enqueue.js";
import { INVALID_SAVE_MODE_ERROR, parseSaveModeField } from "../../jobs/types.js";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
import { isToolInstalled } from "../../lib/feature-status.js";
@@ -87,6 +88,7 @@ export function registerColorize(app: FastifyInstance) {
let settingsRaw: string | null = null;
let clientJobId: string | null = null;
let fileId: string | null = null;
let saveModeRaw: string | null = null;
let inputKey: string | null = null;
try {
@@ -105,6 +107,8 @@ export function registerColorize(app: FastifyInstance) {
}
} else if (part.fieldname === "fileId") {
fileId = part.value as string;
} else if (part.fieldname === "saveMode") {
saveModeRaw = part.value as string;
}
}
} catch (err) {
@@ -114,6 +118,11 @@ export function registerColorize(app: FastifyInstance) {
});
}
const saveMode = parseSaveModeField(saveModeRaw);
if (saveMode === null) {
return reply.status(400).send({ error: INVALID_SAVE_MODE_ERROR });
}
if (!inputKey) {
return reply.status(400).send({ error: "No image file provided" });
}
@@ -173,6 +182,7 @@ export function registerColorize(app: FastifyInstance) {
settings,
clientJobId: clientJobId ?? undefined,
fileId: fileId ?? undefined,
saveMode,
kind: "ai-tool",
});
@@ -8,6 +8,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { registerAiJobHandler } from "../../jobs/ai-handlers.js";
import { enqueueToolJob } from "../../jobs/enqueue.js";
import { INVALID_SAVE_MODE_ERROR, parseSaveModeField } from "../../jobs/types.js";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
import { getFirstMissingBundleForTool, isToolInstalled } from "../../lib/feature-status.js";
@@ -82,6 +83,7 @@ export function registerEnhanceFaces(app: FastifyInstance) {
let settingsRaw: string | null = null;
let clientJobId: string | null = null;
let fileId: string | null = null;
let saveModeRaw: string | null = null;
let inputKey: string | null = null;
try {
@@ -100,6 +102,8 @@ export function registerEnhanceFaces(app: FastifyInstance) {
}
} else if (part.fieldname === "fileId") {
fileId = part.value as string;
} else if (part.fieldname === "saveMode") {
saveModeRaw = part.value as string;
}
}
} catch (err) {
@@ -109,6 +113,11 @@ export function registerEnhanceFaces(app: FastifyInstance) {
});
}
const saveMode = parseSaveModeField(saveModeRaw);
if (saveMode === null) {
return reply.status(400).send({ error: INVALID_SAVE_MODE_ERROR });
}
if (!inputKey) {
return reply.status(400).send({ error: "No image file provided" });
}
@@ -168,6 +177,7 @@ export function registerEnhanceFaces(app: FastifyInstance) {
settings,
clientJobId: clientJobId ?? undefined,
fileId: fileId ?? undefined,
saveMode,
kind: "ai-tool",
});
+10
View File
@@ -5,6 +5,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { enqueueToolJob } from "../../jobs/enqueue.js";
import { INVALID_SAVE_MODE_ERROR, parseSaveModeField } from "../../jobs/types.js";
import { autoOrient } from "../../lib/auto-orient.js";
import { stripInternalPaths } from "../../lib/errors.js";
import { isToolInstalled } from "../../lib/feature-status.js";
@@ -56,6 +57,7 @@ export function registerEraseObject(app: FastifyInstance) {
let filename = "image";
let clientJobId: string | null = null;
let fileId: string | null = null;
let saveModeRaw: string | null = null;
let format = "png";
let quality = 95;
let imageKey: string | null = null;
@@ -80,6 +82,8 @@ export function registerEraseObject(app: FastifyInstance) {
}
} else if (part.fieldname === "fileId") {
fileId = part.value as string;
} else if (part.fieldname === "saveMode") {
saveModeRaw = part.value as string;
} else if (part.fieldname === "format") {
format = (part.value as string) || "png";
} else if (part.fieldname === "quality") {
@@ -93,6 +97,11 @@ export function registerEraseObject(app: FastifyInstance) {
});
}
const saveMode = parseSaveModeField(saveModeRaw);
if (saveMode === null) {
return reply.status(400).send({ error: INVALID_SAVE_MODE_ERROR });
}
if (!imageKey) {
return reply.status(400).send({ error: "No image file provided" });
}
@@ -170,6 +179,7 @@ export function registerEraseObject(app: FastifyInstance) {
settings: { format, quality },
clientJobId: clientJobId ?? undefined,
fileId: fileId ?? undefined,
saveMode,
kind: "ai-tool",
});
@@ -8,6 +8,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { registerAiJobHandler } from "../../jobs/ai-handlers.js";
import { enqueueToolJob } from "../../jobs/enqueue.js";
import { INVALID_SAVE_MODE_ERROR, parseSaveModeField } from "../../jobs/types.js";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
import { isToolInstalled } from "../../lib/feature-status.js";
@@ -92,6 +93,7 @@ export function registerNoiseRemoval(app: FastifyInstance) {
let settingsRaw: string | null = null;
let clientJobId: string | null = null;
let fileId: string | null = null;
let saveModeRaw: string | null = null;
let inputKey: string | null = null;
try {
@@ -110,6 +112,8 @@ export function registerNoiseRemoval(app: FastifyInstance) {
}
} else if (part.fieldname === "fileId") {
fileId = part.value as string;
} else if (part.fieldname === "saveMode") {
saveModeRaw = part.value as string;
}
}
} catch (err) {
@@ -119,6 +123,11 @@ export function registerNoiseRemoval(app: FastifyInstance) {
});
}
const saveMode = parseSaveModeField(saveModeRaw);
if (saveMode === null) {
return reply.status(400).send({ error: INVALID_SAVE_MODE_ERROR });
}
if (!inputKey) {
return reply.status(400).send({ error: "No image file provided" });
}
@@ -178,6 +187,7 @@ export function registerNoiseRemoval(app: FastifyInstance) {
settings: parsed,
clientJobId: clientJobId ?? undefined,
fileId: fileId ?? undefined,
saveMode,
kind: "ai-tool",
});
+11
View File
@@ -7,6 +7,7 @@ import { z } from "zod";
import { env } from "../../config.js";
import { registerAiPathJobHandler } from "../../jobs/ai-handlers.js";
import { enqueueToolJob } from "../../jobs/enqueue.js";
import { INVALID_SAVE_MODE_ERROR, parseSaveModeField } from "../../jobs/types.js";
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
import { copyObjectToFile, deleteObject } from "../../lib/object-storage.js";
import { resolveOcrIngressSettings } from "../../lib/ocr-capability.js";
@@ -123,6 +124,7 @@ export function registerOcrPdf(app: FastifyInstance) {
let settingsRaw: string | null = null;
let clientJobId: string | null = null;
let fileId: string | null = null;
let saveModeRaw: string | null = null;
let inputKey: string | null = null;
const ingressAbort = new AbortController();
const abortIngress = () => ingressAbort.abort();
@@ -150,6 +152,8 @@ export function registerOcrPdf(app: FastifyInstance) {
}
} else if (part.fieldname === "fileId") {
fileId = part.value as string;
} else if (part.fieldname === "saveMode") {
saveModeRaw = part.value as string;
}
}
} catch (err) {
@@ -161,6 +165,12 @@ export function registerOcrPdf(app: FastifyInstance) {
});
}
const saveMode = parseSaveModeField(saveModeRaw);
if (saveMode === null) {
if (inputKey) await deleteObject(inputKey).catch(() => {});
return reply.status(400).send({ error: INVALID_SAVE_MODE_ERROR });
}
if (!inputKey) {
return reply.status(400).send({ error: "No PDF file provided" });
}
@@ -250,6 +260,7 @@ export function registerOcrPdf(app: FastifyInstance) {
settings: { ...normalizedSettings, quality },
clientJobId: clientJobId ?? undefined,
fileId: fileId ?? undefined,
saveMode,
kind: "ai-tool",
});
} catch (err) {
+11
View File
@@ -11,6 +11,7 @@ import { z } from "zod";
import { env } from "../../config.js";
import { registerAiJobHandler } from "../../jobs/ai-handlers.js";
import { enqueueToolJob } from "../../jobs/enqueue.js";
import { INVALID_SAVE_MODE_ERROR, parseSaveModeField } from "../../jobs/types.js";
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
import { deleteObject } from "../../lib/object-storage.js";
import { resolveOcrIngressSettings } from "../../lib/ocr-capability.js";
@@ -134,6 +135,7 @@ export function registerOcr(app: FastifyInstance) {
let settingsRaw: string | null = null;
let clientJobId: string | null = null;
let fileId: string | null = null;
let saveModeRaw: string | null = null;
let inputKey: string | null = null;
try {
@@ -155,6 +157,8 @@ export function registerOcr(app: FastifyInstance) {
}
} else if (part.fieldname === "fileId") {
fileId = part.value as string;
} else if (part.fieldname === "saveMode") {
saveModeRaw = part.value as string;
}
}
} catch (err) {
@@ -166,6 +170,12 @@ export function registerOcr(app: FastifyInstance) {
});
}
const saveMode = parseSaveModeField(saveModeRaw);
if (saveMode === null) {
if (inputKey) await deleteObject(inputKey).catch(() => {});
return reply.status(400).send({ error: INVALID_SAVE_MODE_ERROR });
}
if (!inputKey) {
return reply.status(400).send({ error: "No image file provided" });
}
@@ -223,6 +233,7 @@ export function registerOcr(app: FastifyInstance) {
settings: { ...normalizedSettings, quality },
clientJobId: clientJobId ?? undefined,
fileId: fileId ?? undefined,
saveMode,
kind: "ai-tool",
analyticsDistinctId: request.headers["x-posthog-distinct-id"] as string | undefined,
});
@@ -8,6 +8,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { registerAiJobHandler } from "../../jobs/ai-handlers.js";
import { enqueueToolJob } from "../../jobs/enqueue.js";
import { INVALID_SAVE_MODE_ERROR, parseSaveModeField } from "../../jobs/types.js";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
import { isToolInstalled } from "../../lib/feature-status.js";
@@ -80,6 +81,7 @@ export function registerRedEyeRemoval(app: FastifyInstance) {
let settingsRaw: string | null = null;
let clientJobId: string | null = null;
let fileId: string | null = null;
let saveModeRaw: string | null = null;
let inputKey: string | null = null;
try {
@@ -98,6 +100,8 @@ export function registerRedEyeRemoval(app: FastifyInstance) {
}
} else if (part.fieldname === "fileId") {
fileId = part.value as string;
} else if (part.fieldname === "saveMode") {
saveModeRaw = part.value as string;
}
}
} catch (err) {
@@ -107,6 +111,11 @@ export function registerRedEyeRemoval(app: FastifyInstance) {
});
}
const saveMode = parseSaveModeField(saveModeRaw);
if (saveMode === null) {
return reply.status(400).send({ error: INVALID_SAVE_MODE_ERROR });
}
if (!inputKey) {
return reply.status(400).send({ error: "No image file provided" });
}
@@ -166,6 +175,7 @@ export function registerRedEyeRemoval(app: FastifyInstance) {
settings,
clientJobId: clientJobId ?? undefined,
fileId: fileId ?? undefined,
saveMode,
kind: "ai-tool",
});
@@ -8,6 +8,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { registerAiJobHandler } from "../../jobs/ai-handlers.js";
import { enqueueToolJob } from "../../jobs/enqueue.js";
import { INVALID_SAVE_MODE_ERROR, parseSaveModeField } from "../../jobs/types.js";
import { autoOrient } from "../../lib/auto-orient.js";
import {
applyEffects,
@@ -114,6 +115,7 @@ export function registerRemoveBackground(app: FastifyInstance) {
let settingsRaw: string | null = null;
let clientJobId: string | null = null;
let fileId: string | null = null;
let saveModeRaw: string | null = null;
let inputKey: string | null = null;
try {
@@ -132,6 +134,8 @@ export function registerRemoveBackground(app: FastifyInstance) {
}
} else if (part.fieldname === "fileId") {
fileId = part.value as string;
} else if (part.fieldname === "saveMode") {
saveModeRaw = part.value as string;
}
}
} catch (err) {
@@ -141,6 +145,11 @@ export function registerRemoveBackground(app: FastifyInstance) {
});
}
const saveMode = parseSaveModeField(saveModeRaw);
if (saveMode === null) {
return reply.status(400).send({ error: INVALID_SAVE_MODE_ERROR });
}
if (!inputKey) {
return reply.status(400).send({ error: "No image file provided" });
}
@@ -218,6 +227,7 @@ export function registerRemoveBackground(app: FastifyInstance) {
settings,
clientJobId: clientJobId ?? undefined,
fileId: fileId ?? undefined,
saveMode,
kind: "ai-tool",
});
@@ -10,6 +10,7 @@ import { z } from "zod";
import { env } from "../../config.js";
import { registerAiJobHandler } from "../../jobs/ai-handlers.js";
import { enqueueToolJob } from "../../jobs/enqueue.js";
import { INVALID_SAVE_MODE_ERROR, parseSaveModeField } from "../../jobs/types.js";
import { detectAnimation } from "../../lib/animation-detect.js";
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
import { isToolInstalled } from "../../lib/feature-status.js";
@@ -132,6 +133,7 @@ export function registerRemoveGifBackground(app: FastifyInstance) {
let settingsRaw: string | null = null;
let clientJobId: string | null = null;
let fileId: string | null = null;
let saveModeRaw: string | null = null;
let inputKey: string | null = null;
let bgBuffer: Buffer | null = null;
let bgName = "background";
@@ -157,6 +159,8 @@ export function registerRemoveGifBackground(app: FastifyInstance) {
}
} else if (part.fieldname === "fileId") {
fileId = part.value as string;
} else if (part.fieldname === "saveMode") {
saveModeRaw = part.value as string;
}
}
} catch (err) {
@@ -166,6 +170,11 @@ export function registerRemoveGifBackground(app: FastifyInstance) {
});
}
const saveMode = parseSaveModeField(saveModeRaw);
if (saveMode === null) {
return reply.status(400).send({ error: INVALID_SAVE_MODE_ERROR });
}
if (!inputKey) {
return reply.status(400).send({ error: "No image file provided" });
}
@@ -247,6 +256,7 @@ export function registerRemoveGifBackground(app: FastifyInstance) {
dbSettings: settings, // keep the internal bgImageKey out of the audit row
clientJobId: clientJobId ?? undefined,
fileId: fileId ?? undefined,
saveMode,
kind: "ai-tool",
});
@@ -9,6 +9,7 @@ import sharp from "sharp";
import { z } from "zod";
import { registerAiJobHandler } from "../../jobs/ai-handlers.js";
import { enqueueToolJob } from "../../jobs/enqueue.js";
import { INVALID_SAVE_MODE_ERROR, parseSaveModeField } from "../../jobs/types.js";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
import { isToolInstalled } from "../../lib/feature-status.js";
@@ -105,6 +106,7 @@ export function registerRestorePhoto(app: FastifyInstance) {
let settingsRaw: string | null = null;
let clientJobId: string | null = null;
let fileId: string | null = null;
let saveModeRaw: string | null = null;
let inputKey: string | null = null;
try {
@@ -123,6 +125,8 @@ export function registerRestorePhoto(app: FastifyInstance) {
}
} else if (part.fieldname === "fileId") {
fileId = part.value as string;
} else if (part.fieldname === "saveMode") {
saveModeRaw = part.value as string;
}
}
} catch (err) {
@@ -132,6 +136,11 @@ export function registerRestorePhoto(app: FastifyInstance) {
});
}
const saveMode = parseSaveModeField(saveModeRaw);
if (saveMode === null) {
return reply.status(400).send({ error: INVALID_SAVE_MODE_ERROR });
}
if (!inputKey) {
return reply.status(400).send({ error: "No image file provided" });
}
@@ -198,6 +207,7 @@ export function registerRestorePhoto(app: FastifyInstance) {
settings,
clientJobId: clientJobId ?? undefined,
fileId: fileId ?? undefined,
saveMode,
kind: "ai-tool",
});
+10
View File
@@ -8,6 +8,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { registerAiJobHandler } from "../../jobs/ai-handlers.js";
import { enqueueToolJob, waitForJob } from "../../jobs/enqueue.js";
import { INVALID_SAVE_MODE_ERROR, parseSaveModeField } from "../../jobs/types.js";
import { stripInternalPaths } from "../../lib/errors.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { getObjectBuffer } from "../../lib/object-storage.js";
@@ -55,6 +56,7 @@ export function registerSignPdf(app: FastifyInstance) {
let filename = "document.pdf";
let clientJobId: string | null = null;
let fileId: string | null = null;
let saveModeRaw: string | null = null;
let placementsRaw: string | null = null;
const sigParts: Array<{ index: number; key: string }> = [];
@@ -81,6 +83,8 @@ export function registerSignPdf(app: FastifyInstance) {
if (/^[0-9a-f-]{36}$/i.test(raw)) clientJobId = raw;
} else if (part.fieldname === "fileId") {
fileId = part.value as string;
} else if (part.fieldname === "saveMode") {
saveModeRaw = part.value as string;
}
}
} catch (err) {
@@ -90,6 +94,11 @@ export function registerSignPdf(app: FastifyInstance) {
});
}
const saveMode = parseSaveModeField(saveModeRaw);
if (saveMode === null) {
return reply.status(400).send({ error: INVALID_SAVE_MODE_ERROR });
}
if (!pdfKey) return reply.status(400).send({ error: "No PDF file provided" });
if (!placementsRaw) return reply.status(400).send({ error: "No placements provided" });
@@ -142,6 +151,7 @@ export function registerSignPdf(app: FastifyInstance) {
settings: { placements: parsed.placements },
clientJobId: clientJobId ?? undefined,
fileId: fileId ?? undefined,
saveMode,
kind: "ai-tool",
});
@@ -7,6 +7,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { registerAiJobHandler } from "../../jobs/ai-handlers.js";
import { enqueueToolJob } from "../../jobs/enqueue.js";
import { INVALID_SAVE_MODE_ERROR, parseSaveModeField } from "../../jobs/types.js";
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
import { isToolInstalled } from "../../lib/feature-status.js";
import { type TranscriptSegment, toSrt, toVtt } from "../../lib/subtitle-format.js";
@@ -100,6 +101,7 @@ export function registerTranscribeAudio(app: FastifyInstance) {
let settingsRaw: string | null = null;
let clientJobId: string | null = null;
let fileId: string | null = null;
let saveModeRaw: string | null = null;
let inputKey: string | null = null;
try {
@@ -118,6 +120,8 @@ export function registerTranscribeAudio(app: FastifyInstance) {
}
} else if (part.fieldname === "fileId") {
fileId = part.value as string;
} else if (part.fieldname === "saveMode") {
saveModeRaw = part.value as string;
}
}
} catch (err) {
@@ -127,6 +131,11 @@ export function registerTranscribeAudio(app: FastifyInstance) {
});
}
const saveMode = parseSaveModeField(saveModeRaw);
if (saveMode === null) {
return reply.status(400).send({ error: INVALID_SAVE_MODE_ERROR });
}
if (!inputKey) {
return reply.status(400).send({ error: "No audio file provided" });
}
@@ -156,6 +165,7 @@ export function registerTranscribeAudio(app: FastifyInstance) {
settings,
clientJobId: clientJobId ?? undefined,
fileId: fileId ?? undefined,
saveMode,
kind: "ai-tool",
});
@@ -9,6 +9,7 @@ import sharp from "sharp";
import { z } from "zod";
import { registerAiJobHandler } from "../../jobs/ai-handlers.js";
import { enqueueToolJob } from "../../jobs/enqueue.js";
import { INVALID_SAVE_MODE_ERROR, parseSaveModeField } from "../../jobs/types.js";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
import { isToolInstalled } from "../../lib/feature-status.js";
@@ -173,6 +174,7 @@ export function registerTransparencyFixer(app: FastifyInstance) {
let settingsRaw: string | null = null;
let clientJobId: string | null = null;
let fileId: string | null = null;
let saveModeRaw: string | null = null;
let inputKey: string | null = null;
try {
@@ -191,6 +193,8 @@ export function registerTransparencyFixer(app: FastifyInstance) {
}
} else if (part.fieldname === "fileId") {
fileId = part.value as string;
} else if (part.fieldname === "saveMode") {
saveModeRaw = part.value as string;
}
}
} catch (err) {
@@ -200,6 +204,11 @@ export function registerTransparencyFixer(app: FastifyInstance) {
});
}
const saveMode = parseSaveModeField(saveModeRaw);
if (saveMode === null) {
return reply.status(400).send({ error: INVALID_SAVE_MODE_ERROR });
}
if (!inputKey) {
return reply.status(400).send({ error: "No image file provided" });
}
@@ -263,6 +272,7 @@ export function registerTransparencyFixer(app: FastifyInstance) {
settings,
clientJobId: clientJobId ?? undefined,
fileId: fileId ?? undefined,
saveMode,
kind: "ai-tool",
});
+10
View File
@@ -9,6 +9,7 @@ import sharp from "sharp";
import { z } from "zod";
import { registerAiJobHandler } from "../../jobs/ai-handlers.js";
import { enqueueToolJob } from "../../jobs/enqueue.js";
import { INVALID_SAVE_MODE_ERROR, parseSaveModeField } from "../../jobs/types.js";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
import { isToolInstalled } from "../../lib/feature-status.js";
@@ -137,6 +138,7 @@ export function registerUpscale(app: FastifyInstance) {
let settingsRaw: string | null = null;
let clientJobId: string | null = null;
let fileId: string | null = null;
let saveModeRaw: string | null = null;
let inputKey: string | null = null;
try {
@@ -155,6 +157,8 @@ export function registerUpscale(app: FastifyInstance) {
}
} else if (part.fieldname === "fileId") {
fileId = part.value as string;
} else if (part.fieldname === "saveMode") {
saveModeRaw = part.value as string;
}
}
} catch (err) {
@@ -164,6 +168,11 @@ export function registerUpscale(app: FastifyInstance) {
});
}
const saveMode = parseSaveModeField(saveModeRaw);
if (saveMode === null) {
return reply.status(400).send({ error: INVALID_SAVE_MODE_ERROR });
}
if (!inputKey) {
return reply.status(400).send({ error: "No image file provided" });
}
@@ -225,6 +234,7 @@ export function registerUpscale(app: FastifyInstance) {
settings,
clientJobId: clientJobId ?? undefined,
fileId: fileId ?? undefined,
saveMode,
kind: "ai-tool",
});
@@ -0,0 +1,66 @@
import { LIBRARY_SAVE_MODE_UNSUPPORTED_TOOLS } from "@snapotter/shared";
import { useTranslation } from "@/contexts/i18n-context";
import { MULTI_FILE_TOOLS } from "@/lib/tool-display-modes";
import { useFileStore } from "@/stores/file-store";
/**
* Per-edit choice for library-sourced files (#495): save the processed result
* to the file library as a new file (default, keeps the original) or
* overwrite the original. Renders nothing when the choice would not be
* honored: entry not linked to a library file, tool whose route or submitter
* ignores the saveMode field, or a multi-file batch run (batches never send
* fileId, so nothing is auto-saved).
*/
export function LibrarySaveModeSelector({ toolId }: { toolId: string }) {
const { t } = useTranslation();
const currentEntry = useFileStore((s) => s.currentEntry);
const entries = useFileStore((s) => s.entries);
const librarySaveMode = useFileStore((s) => s.librarySaveMode);
const setLibrarySaveMode = useFileStore((s) => s.setLibrarySaveMode);
const processing = useFileStore((s) => s.processing);
if (!currentEntry?.serverFileId) return null;
if (LIBRARY_SAVE_MODE_UNSUPPORTED_TOOLS.has(toolId)) return null;
const isBatchRun = entries.length > 1 && !MULTI_FILE_TOOLS.has(toolId);
if (isBatchRun) return null;
return (
<fieldset className="space-y-2 rounded-lg border border-border p-3">
<legend className="px-1 text-xs font-medium text-muted-foreground">
{t.toolPage.librarySaveTitle}
</legend>
<label className="flex items-start gap-2 text-sm text-foreground">
<input
type="radio"
name="library-save-mode"
className="mt-1"
checked={librarySaveMode === "new"}
onChange={() => setLibrarySaveMode("new")}
disabled={processing}
/>
<span>
{t.toolPage.librarySaveAsNew}
<span className="block text-xs text-muted-foreground">
{t.toolPage.librarySaveAsNewHint}
</span>
</span>
</label>
<label className="flex items-start gap-2 text-sm text-foreground">
<input
type="radio"
name="library-save-mode"
className="mt-1"
checked={librarySaveMode === "overwrite"}
onChange={() => setLibrarySaveMode("overwrite")}
disabled={processing}
/>
<span>
{t.toolPage.libraryOverwrite}
<span className="block text-xs text-muted-foreground">
{t.toolPage.libraryOverwriteHint}
</span>
</span>
</label>
</fieldset>
);
}
@@ -48,6 +48,8 @@ interface ReviewPanelProps {
totalCount?: number;
successCount?: number;
failedCount?: number;
/** Library id of the auto-saved result (#495); replaces the manual save link. */
savedLibraryFileId?: string | null;
}
export function ReviewPanel({
@@ -62,6 +64,7 @@ export function ReviewPanel({
totalCount,
successCount,
failedCount,
savedLibraryFileId,
}: ReviewPanelProps) {
const { t } = useTranslation();
@@ -194,8 +197,20 @@ export function ReviewPanel({
</button>
)}
{/* Result already auto-saved to the library: show where it went
instead of the manual save link (avoids duplicate saves). */}
{!isDataOutput && savedLibraryFileId && (
<div className="flex items-center justify-center gap-1.5 text-xs text-emerald-600 dark:text-emerald-400">
<CheckCircle2 className="h-3 w-3" />
{t.toolPage.savedToFiles}
<Link to="/files" className="underline underline-offset-2 hover:text-foreground">
{t.toolPage.viewInFiles}
</Link>
</div>
)}
{/* Save to Files -- subtle text link */}
{!isDataOutput && (
{!isDataOutput && !savedLibraryFileId && (
<div className="flex justify-center">
<button
type="button"
@@ -432,7 +432,9 @@ export function FileDetails({ mobile = false }: FileDetailsProps) {
navigate("/", { state: { fromLibrary: true } });
}
// Set serverFileId on each entry so tool processing creates new versions
// Set serverFileId on each entry so tool processing auto-saves the result
// to the library: an independent new file by default, or a superseding
// version when the user picks overwrite (#495)
setTimeout(() => {
const store = useFileStore.getState();
for (let i = 0; i < valid.length; i++) {
@@ -186,8 +186,12 @@ export function SignPdfSettings({ signProps }: { signProps?: SignProps }) {
form.append("placements", JSON.stringify(placements));
form.append("clientJobId", clientJobId);
// Forward the library file id (when the PDF came from the library) so the
// worker auto-saves the signed result as a new version.
if (currentEntry?.serverFileId) form.append("fileId", currentEntry.serverFileId);
// worker auto-saves the signed result, honoring the chosen save mode
// (new file by default, overwrite on request).
if (currentEntry?.serverFileId) {
form.append("fileId", currentEntry.serverFileId);
form.append("saveMode", useFileStore.getState().librarySaveMode);
}
pngs.forEach((png, i) => {
form.append(`sig${i}`, new File([png], `sig${i}.png`, { type: "image/png" }));
});
+28 -3
View File
@@ -91,6 +91,10 @@ export function useToolProcessor(toolId: string) {
const stallTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const activeJobIdRef = useRef<string | null>(null);
const asyncModeRef = useRef(false);
// Save mode captured at run start (#495). Only "overwrite" re-anchors
// serverFileId to the saved result, so "new" keeps deriving from the
// original library file on re-runs.
const saveModeRef = useRef<"new" | "overwrite">("new");
const isAiTool = AI_PYTHON_TOOLS.has(toolId);
const toolName = TOOLS.find((t) => t.id === toolId)?.name ?? toolId;
@@ -180,6 +184,9 @@ export function useToolProcessor(toolId: string) {
const result = data.result as ProcessResult;
setWarning(result.warning ?? null);
setResultPayload(result as unknown as Record<string, unknown>);
if (result.savedFileId) {
useFileStore.getState().setLastSavedLibraryFileId(result.savedFileId);
}
const idx = useFileStore.getState().selectedIndex;
useFileStore.getState().updateEntry(idx, {
processedUrl: result.downloadUrl,
@@ -188,7 +195,9 @@ export function useToolProcessor(toolId: string) {
status: "completed",
originalSize: result.originalSize,
processedSize: result.processedSize,
...(result.savedFileId ? { serverFileId: result.savedFileId } : {}),
...(result.savedFileId && saveModeRef.current === "overwrite"
? { serverFileId: result.savedFileId }
: {}),
});
setProcessing(false);
setProgress(IDLE_PROGRESS);
@@ -275,6 +284,7 @@ export function useToolProcessor(toolId: string) {
setError(null);
setWarning(null);
setResultPayload(null);
useFileStore.getState().setLastSavedLibraryFileId(null);
useFileStore.getState().updateEntry(capturedIndex, {
processedUrl: null,
processedPreviewUrl: null,
@@ -353,6 +363,9 @@ export function useToolProcessor(toolId: string) {
const result = data.result as ProcessResult;
setWarning(result.warning ?? null);
setResultPayload(result as unknown as Record<string, unknown>);
if (result.savedFileId) {
useFileStore.getState().setLastSavedLibraryFileId(result.savedFileId);
}
useFileStore.getState().updateEntry(capturedIndex, {
processedUrl: result.downloadUrl,
processedPreviewUrl: result.previewUrl ?? null,
@@ -360,7 +373,9 @@ export function useToolProcessor(toolId: string) {
status: "completed",
originalSize: result.originalSize,
processedSize: result.processedSize,
...(result.savedFileId ? { serverFileId: result.savedFileId } : {}),
...(result.savedFileId && saveModeRef.current === "overwrite"
? { serverFileId: result.savedFileId }
: {}),
});
setProcessing(false);
setProgress(IDLE_PROGRESS);
@@ -421,8 +436,10 @@ export function useToolProcessor(toolId: string) {
formData.append("clientJobId", clientJobId);
const capturedEntry = useFileStore.getState().entries[capturedIndex];
saveModeRef.current = useFileStore.getState().librarySaveMode;
if (capturedEntry?.serverFileId) {
formData.append("fileId", capturedEntry.serverFileId);
formData.append("saveMode", saveModeRef.current);
}
const xhr = new XMLHttpRequest();
@@ -469,6 +486,9 @@ export function useToolProcessor(toolId: string) {
const result: ProcessResult = JSON.parse(xhr.responseText);
setWarning(result.warning ?? null);
setResultPayload(result as unknown as Record<string, unknown>);
if (result.savedFileId) {
useFileStore.getState().setLastSavedLibraryFileId(result.savedFileId);
}
useFileStore.getState().updateEntry(capturedIndex, {
processedUrl: result.downloadUrl,
processedPreviewUrl: result.previewUrl ?? null,
@@ -476,7 +496,9 @@ export function useToolProcessor(toolId: string) {
status: "completed",
originalSize: result.originalSize,
processedSize: result.processedSize,
...(result.savedFileId ? { serverFileId: result.savedFileId } : {}),
...(result.savedFileId && saveModeRef.current === "overwrite"
? { serverFileId: result.savedFileId }
: {}),
});
} catch {
setError("Invalid response from server");
@@ -580,6 +602,9 @@ export function useToolProcessor(toolId: string) {
const { updateEntry, setBatchZip } = useFileStore.getState();
setError(null);
// Batch runs never auto-save to the library (no fileId is sent), so a
// previous single run's saved indicator must not survive into this one.
useFileStore.getState().setLastSavedLibraryFileId(null);
setProcessing(true);
setProgress({ phase: "uploading", percent: 0, elapsed: 0 });
+5
View File
@@ -27,6 +27,7 @@ import { BeforeAfterSlider } from "@/components/common/before-after-slider";
import { BottomSheet } from "@/components/common/bottom-sheet";
import { Dropzone } from "@/components/common/dropzone";
import { type BgPreviewState, ImageViewer } from "@/components/common/image-viewer";
import { LibrarySaveModeSelector } from "@/components/common/library-save-mode-selector";
import { ReviewPanel } from "@/components/common/review-panel";
import { SideBySideComparison } from "@/components/common/side-by-side-comparison";
import { ThumbnailStrip } from "@/components/common/thumbnail-strip";
@@ -303,6 +304,7 @@ export function ToolPage() {
navigateNext,
navigatePrev,
currentEntry,
lastSavedLibraryFileId,
} = useFileStore();
const isMobile = useMobile();
const hasMultiple = entries.length > 1;
@@ -1115,6 +1117,8 @@ export function ToolPage() {
</div>
)}
<LibrarySaveModeSelector toolId={tool?.id ?? ""} />
<div className="border-t border-border" />
<div className="space-y-2">
@@ -1161,6 +1165,7 @@ export function ToolPage() {
totalCount={batchTotal}
successCount={batchSuccess}
failedCount={batchFailed}
savedLibraryFileId={lastSavedLibraryFileId}
/>
</div>
)}
+24 -1
View File
@@ -1,4 +1,9 @@
import { ANALYTICS_EVENTS, detectModalityFromMime, type Modality } from "@snapotter/shared";
import {
ANALYTICS_EVENTS,
detectModalityFromMime,
type LibrarySaveMode,
type Modality,
} from "@snapotter/shared";
import { create } from "zustand";
import { fetchDecodedPreview, needsServerPreview } from "@/lib/image-preview";
@@ -118,6 +123,10 @@ interface FileState {
error: string | null;
activeJobId: string | null;
cancelCurrentJob: (() => Promise<void>) | null;
/** How library-sourced results are saved (#495): "new" keeps the original. */
librarySaveMode: LibrarySaveMode;
/** Library file id of the last run's auto-saved result, for the review UI. */
lastSavedLibraryFileId: string | null;
// Derived from entries (selected entry fields)
readonly files: File[];
@@ -142,6 +151,8 @@ interface FileState {
setProcessing: (v: boolean) => void;
setError: (e: string | null) => void;
setActiveJob: (id: string | null, cancelFn: (() => Promise<void>) | null) => void;
setLibrarySaveMode: (mode: LibrarySaveMode) => void;
setLastSavedLibraryFileId: (id: string | null) => void;
setJobId: (id: string) => void;
setProcessedUrl: (url: string | null, previewUrl?: string | null) => void;
setSizes: (original: number, processed: number) => void;
@@ -158,6 +169,8 @@ export const useFileStore = create<FileState>((set, get) => ({
error: null,
activeJobId: null,
cancelCurrentJob: null,
librarySaveMode: "new",
lastSavedLibraryFileId: null,
// Initial derived values (empty state)
files: [],
@@ -175,6 +188,9 @@ export const useFileStore = create<FileState>((set, get) => ({
entries,
selectedIndex: 0,
error: null,
// A fresh file set is a fresh edit: the save-mode choice made for a
// previous file must not carry over (#495 defaults to non-destructive).
librarySaveMode: "new",
files: deriveFiles(entries),
...deriveSelected(entries, 0),
});
@@ -347,6 +363,10 @@ export const useFileStore = create<FileState>((set, get) => ({
set({ entries: updated, ...deriveSelected(updated, selectedIndex) });
},
setLibrarySaveMode: (mode) => set({ librarySaveMode: mode }),
setLastSavedLibraryFileId: (id) => set({ lastSavedLibraryFileId: id }),
undoProcessing: () => {
const { entries, selectedIndex } = get();
for (const entry of entries) {
@@ -370,6 +390,7 @@ export const useFileStore = create<FileState>((set, get) => ({
error: null,
activeJobId: null,
cancelCurrentJob: null,
lastSavedLibraryFileId: null,
files: deriveFiles(resetEntries),
...deriveSelected(resetEntries, selectedIndex),
});
@@ -387,6 +408,8 @@ export const useFileStore = create<FileState>((set, get) => ({
error: null,
activeJobId: null,
cancelCurrentJob: null,
librarySaveMode: "new",
lastSavedLibraryFileId: null,
files: [],
...deriveSelected([], 0),
});
+6
View File
@@ -2975,6 +2975,12 @@ export const ar: TranslationKeys = {
importFromFiles: "استيراد من الملفات",
saveToFiles: "حفظ في الملفات",
savedToFiles: "تم الحفظ في الملفات",
librarySaveTitle: "هذا الملف من ملفاتك",
librarySaveAsNew: "حفظ النتيجة كملف جديد",
librarySaveAsNewHint: "يُبقي الملف الأصلي في ملفاتك",
libraryOverwrite: "استبدال الملف الأصلي",
libraryOverwriteHint: "تحل النتيجة محل الملف الأصلي في ملفاتك",
viewInFiles: "عرض في الملفات",
adjustSettings: "ضبط الإعدادات",
startOver: "البدء من جديد بملف جديد",
newFile: "ملف جديد",
+6
View File
@@ -3002,6 +3002,12 @@ export const de: TranslationKeys = {
importFromFiles: "Aus Dateien importieren",
saveToFiles: "In Dateien speichern",
savedToFiles: "In Dateien gespeichert",
librarySaveTitle: "Diese Datei stammt aus deinen Dateien",
librarySaveAsNew: "Ergebnis als neue Datei speichern",
librarySaveAsNewHint: "Das Original bleibt in deinen Dateien erhalten",
libraryOverwrite: "Original überschreiben",
libraryOverwriteHint: "Das Ergebnis ersetzt das Original in deinen Dateien",
viewInFiles: "In Dateien anzeigen",
adjustSettings: "Einstellungen anpassen",
startOver: "Mit einer neuen Datei neu beginnen",
newFile: "Neue Datei",
+6
View File
@@ -2941,6 +2941,12 @@ export const en = {
importFromFiles: "Import from Files",
saveToFiles: "Save to Files",
savedToFiles: "Saved to Files",
librarySaveTitle: "This file is from your Files",
librarySaveAsNew: "Save result as a new file",
librarySaveAsNewHint: "Keeps the original in your Files",
libraryOverwrite: "Overwrite the original",
libraryOverwriteHint: "The result replaces the original in your Files",
viewInFiles: "View in Files",
adjustSettings: "Adjust settings",
startOver: "Start over with a new file",
newFile: "New file",
+6
View File
@@ -2982,6 +2982,12 @@ export const es: TranslationKeys = {
importFromFiles: "Importar desde Archivos",
saveToFiles: "Guardar en Archivos",
savedToFiles: "Guardado en Archivos",
librarySaveTitle: "Este archivo proviene de tus Archivos",
librarySaveAsNew: "Guardar el resultado como archivo nuevo",
librarySaveAsNewHint: "Conserva el original en tus Archivos",
libraryOverwrite: "Sobrescribir el original",
libraryOverwriteHint: "El resultado reemplaza al original en tus Archivos",
viewInFiles: "Ver en Archivos",
adjustSettings: "Ajustar configuración",
startOver: "Empezar de nuevo con un archivo nuevo",
newFile: "Nuevo archivo",
+6
View File
@@ -3008,6 +3008,12 @@ export const fr: TranslationKeys = {
importFromFiles: "Importer depuis Fichiers",
saveToFiles: "Enregistrer dans Fichiers",
savedToFiles: "Enregistré dans Fichiers",
librarySaveTitle: "Ce fichier provient de vos Fichiers",
librarySaveAsNew: "Enregistrer le résultat comme nouveau fichier",
librarySaveAsNewHint: "Conserve l'original dans vos Fichiers",
libraryOverwrite: "Écraser l'original",
libraryOverwriteHint: "Le résultat remplace l'original dans vos Fichiers",
viewInFiles: "Voir dans Fichiers",
adjustSettings: "Ajuster les paramètres",
startOver: "Recommencer avec un nouveau fichier",
newFile: "Nouveau fichier",
+6
View File
@@ -2806,6 +2806,12 @@ export const hi: TranslationKeys = {
importFromFiles: "फ़ाइल्स से आयात करें",
saveToFiles: "फ़ाइल्स में सहेजें",
savedToFiles: "फ़ाइल्स में सहेजा गया",
librarySaveTitle: "यह फ़ाइल आपकी फ़ाइल्स से है",
librarySaveAsNew: "परिणाम को नई फ़ाइल के रूप में सहेजें",
librarySaveAsNewHint: "मूल फ़ाइल आपकी फ़ाइल्स में बनी रहती है",
libraryOverwrite: "मूल फ़ाइल को अधिलेखित करें",
libraryOverwriteHint: "परिणाम आपकी फ़ाइल्स में मूल फ़ाइल की जगह लेता है",
viewInFiles: "फ़ाइल्स में देखें",
adjustSettings: "सेटिंग्स समायोजित करें",
startOver: "नई फ़ाइल से शुरू करें",
newFile: "नई फ़ाइल",
+6
View File
@@ -2991,6 +2991,12 @@ export const id: TranslationKeys = {
importFromFiles: "Impor dari File",
saveToFiles: "Simpan ke File",
savedToFiles: "Tersimpan ke File",
librarySaveTitle: "File ini berasal dari File Anda",
librarySaveAsNew: "Simpan hasil sebagai file baru",
librarySaveAsNewHint: "File asli tetap tersimpan di File Anda",
libraryOverwrite: "Timpa file asli",
libraryOverwriteHint: "Hasil menggantikan file asli di File Anda",
viewInFiles: "Lihat di File",
adjustSettings: "Sesuaikan pengaturan",
startOver: "Mulai ulang dengan file baru",
newFile: "File baru",
+6
View File
@@ -2996,6 +2996,12 @@ export const it: TranslationKeys = {
importFromFiles: "Importa da File",
saveToFiles: "Salva su File",
savedToFiles: "Salvato su File",
librarySaveTitle: "Questo file proviene dai tuoi File",
librarySaveAsNew: "Salva il risultato come nuovo file",
librarySaveAsNewHint: "L'originale resta nei tuoi File",
libraryOverwrite: "Sovrascrivi l'originale",
libraryOverwriteHint: "Il risultato sostituisce l'originale nei tuoi File",
viewInFiles: "Visualizza in File",
adjustSettings: "Modifica impostazioni",
startOver: "Ricomincia con un nuovo file",
newFile: "Nuovo file",
+6
View File
@@ -2949,6 +2949,12 @@ export const ja: TranslationKeys = {
importFromFiles: "ファイルからインポート",
saveToFiles: "ファイルに保存",
savedToFiles: "ファイルに保存しました",
librarySaveTitle: "このファイルはマイファイルから読み込まれています",
librarySaveAsNew: "結果を新しいファイルとして保存",
librarySaveAsNewHint: "元のファイルはマイファイルに残ります",
libraryOverwrite: "元のファイルを上書き",
libraryOverwriteHint: "結果がマイファイル内の元のファイルを置き換えます",
viewInFiles: "マイファイルで表示",
adjustSettings: "設定を調整",
startOver: "新しいファイルでやり直す",
newFile: "新しいファイル",
+6
View File
@@ -2929,6 +2929,12 @@ export const ko: TranslationKeys = {
importFromFiles: "파일에서 가져오기",
saveToFiles: "파일에 저장",
savedToFiles: "파일에 저장됨",
librarySaveTitle: "이 파일은 내 파일에서 가져왔습니다",
librarySaveAsNew: "결과를 새 파일로 저장",
librarySaveAsNewHint: "원본은 내 파일에 유지됩니다",
libraryOverwrite: "원본 덮어쓰기",
libraryOverwriteHint: "결과가 내 파일의 원본을 대체합니다",
viewInFiles: "내 파일에서 보기",
adjustSettings: "설정 조정",
startOver: "새 파일로 다시 시작",
newFile: "새 파일",
+6
View File
@@ -2997,6 +2997,12 @@ export const nl: TranslationKeys = {
importFromFiles: "Importeren uit Bestanden",
saveToFiles: "Opslaan in Bestanden",
savedToFiles: "Opgeslagen in Bestanden",
librarySaveTitle: "Dit bestand komt uit je Bestanden",
librarySaveAsNew: "Resultaat opslaan als nieuw bestand",
librarySaveAsNewHint: "Het origineel blijft in je Bestanden",
libraryOverwrite: "Origineel overschrijven",
libraryOverwriteHint: "Het resultaat vervangt het origineel in je Bestanden",
viewInFiles: "Bekijken in Bestanden",
adjustSettings: "Instellingen aanpassen",
startOver: "Opnieuw beginnen met een nieuw bestand",
newFile: "Nieuw bestand",
+6
View File
@@ -2995,6 +2995,12 @@ export const pl: TranslationKeys = {
importFromFiles: "Importuj z Plików",
saveToFiles: "Zapisz do Plików",
savedToFiles: "Zapisano do Plików",
librarySaveTitle: "Ten plik pochodzi z Twoich plików",
librarySaveAsNew: "Zapisz wynik jako nowy plik",
librarySaveAsNewHint: "Oryginał pozostaje w Twoich plikach",
libraryOverwrite: "Nadpisz oryginał",
libraryOverwriteHint: "Wynik zastępuje oryginał w Twoich plikach",
viewInFiles: "Zobacz w Plikach",
adjustSettings: "Dostosuj ustawienia",
startOver: "Zacznij od nowa z nowym plikiem",
newFile: "Nowy plik",
+6
View File
@@ -2992,6 +2992,12 @@ export const ptBR: TranslationKeys = {
importFromFiles: "Importar de Arquivos",
saveToFiles: "Salvar em Arquivos",
savedToFiles: "Salvo em Arquivos",
librarySaveTitle: "Este arquivo vem dos seus Arquivos",
librarySaveAsNew: "Salvar o resultado como um novo arquivo",
librarySaveAsNewHint: "Mantém o original nos seus Arquivos",
libraryOverwrite: "Sobrescrever o original",
libraryOverwriteHint: "O resultado substitui o original nos seus Arquivos",
viewInFiles: "Ver em Arquivos",
adjustSettings: "Ajustar configurações",
startOver: "Recomeçar com um novo arquivo",
newFile: "Novo arquivo",
+6
View File
@@ -2993,6 +2993,12 @@ export const ru: TranslationKeys = {
importFromFiles: "Импорт из Файлов",
saveToFiles: "Сохранить в Файлы",
savedToFiles: "Сохранено в Файлы",
librarySaveTitle: "Этот файл из ваших файлов",
librarySaveAsNew: "Сохранить результат как новый файл",
librarySaveAsNewHint: "Оригинал останется в ваших файлах",
libraryOverwrite: "Перезаписать оригинал",
libraryOverwriteHint: "Результат заменит оригинал в ваших файлах",
viewInFiles: "Открыть в Файлах",
adjustSettings: "Настроить параметры",
startOver: "Начать заново с новым файлом",
newFile: "Новый файл",
+6
View File
@@ -2990,6 +2990,12 @@ export const sv: TranslationKeys = {
importFromFiles: "Importera från Filer",
saveToFiles: "Spara till Filer",
savedToFiles: "Sparad till Filer",
librarySaveTitle: "Den här filen kommer från dina Filer",
librarySaveAsNew: "Spara resultatet som en ny fil",
librarySaveAsNewHint: "Originalet finns kvar i dina Filer",
libraryOverwrite: "Skriv över originalet",
libraryOverwriteHint: "Resultatet ersätter originalet i dina Filer",
viewInFiles: "Visa i Filer",
adjustSettings: "Justera inställningar",
startOver: "Börja om med en ny fil",
newFile: "Ny fil",
+6
View File
@@ -2959,6 +2959,12 @@ export const th: TranslationKeys = {
importFromFiles: "นำเข้าจากไฟล์",
saveToFiles: "บันทึกไปยังไฟล์",
savedToFiles: "บันทึกไปยังไฟล์แล้ว",
librarySaveTitle: "ไฟล์นี้มาจากไฟล์ของคุณ",
librarySaveAsNew: "บันทึกผลลัพธ์เป็นไฟล์ใหม่",
librarySaveAsNewHint: "เก็บไฟล์ต้นฉบับไว้ในไฟล์ของคุณ",
libraryOverwrite: "เขียนทับไฟล์ต้นฉบับ",
libraryOverwriteHint: "ผลลัพธ์จะแทนที่ไฟล์ต้นฉบับในไฟล์ของคุณ",
viewInFiles: "ดูในไฟล์",
adjustSettings: "ปรับการตั้งค่า",
startOver: "เริ่มใหม่ด้วยไฟล์ใหม่",
newFile: "ไฟล์ใหม่",
+6
View File
@@ -2994,6 +2994,12 @@ export const tr: TranslationKeys = {
importFromFiles: "Dosyalardan İçe Aktar",
saveToFiles: "Dosyalara Kaydet",
savedToFiles: "Dosyalara Kaydedildi",
librarySaveTitle: "Bu dosya Dosyalarınızdan geliyor",
librarySaveAsNew: "Sonucu yeni dosya olarak kaydet",
librarySaveAsNewHint: "Orijinal, Dosyalarınızda kalır",
libraryOverwrite: "Orijinalin üzerine yaz",
libraryOverwriteHint: "Sonuç, Dosyalarınızdaki orijinalin yerini alır",
viewInFiles: "Dosyalarda görüntüle",
adjustSettings: "Ayarları düzenle",
startOver: "Yeni bir dosya ile baştan başla",
newFile: "Yeni dosya",
+6
View File
@@ -2992,6 +2992,12 @@ export const uk: TranslationKeys = {
importFromFiles: "Імпортувати з файлів",
saveToFiles: "Зберегти у файли",
savedToFiles: "Збережено у файли",
librarySaveTitle: "Цей файл із ваших файлів",
librarySaveAsNew: "Зберегти результат як новий файл",
librarySaveAsNewHint: "Оригінал залишиться у ваших файлах",
libraryOverwrite: "Перезаписати оригінал",
libraryOverwriteHint: "Результат замінить оригінал у ваших файлах",
viewInFiles: "Переглянути у файлах",
adjustSettings: "Налаштувати параметри",
startOver: "Почати спочатку з новим файлом",
newFile: "Новий файл",
+6
View File
@@ -2991,6 +2991,12 @@ export const vi: TranslationKeys = {
importFromFiles: "Nhập từ Tệp",
saveToFiles: "Lưu vào Tệp",
savedToFiles: "Đã lưu vào Tệp",
librarySaveTitle: "Tệp này lấy từ Tệp của bạn",
librarySaveAsNew: "Lưu kết quả thành tệp mới",
librarySaveAsNewHint: "Giữ nguyên tệp gốc trong Tệp của bạn",
libraryOverwrite: "Ghi đè tệp gốc",
libraryOverwriteHint: "Kết quả sẽ thay thế tệp gốc trong Tệp của bạn",
viewInFiles: "Xem trong Tệp",
adjustSettings: "Điều chỉnh cài đặt",
startOver: "Bắt đầu lại với tệp mới",
newFile: "Tệp mới",
+6
View File
@@ -2746,6 +2746,12 @@ export const zhCN: TranslationKeys = {
importFromFiles: "从文件库导入",
saveToFiles: "保存到文件库",
savedToFiles: "已保存到文件库",
librarySaveTitle: "此文件来自文件库",
librarySaveAsNew: "将结果保存为新文件",
librarySaveAsNewHint: "原文件保留在文件库中",
libraryOverwrite: "覆盖原文件",
libraryOverwriteHint: "结果将替换文件库中的原文件",
viewInFiles: "在文件库中查看",
adjustSettings: "调整设置",
startOver: "使用新文件重新开始",
newFile: "新文件",
+6
View File
@@ -2746,6 +2746,12 @@ export const zhTW: TranslationKeys = {
importFromFiles: "從檔案庫匯入",
saveToFiles: "儲存至檔案庫",
savedToFiles: "已儲存至檔案庫",
librarySaveTitle: "此檔案來自檔案庫",
librarySaveAsNew: "將結果儲存為新檔案",
librarySaveAsNewHint: "原始檔案保留在檔案庫中",
libraryOverwrite: "覆寫原始檔案",
libraryOverwriteHint: "結果將取代檔案庫中的原始檔案",
viewInFiles: "在檔案庫中檢視",
adjustSettings: "調整設定",
startOver: "重新開始處理新檔案",
newFile: "新檔案",
+1
View File
@@ -8,6 +8,7 @@ export * from "./constants.js";
export * from "./conversion-presets.js";
export * from "./features.js";
export * from "./i18n/index.js";
export * from "./library-save.js";
export * from "./modality.js";
export * from "./permissions.js";
export * from "./pipeline-templates.js";
+55
View File
@@ -0,0 +1,55 @@
import { BASE_CONFIG, CONVERSION_PRESETS } from "./conversion-presets.js";
/**
* Tools where the library save-mode choice (#495) does not work end to end,
* so the selector must stay hidden: either the tool's hand-written route
* ignores the multipart fileId/saveMode pair, or its settings component
* submits through its own XHR without sending them. For these tools a
* library-sourced edit is never auto-saved (the pre-#495 status quo).
*
* When wiring one of these up (parse fileId/saveMode in the route like
* tool-factory.ts, append them in the client like sign-pdf-settings.tsx),
* remove it from this set so the selector appears. The integration suite
* pins the other direction: every tool NOT listed here returns 400 for an
* invalid saveMode.
*/
export const LIBRARY_SAVE_MODE_UNSUPPORTED_TOOLS: ReadonlySet<string> = new Set([
// Custom routes without fileId/saveMode handling
"barcode-generate",
"barcode-read",
"beautify",
"bulk-rename",
"collage",
"color-palette",
"compare",
"compose",
"content-aware-resize",
"edit-metadata",
"favicon",
"find-duplicates",
"gif-tools",
"html-to-image",
"image-enhancement",
"image-to-base64",
"image-to-pdf",
"info",
"meme-generator",
"optimize-for-web",
"passport-photo",
"pdf-to-image",
"qr-generate",
"split",
"stitch",
"strip-metadata",
"vectorize",
"watermark-image",
// Routes accept saveMode but the custom client submitters do not send it yet
"ocr",
"remove-background",
"erase-object",
"background-replace",
"blur-background",
// Conversion presets served by the custom image-to-pdf / pdf-to-image /
// svg-to-raster routes (the registry group rides the factory and works)
...CONVERSION_PRESETS.filter((p) => BASE_CONFIG[p.base]?.group !== "registry").map((p) => p.id),
]);
+9
View File
@@ -51,6 +51,15 @@ export type ToolCategory =
| "data"
| "archives";
/**
* How a tool result is saved back to the file library when the input came
* from it (issue #495). "new" keeps the original and adds an independent
* file; "overwrite" supersedes the original with a linked version. The
* default everywhere is "new" so users never lose an original by accident.
*/
export const LIBRARY_SAVE_MODES = ["new", "overwrite"] as const;
export type LibrarySaveMode = (typeof LIBRARY_SAVE_MODES)[number];
export interface CategoryInfo {
id: ToolCategory;
name: string;
+1 -1
View File
@@ -22,7 +22,7 @@ const e2eDatabaseUrl = (() => {
// or assert on global lists/timing. These run in the chromium-serial project
// with --workers=1; everything else parallelizes safely.
const SERIAL_SPECS =
/gui-settings-|settings\.spec|rbac|security|people|api\.spec|state-bleed|full-session|gui-file-carry|i18n|theme|gui-performance/;
/gui-settings-|settings\.spec|rbac|security|people|api\.spec|state-bleed|full-session|gui-file-carry|library-save-mode|i18n|theme|gui-performance/;
// Screenshot-comparison specs. Separate project because baselines are
// platform-specific: they run locally (darwin baselines) and via the
+127
View File
@@ -0,0 +1,127 @@
import fs from "node:fs";
import type { Page } from "@playwright/test";
import { expect, getTestImagePath, test, uploadTestImage, waitForProcessing } from "./helpers";
// ---------------------------------------------------------------------------
// Library save-mode choice (issue #495)
//
// When a tool input comes from the file library, the tool page offers a
// per-edit choice: save the result as a new file (default, original stays)
// or overwrite the original (superseding version). Serial bucket: these
// tests assert on the global library list.
// ---------------------------------------------------------------------------
async function authHeaders(page: Page): Promise<Record<string, string>> {
const token = await page
.evaluate(() => localStorage.getItem("snapotter-token"))
.catch(() => null);
return token ? { authorization: `Bearer ${token}` } : {};
}
/** Upload a PNG into the library via the API, return its file id. */
async function seedLibraryFile(page: Page, name: string): Promise<string> {
const res = await page.request.post("/api/v1/files/upload", {
headers: await authHeaders(page),
multipart: {
file: { name, mimeType: "image/png", buffer: fs.readFileSync(getTestImagePath()) },
},
});
expect(res.ok()).toBeTruthy();
return (await res.json()).files[0].id;
}
/** List library file ids matching a search term. */
async function listLibraryIds(page: Page, search: string): Promise<string[]> {
const res = await page.request.get(`/api/v1/files?search=${encodeURIComponent(search)}`, {
headers: await authHeaders(page),
});
expect(res.ok()).toBeTruthy();
return ((await res.json()).files as Array<{ id: string }>).map((f) => f.id);
}
/** Import a seeded library file into the resize tool via the files page. */
async function importIntoResize(page: Page, filename: string) {
await page.goto("/image/resize");
await page.getByRole("link", { name: /import from files/i }).click();
await expect(page).toHaveURL(/\/files/);
// Search (300ms debounce), then select the file row and confirm
await page.getByPlaceholder("Search files...").fill(filename.replace(".png", ""));
await page.getByText(filename, { exact: true }).first().click();
await page.getByRole("button", { name: /select file/i }).click();
// Back on the tool page with the file staged and library-linked
await expect(page).toHaveURL(/\/image\/resize/);
await expect(page.getByText("This file is from your Files")).toBeVisible({ timeout: 10_000 });
}
async function processResize(page: Page) {
await page.locator("input[placeholder='Auto']").first().fill("200");
await page.getByTestId("resize-submit").click();
await waitForProcessing(page);
await expect(page.getByTestId("resize-download")).toBeVisible({ timeout: 15_000 });
}
test.describe("Library save mode", () => {
test("default keeps the original and saves the result as a new file", async ({
loggedInPage: page,
}) => {
const filename = `lsm-default-${Date.now()}.png`;
const originalId = await seedLibraryFile(page, filename);
await importIntoResize(page, filename);
// Non-destructive default is preselected
await expect(page.getByRole("radio", { name: /save result as a new file/i })).toBeChecked();
await processResize(page);
// The review panel reflects the auto-save instead of the manual link
await expect(page.getByText("Saved to Files")).toBeVisible();
await expect(page.getByRole("link", { name: /view in files/i })).toBeVisible();
// The manual save button is suppressed: it would create a duplicate copy
await expect(page.getByRole("button", { name: /save to files/i })).toHaveCount(0);
// Library now holds BOTH the original and the edited copy
const searchTerm = filename.replace(".png", "");
await expect
.poll(async () => (await listLibraryIds(page, searchTerm)).length, { timeout: 10_000 })
.toBe(2);
expect(await listLibraryIds(page, searchTerm)).toContain(originalId);
});
test("overwrite replaces the original in the library list", async ({ loggedInPage: page }) => {
const filename = `lsm-overwrite-${Date.now()}.png`;
const originalId = await seedLibraryFile(page, filename);
await importIntoResize(page, filename);
await page.getByRole("radio", { name: /overwrite the original/i }).check();
await processResize(page);
// Only the superseding version remains listed; it links back to the original
const searchTerm = filename.replace(".png", "");
await expect
.poll(async () => (await listLibraryIds(page, searchTerm)).length, { timeout: 10_000 })
.toBe(1);
const [survivorId] = await listLibraryIds(page, searchTerm);
expect(survivorId).not.toBe(originalId);
const detailRes = await page.request.get(`/api/v1/files/${survivorId}`, {
headers: await authHeaders(page),
});
expect(detailRes.ok()).toBeTruthy();
const detail = await detailRes.json();
expect(detail.file.version).toBe(2);
expect(detail.file.parentId).toBe(originalId);
});
test("plain uploads show no save-mode choice", async ({ loggedInPage: page }) => {
await page.goto("/image/resize");
await uploadTestImage(page);
await expect(page.getByText(/test-image/i).first()).toBeVisible();
await expect(page.getByText("This file is from your Files")).not.toBeVisible();
});
});
@@ -0,0 +1,262 @@
/**
* Integration tests for the library saveMode choice (issue #495).
*
* When a tool run references a library file (multipart fileId), the worker
* auto-saves the result. The saveMode field controls how:
* - "new" (default): insert an independent root file; the original stays
* visible in the library list.
* - "overwrite": insert a new version linked to the original, which then
* supersedes it in the leaf-only listing (pre-#495 behavior).
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { sharedRedis } from "../../../apps/api/src/jobs/connection.js";
import { bullPrefix } from "../../../apps/api/src/jobs/types.js";
import { fixtures, readFixture } from "../../fixtures/index.js";
import {
buildTestApp,
createMultipartPayload,
loginAsAdmin,
type TestApp,
} from "../test-server.js";
const PNG = readFixture(fixtures.image.base.png200);
let testApp: TestApp;
let app: TestApp["app"];
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
app = testApp.app;
adminToken = await loginAsAdmin(app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
/** Upload a PNG into the library, return its file id. */
async function uploadLibraryFile(filename: string): Promise<string> {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename, contentType: "image/png", content: PNG },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/files/upload",
headers: { "content-type": contentType, authorization: `Bearer ${adminToken}` },
body,
});
expect(res.statusCode).toBe(201);
return JSON.parse(res.body).files[0].id;
}
interface ToolRunResult {
statusCode: number;
savedFileId?: string;
error?: string;
}
/**
* Run the resize tool with optional fileId/saveMode fields. Tolerates the
* 202 async fallback by polling the terminal SSE replay key in Redis.
*/
async function runResize(opts: {
filename: string;
fileId?: string;
saveMode?: string;
toolPath?: string;
settings?: Record<string, unknown>;
}): Promise<ToolRunResult> {
const parts: Parameters<typeof createMultipartPayload>[0] = [
{
name: "file",
filename: opts.filename,
contentType: "image/png",
content: PNG,
},
{ name: "settings", content: JSON.stringify(opts.settings ?? { width: 100 }) },
];
if (opts.fileId) parts.push({ name: "fileId", content: opts.fileId });
if (opts.saveMode !== undefined) parts.push({ name: "saveMode", content: opts.saveMode });
const { body, contentType } = createMultipartPayload(parts);
const res = await app.inject({
method: "POST",
url: opts.toolPath ?? "/api/v1/tools/image/resize",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
if (res.statusCode === 200) {
const parsed = JSON.parse(res.body);
return { statusCode: 200, savedFileId: parsed.savedFileId };
}
if (res.statusCode === 202) {
// Async fallback: wait for the terminal SSE replay frame in Redis
const jobId = JSON.parse(res.body).jobId;
const key = `${bullPrefix()}:terminal:${jobId}`;
for (let i = 0; i < 150; i++) {
const cached = await sharedRedis().get(key);
if (cached) {
const frame = JSON.parse(cached);
expect(frame.phase).toBe("complete");
return { statusCode: 200, savedFileId: frame.result?.savedFileId };
}
await new Promise((r) => setTimeout(r, 200));
}
throw new Error("Timed out waiting for async job result");
}
let error: string | undefined;
try {
error = JSON.parse(res.body).error;
} catch {
// Non-JSON error body
}
return { statusCode: res.statusCode, error };
}
/** Fetch file detail (metadata + version chain). */
async function getFileDetail(id: string) {
const res = await app.inject({
method: "GET",
url: `/api/v1/files/${id}`,
headers: { authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
return JSON.parse(res.body);
}
/** List library files matching a search term, return their ids. */
async function listFileIds(search: string): Promise<string[]> {
const res = await app.inject({
method: "GET",
url: `/api/v1/files?search=${encodeURIComponent(search)}`,
headers: { authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
return JSON.parse(res.body).files.map((f: { id: string }) => f.id);
}
describe("Library saveMode: default (save as new file)", () => {
it("saves the result as an independent file and keeps the original listed", async () => {
const originalId = await uploadLibraryFile("lsmdef.png");
const run = await runResize({ filename: "lsmdef.png", fileId: originalId });
expect(run.statusCode).toBe(200);
expect(run.savedFileId).toBeDefined();
expect(run.savedFileId).not.toBe(originalId);
const detail = await getFileDetail(run.savedFileId!);
expect(detail.file.version).toBe(1);
expect(detail.file.parentId).toBeNull();
expect(detail.file.toolChain).toContain("resize");
// Both the original and the new file remain visible in the library list
const listed = await listFileIds("lsmdef");
expect(listed).toContain(originalId);
expect(listed).toContain(run.savedFileId!);
});
it("treats an explicit saveMode=new like the default", async () => {
const originalId = await uploadLibraryFile("lsmexp.png");
const run = await runResize({ filename: "lsmexp.png", fileId: originalId, saveMode: "new" });
expect(run.statusCode).toBe(200);
expect(run.savedFileId).toBeDefined();
const detail = await getFileDetail(run.savedFileId!);
expect(detail.file.version).toBe(1);
expect(detail.file.parentId).toBeNull();
const listed = await listFileIds("lsmexp");
expect(listed).toContain(originalId);
expect(listed).toContain(run.savedFileId!);
});
it("carries the parent's toolChain into the new file for provenance", async () => {
const originalId = await uploadLibraryFile("lsmchain.png");
// First edit overwrites the original (version chain)
const first = await runResize({
filename: "lsmchain.png",
fileId: originalId,
saveMode: "overwrite",
});
expect(first.statusCode).toBe(200);
expect(first.savedFileId).toBeDefined();
// Second edit saves as new from the version; chain accumulates
const second = await runResize({
filename: "lsmchain.png",
fileId: first.savedFileId!,
saveMode: "new",
toolPath: "/api/v1/tools/image/compress",
settings: { mode: "quality", quality: 90 },
});
expect(second.statusCode).toBe(200);
expect(second.savedFileId).toBeDefined();
const detail = await getFileDetail(second.savedFileId!);
expect(detail.file.version).toBe(1);
expect(detail.file.parentId).toBeNull();
expect(detail.file.toolChain).toEqual(["resize", "compress"]);
});
});
describe("Library saveMode: overwrite", () => {
it("creates a new version that supersedes the original in the list", async () => {
const originalId = await uploadLibraryFile("lsmover.png");
const run = await runResize({
filename: "lsmover.png",
fileId: originalId,
saveMode: "overwrite",
});
expect(run.statusCode).toBe(200);
expect(run.savedFileId).toBeDefined();
expect(run.savedFileId).not.toBe(originalId);
const detail = await getFileDetail(run.savedFileId!);
expect(detail.file.version).toBe(2);
expect(detail.file.parentId).toBe(originalId);
expect(detail.file.toolChain).toContain("resize");
// The original is superseded: only the new version appears in the list
const listed = await listFileIds("lsmover");
expect(listed).toContain(run.savedFileId!);
expect(listed).not.toContain(originalId);
});
});
describe("Library saveMode: validation and guards", () => {
it("rejects an invalid saveMode with 400", async () => {
const originalId = await uploadLibraryFile("lsmbad.png");
const run = await runResize({
filename: "lsmbad.png",
fileId: originalId,
saveMode: "destroy-everything",
});
expect(run.statusCode).toBe(400);
expect(run.error).toMatch(/saveMode/i);
});
it("does not save to the library when no fileId is sent", async () => {
const run = await runResize({ filename: "lsmnone.png" });
expect(run.statusCode).toBe(200);
expect(run.savedFileId).toBeUndefined();
const listed = await listFileIds("lsmnone");
expect(listed).toHaveLength(0);
});
it("silently skips the save for an unknown fileId", async () => {
const run = await runResize({
filename: "lsmghost.png",
fileId: "00000000-0000-0000-0000-000000000000",
});
expect(run.statusCode).toBe(200);
expect(run.savedFileId).toBeUndefined();
});
});
@@ -0,0 +1,167 @@
/**
* Per-route coverage for the saveMode multipart field (#495) on the 20
* hand-written tool routes that honor it. The parse-and-400 gate runs before
* file and bundle validation, so a lone bogus saveMode field pins each
* route's field capture and error contract without needing AI bundles.
*
* Mirrors ai-async-route-coverage.test.ts: bundle gates forced open and
* enqueueToolJob mocked so no Python model or worker runs.
*/
import { apiToolPath } from "@snapotter/shared";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { INVALID_SAVE_MODE_ERROR } from "../../../apps/api/src/jobs/types.js";
import { fixtures, readFixture } from "../../fixtures/index.js";
import {
buildTestApp,
createMultipartPayload,
loginAsAdmin,
type TestApp,
} from "../test-server.js";
/** Custom routes that parse and forward saveMode. */
const SAVE_MODE_ROUTES = [
"ai-canvas-expand",
"auto-subtitles",
"background-replace",
"blur-background",
"blur-faces",
"colorize",
"enhance-faces",
"erase-object",
"noise-removal",
"ocr",
"ocr-pdf",
"red-eye-removal",
"remove-background",
"remove-gif-background",
"restore-photo",
"sign-pdf",
"transcribe-audio",
"transparency-fixer",
"upscale",
];
const mocks = vi.hoisted(() => ({
enqueueToolJob: vi.fn(),
waitForJob: vi.fn(),
}));
vi.mock("../../../apps/api/src/lib/feature-status.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../../apps/api/src/lib/feature-status.js")>();
return {
...actual,
isToolInstalled: () => true,
};
});
vi.mock("../../../apps/api/src/jobs/enqueue.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../apps/api/src/jobs/enqueue.js")>();
return {
...actual,
enqueueToolJob: mocks.enqueueToolJob,
waitForJob: mocks.waitForJob,
};
});
const PDF = readFixture(fixtures.document.pdf2);
const PNG = readFixture(fixtures.image.base.png200);
let testApp: TestApp;
let app: TestApp["app"];
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
app = testApp.app;
adminToken = await loginAsAdmin(app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
beforeEach(() => {
mocks.enqueueToolJob.mockReset().mockResolvedValue({});
mocks.waitForJob.mockReset().mockResolvedValue(null);
});
describe("saveMode 400 gate on custom routes", () => {
for (const toolId of SAVE_MODE_ROUTES) {
it(`${toolId} rejects an invalid saveMode with 400`, async () => {
const { body, contentType } = createMultipartPayload([
{ name: "saveMode", content: "bogus" },
]);
const res = await app.inject({
method: "POST",
url: apiToolPath(toolId),
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.body).error).toBe(INVALID_SAVE_MODE_ERROR);
});
}
});
describe("saveMode is ignored by routes outside the feature", () => {
// Representative custom routes without fileId/saveMode handling: they are
// listed in LIBRARY_SAVE_MODE_UNSUPPORTED_TOOLS, so the selector never
// shows for them and a stray saveMode field must not change their errors.
for (const toolId of ["watermark-image", "edit-metadata"]) {
it(`${toolId} does not return the saveMode error`, async () => {
const { body, contentType } = createMultipartPayload([
{ name: "saveMode", content: "bogus" },
]);
const res = await app.inject({
method: "POST",
url: apiToolPath(toolId),
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBeGreaterThanOrEqual(400);
let error: string | undefined;
try {
error = JSON.parse(res.body).error;
} catch {
// Non-JSON error body is fine; it is certainly not the saveMode error
}
expect(error).not.toBe(INVALID_SAVE_MODE_ERROR);
});
}
});
describe("sign-pdf saveMode pass-through", () => {
it("forwards fileId and saveMode into the enqueued job", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "contract.pdf", contentType: "application/pdf", content: PDF },
{
name: "placements",
content: JSON.stringify([{ sig: 0, page: 0, x: 0.1, y: 0.1, w: 0.2, h: 0.1 }]),
},
{ name: "sig0", filename: "sig0.png", contentType: "image/png", content: PNG },
{ name: "fileId", content: "lib-contract" },
{ name: "saveMode", content: "overwrite" },
]);
const res = await app.inject({
method: "POST",
url: apiToolPath("sign-pdf"),
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(202);
expect(mocks.enqueueToolJob).toHaveBeenCalledTimes(1);
expect(mocks.enqueueToolJob.mock.calls[0][0]).toMatchObject({
toolId: "sign-pdf",
fileId: "lib-contract",
saveMode: "overwrite",
});
});
});
+69
View File
@@ -220,6 +220,7 @@ function createMockRequest(opts: {
filename?: string;
settings?: string;
fileId?: string;
saveMode?: string;
clientJobId?: string;
fileCount?: number;
}) {
@@ -260,6 +261,15 @@ function createMockRequest(opts: {
});
}
if (opts.saveMode) {
parts.push({
type: "field",
fieldname: "saveMode",
value: opts.saveMode,
file: (async function* () {})(),
});
}
if (opts.clientJobId) {
parts.push({
type: "field",
@@ -410,6 +420,65 @@ describe("createToolRoute", () => {
);
});
it("returns 400 for an invalid saveMode field", async () => {
const app = createMockApp();
const id = "resize";
createToolRoute(app as never, makeMockConfig(id));
const handler = app.routes[apiToolPath(id)];
const reply = createMockReply();
const req = createMockRequest({
fileBuffer: Buffer.from("png-data"),
settings: JSON.stringify({}),
fileId: "lib-1",
saveMode: "bogus",
});
await handler(req, reply);
expect(reply.status).toHaveBeenCalledWith(400);
expect(reply.send).toHaveBeenCalledWith(
expect.objectContaining({ error: expect.stringContaining("saveMode") }),
);
});
it("passes a valid saveMode through to the enqueued job", async () => {
const app = createMockApp();
const id = "resize";
createToolRoute(app as never, makeMockConfig(id));
const handler = app.routes[apiToolPath(id)];
const reply = createMockReply();
const req = createMockRequest({
fileBuffer: Buffer.from("png-data"),
settings: JSON.stringify({}),
fileId: "lib-1",
saveMode: "overwrite",
});
await handler(req, reply);
expect(vi.mocked(enqueueToolJob).mock.calls[0][0]).toMatchObject({
fileId: "lib-1",
saveMode: "overwrite",
});
});
it("enqueues no saveMode when the field is absent", async () => {
const app = createMockApp();
const id = "resize";
createToolRoute(app as never, makeMockConfig(id));
const handler = app.routes[apiToolPath(id)];
const reply = createMockReply();
const req = createMockRequest({
fileBuffer: Buffer.from("png-data"),
settings: JSON.stringify({}),
fileId: "lib-1",
});
await handler(req, reply);
expect(vi.mocked(enqueueToolJob).mock.calls[0][0].saveMode).toBeUndefined();
});
it("returns 501 when AI feature bundle is not installed", async () => {
vi.mocked(isToolInstalled).mockReturnValueOnce(false);
const app = createMockApp();
@@ -0,0 +1,99 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.stubGlobal("URL", {
...globalThis.URL,
createObjectURL: vi.fn(() => "blob:fake-url"),
revokeObjectURL: vi.fn(),
});
vi.mock("@/lib/image-preview", () => ({
needsServerPreview: vi.fn(() => false),
fetchDecodedPreview: vi.fn(() => Promise.resolve(null)),
}));
vi.mock("@/lib/analytics", () => ({
track: vi.fn(),
}));
import { LibrarySaveModeSelector } from "@/components/common/library-save-mode-selector";
import { useFileStore } from "@/stores/file-store";
function makeFile(name: string): File {
return new File([new ArrayBuffer(64)], name, { type: "image/png" });
}
function stageLibraryFile() {
useFileStore.getState().setFiles([makeFile("photo.png")]);
useFileStore.getState().updateEntry(0, { serverFileId: "lib-1" });
}
describe("LibrarySaveModeSelector", () => {
beforeEach(() => {
useFileStore.getState().reset();
});
afterEach(() => {
cleanup();
});
it("renders nothing when the selected file is not from the library", () => {
useFileStore.getState().setFiles([makeFile("plain.png")]);
const { container } = render(<LibrarySaveModeSelector toolId="resize" />);
expect(container.innerHTML).toBe("");
});
it("shows both choices with 'save as new' selected by default", () => {
stageLibraryFile();
render(<LibrarySaveModeSelector toolId="resize" />);
const saveAsNew = screen.getByRole("radio", { name: /save result as a new file/i });
const overwrite = screen.getByRole("radio", { name: /overwrite the original/i });
expect((saveAsNew as HTMLInputElement).checked).toBe(true);
expect((overwrite as HTMLInputElement).checked).toBe(false);
});
it("updates the store when overwrite is chosen", () => {
stageLibraryFile();
render(<LibrarySaveModeSelector toolId="resize" />);
fireEvent.click(screen.getByRole("radio", { name: /overwrite the original/i }));
expect(useFileStore.getState().librarySaveMode).toBe("overwrite");
});
it("disables the choice while processing", () => {
stageLibraryFile();
useFileStore.getState().setProcessing(true);
render(<LibrarySaveModeSelector toolId="resize" />);
const saveAsNew = screen.getByRole("radio", { name: /save result as a new file/i });
const overwrite = screen.getByRole("radio", { name: /overwrite the original/i });
expect((saveAsNew as HTMLInputElement).disabled).toBe(true);
expect((overwrite as HTMLInputElement).disabled).toBe(true);
});
it("renders nothing for tools that do not honor the save mode", () => {
stageLibraryFile();
const { container } = render(<LibrarySaveModeSelector toolId="watermark-image" />);
expect(container.innerHTML).toBe("");
});
it("renders nothing when multiple files would go through the batch path", () => {
useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]);
useFileStore.getState().updateEntry(0, { serverFileId: "lib-1" });
useFileStore.getState().updateEntry(1, { serverFileId: "lib-2" });
const { container } = render(<LibrarySaveModeSelector toolId="compress" />);
expect(container.innerHTML).toBe("");
});
it("still renders for multi-input tools that process all files in one run", () => {
useFileStore.getState().setFiles([makeFile("a.pdf"), makeFile("b.pdf")]);
useFileStore.getState().updateEntry(0, { serverFileId: "lib-1" });
render(<LibrarySaveModeSelector toolId="merge-pdf" />);
expect(screen.getByRole("radio", { name: /save result as a new file/i })).toBeDefined();
});
});
@@ -0,0 +1,61 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/analytics", () => ({
track: vi.fn(),
}));
vi.mock("@/components/feedback/tool-feedback-prompt", () => ({
ToolFeedbackPrompt: () => null,
}));
import { ReviewPanel } from "@/components/common/review-panel";
function renderPanel(props: Partial<Parameters<typeof ReviewPanel>[0]> = {}) {
return render(
<MemoryRouter>
<ReviewPanel
filename="photo_resize.png"
fileSize={512}
fileType="PNG"
originalSize={1024}
downloadUrl="/api/v1/download/job-1/photo_resize.png"
onUndo={() => {}}
onStartOver={() => {}}
currentToolId="resize"
{...props}
/>
</MemoryRouter>,
);
}
describe("ReviewPanel library saved state (issue #495)", () => {
afterEach(() => {
cleanup();
});
it("shows the manual save link when the result was not auto-saved", () => {
renderPanel();
expect(screen.getByRole("button", { name: /save to files/i })).toBeDefined();
expect(screen.queryByRole("link", { name: /view in files/i })).toBeNull();
});
it("replaces the manual save link with the saved indicator after an auto-save", () => {
renderPanel({ savedLibraryFileId: "lib-copy" });
expect(screen.getByText("Saved to Files")).toBeDefined();
expect(screen.getByRole("link", { name: /view in files/i })).toBeDefined();
// The manual save button must be gone: clicking it would create a duplicate
expect(screen.queryByRole("button", { name: /save to files/i })).toBeNull();
});
it("shows neither control for data-output tools", () => {
renderPanel({ currentToolId: "ocr", savedLibraryFileId: "lib-copy" });
expect(screen.queryByRole("button", { name: /save to files/i })).toBeNull();
expect(screen.queryByRole("link", { name: /view in files/i })).toBeNull();
});
});
@@ -0,0 +1,83 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from "vitest";
const revokeObjectURL = vi.fn();
const createObjectURL = vi.fn((_obj: Blob | MediaSource) => "blob:fake-url");
vi.stubGlobal("URL", {
...globalThis.URL,
createObjectURL,
revokeObjectURL,
});
const imagePreviewMock = vi.hoisted(() => ({
needsServerPreview: vi.fn(() => false),
fetchDecodedPreview: vi.fn(() => Promise.resolve(null)),
}));
vi.mock("@/lib/image-preview", () => imagePreviewMock);
vi.mock("@/lib/analytics", () => ({
track: vi.fn(),
}));
import { useFileStore } from "@/stores/file-store";
function makeFile(name: string, size = 1024, type = "image/png"): File {
const buf = new ArrayBuffer(size);
return new File([buf], name, { type });
}
describe("useFileStore library save mode (issue #495)", () => {
beforeEach(() => {
useFileStore.getState().reset();
vi.clearAllMocks();
});
it("defaults librarySaveMode to non-destructive 'new'", () => {
expect(useFileStore.getState().librarySaveMode).toBe("new");
});
it("setLibrarySaveMode switches the mode", () => {
useFileStore.getState().setLibrarySaveMode("overwrite");
expect(useFileStore.getState().librarySaveMode).toBe("overwrite");
});
it("reset restores the 'new' default and clears the last saved file id", () => {
useFileStore.getState().setLibrarySaveMode("overwrite");
useFileStore.getState().setLastSavedLibraryFileId("lib-1");
useFileStore.getState().reset();
expect(useFileStore.getState().librarySaveMode).toBe("new");
expect(useFileStore.getState().lastSavedLibraryFileId).toBeNull();
});
it("undoProcessing keeps the chosen mode but clears the last saved file id", () => {
useFileStore.getState().setFiles([makeFile("a.png")]);
useFileStore.getState().setLibrarySaveMode("overwrite");
useFileStore.getState().setLastSavedLibraryFileId("lib-2");
useFileStore.getState().undoProcessing();
expect(useFileStore.getState().librarySaveMode).toBe("overwrite");
expect(useFileStore.getState().lastSavedLibraryFileId).toBeNull();
});
it("staging a new file set restores the non-destructive default", () => {
useFileStore.getState().setFiles([makeFile("a.png")]);
useFileStore.getState().setLibrarySaveMode("overwrite");
// A later library import stages a different file; the overwrite choice
// made for the previous file must not carry over (#495 review finding).
useFileStore.getState().setFiles([makeFile("b.png")]);
expect(useFileStore.getState().librarySaveMode).toBe("new");
});
it("tracks the last saved library file id", () => {
expect(useFileStore.getState().lastSavedLibraryFileId).toBeNull();
useFileStore.getState().setLastSavedLibraryFileId("lib-3");
expect(useFileStore.getState().lastSavedLibraryFileId).toBe("lib-3");
});
});
@@ -0,0 +1,273 @@
// @vitest-environment jsdom
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/image-preview", () => ({
needsServerPreview: vi.fn(() => false),
fetchDecodedPreview: vi.fn(() => Promise.resolve(null)),
}));
vi.mock("@/lib/analytics", () => ({
track: vi.fn(),
}));
vi.mock("@/lib/api", () => ({
formatHeaders: () => new Map<string, string>(),
parseApiError: () => "error",
}));
vi.mock("@/lib/utils", async (importOriginal) => {
const actual: Record<string, unknown> = await importOriginal();
return { ...actual, generateId: () => "11111111-1111-4111-8111-111111111111" };
});
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { useFileStore } from "@/stores/file-store";
interface MockXhr {
status: number;
responseText: string;
timeout: number;
upload: { onprogress?: unknown; onload?: unknown };
onload?: () => void;
onerror?: (() => void) | null;
ontimeout?: (() => void) | null;
open: ReturnType<typeof vi.fn>;
send: ReturnType<typeof vi.fn>;
setRequestHeader: ReturnType<typeof vi.fn>;
abort: ReturnType<typeof vi.fn>;
}
class MockEventSource {
static instances: MockEventSource[] = [];
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
readyState = 1;
close = vi.fn();
constructor(readonly url: string) {
MockEventSource.instances.push(this);
}
}
let xhrs: MockXhr[];
beforeEach(() => {
vi.stubGlobal("URL", {
...globalThis.URL,
createObjectURL: vi.fn(() => "blob:fake-url"),
revokeObjectURL: vi.fn(),
});
useFileStore.getState().reset();
xhrs = [];
MockEventSource.instances = [];
vi.stubGlobal("EventSource", MockEventSource);
vi.stubGlobal(
"XMLHttpRequest",
vi.fn(() => {
const xhr: MockXhr = {
status: 0,
responseText: "",
timeout: 0,
upload: {},
open: vi.fn(),
send: vi.fn(),
setRequestHeader: vi.fn(),
abort: vi.fn(),
};
xhrs.push(xhr);
return xhr;
}),
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
function stageLibraryFile() {
const file = new File([new ArrayBuffer(64)], "photo.png", { type: "image/png" });
useFileStore.getState().setFiles([file]);
useFileStore.getState().updateEntry(0, { serverFileId: "lib-original" });
return file;
}
function completeRun(xhr: MockXhr, savedFileId: string) {
xhr.status = 200;
xhr.responseText = JSON.stringify({
jobId: "job-1",
downloadUrl: "/api/v1/download/job-1/photo_resize.png",
originalSize: 64,
processedSize: 32,
savedFileId,
});
xhr.onload?.();
}
describe("useToolProcessor library save mode (issue #495)", () => {
it("sends the chosen saveMode with the library fileId", async () => {
const file = stageLibraryFile();
useFileStore.getState().setLibrarySaveMode("overwrite");
const { result, unmount } = renderHook(() => useToolProcessor("resize"));
act(() => {
result.current.processFiles([file], {});
});
const sent = xhrs[0].send.mock.calls[0][0] as FormData;
expect(sent.get("fileId")).toBe("lib-original");
expect(sent.get("saveMode")).toBe("overwrite");
unmount();
});
it("keeps serverFileId anchored to the original in 'new' mode", async () => {
const file = stageLibraryFile();
const { result, unmount } = renderHook(() => useToolProcessor("resize"));
act(() => {
result.current.processFiles([file], {});
});
act(() => {
completeRun(xhrs[0], "lib-copy");
});
expect(useFileStore.getState().entries[0].serverFileId).toBe("lib-original");
expect(useFileStore.getState().lastSavedLibraryFileId).toBe("lib-copy");
unmount();
});
it("re-anchors serverFileId to the saved version in 'overwrite' mode", async () => {
const file = stageLibraryFile();
useFileStore.getState().setLibrarySaveMode("overwrite");
const { result, unmount } = renderHook(() => useToolProcessor("resize"));
act(() => {
result.current.processFiles([file], {});
});
act(() => {
completeRun(xhrs[0], "lib-v2");
});
expect(useFileStore.getState().entries[0].serverFileId).toBe("lib-v2");
expect(useFileStore.getState().lastSavedLibraryFileId).toBe("lib-v2");
unmount();
});
it("sends no saveMode for files that are not from the library", async () => {
const file = new File([new ArrayBuffer(64)], "plain.png", { type: "image/png" });
useFileStore.getState().setFiles([file]);
const { result, unmount } = renderHook(() => useToolProcessor("resize"));
act(() => {
result.current.processFiles([file], {});
});
const sent = xhrs[0].send.mock.calls[0][0] as FormData;
expect(sent.get("fileId")).toBeNull();
expect(sent.get("saveMode")).toBeNull();
unmount();
});
it("keeps serverFileId anchored to the original on the async SSE path in 'new' mode", async () => {
const file = stageLibraryFile();
const { result, unmount } = renderHook(() => useToolProcessor("resize"));
act(() => {
result.current.processFiles([file], {});
});
// 202 accepted: completion arrives via the SSE progress stream instead
act(() => {
xhrs[0].status = 202;
xhrs[0].responseText = JSON.stringify({ jobId: "job-1", async: true });
xhrs[0].onload?.();
});
act(() => {
MockEventSource.instances[0].onmessage?.({
data: JSON.stringify({
type: "single",
phase: "complete",
result: {
jobId: "job-1",
downloadUrl: "/api/v1/download/job-1/photo_resize.png",
originalSize: 64,
processedSize: 32,
savedFileId: "lib-copy",
},
}),
} as MessageEvent);
});
expect(useFileStore.getState().entries[0].serverFileId).toBe("lib-original");
expect(useFileStore.getState().lastSavedLibraryFileId).toBe("lib-copy");
unmount();
});
it("re-anchors serverFileId on the async SSE path in 'overwrite' mode", async () => {
const file = stageLibraryFile();
useFileStore.getState().setLibrarySaveMode("overwrite");
const { result, unmount } = renderHook(() => useToolProcessor("resize"));
act(() => {
result.current.processFiles([file], {});
});
act(() => {
xhrs[0].status = 202;
xhrs[0].responseText = JSON.stringify({ jobId: "job-1", async: true });
xhrs[0].onload?.();
});
act(() => {
MockEventSource.instances[0].onmessage?.({
data: JSON.stringify({
type: "single",
phase: "complete",
result: {
jobId: "job-1",
downloadUrl: "/api/v1/download/job-1/photo_resize.png",
originalSize: 64,
processedSize: 32,
savedFileId: "lib-v2",
},
}),
} as MessageEvent);
});
expect(useFileStore.getState().entries[0].serverFileId).toBe("lib-v2");
expect(useFileStore.getState().lastSavedLibraryFileId).toBe("lib-v2");
unmount();
});
it("clears the saved indicator when a batch run starts", async () => {
const fileA = new File([new ArrayBuffer(64)], "a.png", { type: "image/png" });
const fileB = new File([new ArrayBuffer(64)], "b.png", { type: "image/png" });
useFileStore.getState().setFiles([fileA, fileB]);
useFileStore.getState().setLastSavedLibraryFileId("lib-stale");
const { result, unmount } = renderHook(() => useToolProcessor("compress"));
act(() => {
result.current.processAllFiles([fileA, fileB], {});
});
expect(useFileStore.getState().lastSavedLibraryFileId).toBeNull();
unmount();
});
it("clears the previous saved indicator when a new run starts", async () => {
const file = stageLibraryFile();
const { result, unmount } = renderHook(() => useToolProcessor("resize"));
act(() => {
result.current.processFiles([file], {});
});
act(() => {
completeRun(xhrs[0], "lib-copy");
});
expect(useFileStore.getState().lastSavedLibraryFileId).toBe("lib-copy");
act(() => {
result.current.processFiles([file], {});
});
expect(useFileStore.getState().lastSavedLibraryFileId).toBeNull();
unmount();
});
});