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
+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;