diff --git a/client/src/console/files/ConflictDialog.tsx b/client/src/console/files/ConflictDialog.tsx index 5a473fd1..e2c78aa4 100644 --- a/client/src/console/files/ConflictDialog.tsx +++ b/client/src/console/files/ConflictDialog.tsx @@ -20,7 +20,13 @@ export function ConflictDialog({ conflict, onAnswer }: { conflict: Conflict; onA return ( onAnswer({ choice: "replace", all })} diff --git a/client/src/console/files/Files.test.tsx b/client/src/console/files/Files.test.tsx index 4818f6ee..41119aba 100644 --- a/client/src/console/files/Files.test.tsx +++ b/client/src/console/files/Files.test.tsx @@ -381,7 +381,7 @@ describe("Files listing controls", () => { }); it("paste messages: skipped, already here, failures", () => { - const base = { started: [], moved: [], failed: [], jobFailed: [], skipped: [], done: [], declined: [], here: [] }; + const base = { started: [], moved: [], failed: [], jobFailed: [], skipped: [], done: [], left: [], halted: [], declined: [], here: [] }; expect(pasteMessage({ ...base, done: ["/a"] })?.text).toBe(t("cw_files_done")); expect(pasteMessage({ ...base, done: ["/a"], declined: ["/b", "/c"] })?.text).toBe(t("cw_files_paste_done_skipped", { n: 2 })); expect(pasteMessage({ ...base, here: ["/a"] })?.text).toBe(t("cw_files_paste_here_already")); @@ -390,5 +390,12 @@ describe("Files listing controls", () => { t("cw_files_err_into_itself"), ); expect(filesErrorText(new ApiError(500, "replace_delete_failed"))).toBe(t("cw_files_replace_failed")); + // Stop during a Replace's delete: that item is named, the rest counted. + expect(pasteMessage({ ...base, halted: ["/data/a/G"], skipped: ["/data/a/G"] })?.text).toBe( + t("cw_files_paste_halted", { name: "G" }), + ); + expect(pasteMessage({ ...base, halted: ["/data/a/G"], skipped: ["/data/a/G", "/data/a/H"] })?.text).toBe( + `${t("cw_files_paste_halted", { name: "G" })} ${t("cw_files_batch_stopped", { n: 1 })}`, + ); }); }); diff --git a/client/src/console/files/Files.tsx b/client/src/console/files/Files.tsx index fff25e1d..d2191d6c 100644 --- a/client/src/console/files/Files.tsx +++ b/client/src/console/files/Files.tsx @@ -55,6 +55,7 @@ import { dropFromClipboard, getClipboard, setClipboard, + skipPendingConflict, useClipboard, useConflict, type Clip, @@ -114,6 +115,11 @@ export function batchMessage(r: BatchResult, op?: Busy["op"]): Message | null { /** The line a finished paste leaves: batchMessage's, or what was skipped / already there. */ export function pasteMessage(r: PasteResult): Message | null { + if (r.halted.length && !r.failed.length && !r.jobFailed.length) { + const rest = r.skipped.length - r.halted.length; + const text = t("cw_files_paste_halted", { name: baseName(r.halted[0]) }); + return { tone: "info", text: rest > 0 ? `${text} ${t("cw_files_batch_stopped", { n: rest })}` : text }; + } if (r.failed.length || r.jobFailed.length || r.skipped.length) return batchMessage(r); if (!r.done.length && !r.declined.length && r.here.length) return { tone: "info", text: t("cw_files_paste_here_already") }; if (r.declined.length) return { tone: "info", text: t("cw_files_paste_done_skipped", { n: r.declined.length }) }; @@ -783,6 +789,12 @@ export function FilesView(p: FilesViewProps) { ); } +/** Stop: the batch ends before its next item, and a conflict it waits on is answered Skip. */ +export function stopBatchAndConflict(): void { + stopBatch(); + skipPendingConflict(); +} + /* ---- the section ---- */ function useVolumes(path: string | null, tick: number): { volumes: Volume[] | null; error: boolean } { @@ -863,6 +875,8 @@ export function Files({ ctx }: { ctx: SectionContext }) { mounted.current = true; return () => { mounted.current = false; + // A paste conflict nobody can answer now: skip it (and the rest), so the batch ends. + skipPendingConflict(); }; }, []); @@ -1355,7 +1369,7 @@ export function Files({ ctx }: { ctx: SectionContext }) { onUnmount={onUnmount} onGoTo={onGoTo} gameRunning={gameRunning} - onStopBatch={stopBatch} + onStopBatch={stopBatchAndConflict} clip={clip} conflict={conflict} onClip={onClip} diff --git a/client/src/console/files/clipboard.ts b/client/src/console/files/clipboard.ts index b87514f3..58882efd 100644 --- a/client/src/console/files/clipboard.ts +++ b/client/src/console/files/clipboard.ts @@ -5,6 +5,9 @@ * folders change and while another section is open, for the whole page * session; a paste that meets a conflict while Files is closed waits until * Files is open again to ask. Nothing here is saved: a reload empties it. + * + * Ruling R22: ○ (browser Back) never clears the clipboard, at any level; only + * the bar's Clear does, or a cut whose items all moved. */ import { useEffect, useState } from "react"; import type { Conflict, Decision, PasteMode } from "./paste"; @@ -94,6 +97,14 @@ export function askConflict(conflict: Conflict): Promise { }); } +/** + * Answer a waiting conflict with Skip for it and every later one (Stop, or + * Files closing): the batch never sits busy behind a dialog nobody can see. + */ +export function skipPendingConflict(): void { + pending.get()?.answer({ choice: "skip", all: true }); +} + export function currentConflict(): PendingConflict | null { return pending.get(); } diff --git a/client/src/console/files/paste.test.ts b/client/src/console/files/paste.test.ts index f4ba6b57..c0c2ccd8 100644 --- a/client/src/console/files/paste.test.ts +++ b/client/src/console/files/paste.test.ts @@ -342,3 +342,115 @@ describe("paste as the page's batch", () => { expect(currentBatch()).toBeNull(); }); }); + +describe("the item itself, by identity (exFAT is case-insensitive)", () => { + /** /mnt/usb0 answers any letter case with the same entry (dev + ino), like exFAT. */ + function exfat(fs: Record, ids = true) { + const key = (p: string) => (p.startsWith("/mnt/usb0") ? p.toLowerCase() : p); + const norm: Record = {}; + for (const [p, k] of Object.entries(fs)) norm[key(p)] = k; + let n = 0; + mockCall.mockImplementation(async (route: string, body?: unknown) => { + const b = body as Record; + if (route === "/api/fs/stat") { + const k = norm[key(b.path)]; + if (!k) throw new ApiError(404, "not_found"); + return (ids ? { kind: k, dev: "7", ino: key(b.path) } : { kind: k }) as never; + } + if (route === "/api/fs/delete") return { job: `del${++n}` } as never; + if (route === "/api/fs/copy") return { job: `job${++n}` } as never; + return {} as never; + }); + } + + it("a cut into the same folder spelled differently is 'already here', nothing is sent", async () => { + exfat({ "/mnt/usb0/Games": "dir", "/mnt/usb0/Games/x": "file" }); + const a = answers(); + const r = await pasteItems([{ path: "/mnt/usb0/Games/x", dir: false }], "/mnt/usb0/games", "cut", { + decide: a.decide, + volumes: VOLS, + }); + expect(a.asked).toEqual([]); + expect(routes()).toEqual([]); + expect(r.here).toEqual(["/mnt/usb0/Games/x"]); + }); + + it("the destination found is the item itself: Replace is never offered, Apply-to-all Replace skips it", async () => { + exfat({ "/mnt/usb0/Games": "dir", "/mnt/usb0/Games/x": "file", "/mnt/usb0/Games/y": "file" }); + const a = answers({ choice: "replace", all: true }); + const r = await pasteItems( + [ + { path: "/mnt/usb0/Games/x", dir: false }, + { path: "/mnt/usb0/Games/y", dir: false }, + ], + "/mnt/usb0/games", + "copy", + { decide: a.decide, volumes: VOLS }, + ); + expect(a.asked[0].canReplace).toBe(false); + expect(routes()).toEqual([]); // no delete, no copy + expect(r.declined).toEqual(["/mnt/usb0/Games/x", "/mnt/usb0/Games/y"]); + }); + + it("a cut whose folder ids differ but the destination is the item itself: no-op, never deleted", async () => { + exfat({ "/mnt/usb0/Games/x": "file" }); + // The folders' stats fail (no ids to compare): the item's own stat still catches it. + const base = mockCall.getMockImplementation()!; + mockCall.mockImplementation(async (route: string, body?: unknown) => { + const p = (body as { path?: string })?.path ?? ""; + if (route === "/api/fs/stat" && !p.endsWith("/x")) throw new ApiError(500, "io"); + return base(route, body as never); + }); + const r = await pasteItems([{ path: "/mnt/usb0/Games/x", dir: false }], "/mnt/usb0/GAMES", "cut", { + decide: answers({ choice: "replace", all: false }).decide, + volumes: VOLS, + }); + expect(routes()).toEqual([]); + expect(r.here).toEqual(["/mnt/usb0/Games/x"]); + }); + + it("an older helper without ids falls back to the path compare", async () => { + exfat({ "/data/a/x": "file", "/data/b/x": "file" }, false); + const a = answers({ choice: "replace", all: false }); + await pasteItems([{ path: "/data/a/x", dir: false }], "/data/b", "copy", { decide: a.decide }); + expect(a.asked[0].canReplace).toBe(true); + }); +}); + +describe("Stop leaves a job running", () => { + it("a cut whose job Stop left running is not counted done (it stays in the clipboard)", async () => { + fakeConsole({ "/mnt/usb0/G": "dir" }); + const r = await pasteItems([{ path: "/mnt/usb0/G", dir: true }], "/data/b", "cut", { decide: answers().decide, volumes: VOLS }, { + waitJob: async () => "left", + }); + expect(r.left).toEqual(["/mnt/usb0/G"]); + expect(r.done).toEqual([]); + }); + + it("Stop during a replace delete is reported as halted", async () => { + fakeConsole({ "/data/a/G": "dir", "/data/b/G": "dir" }); + const r = await pasteItems([{ path: "/data/a/G", dir: true }], "/data/b", "copy", { + decide: answers({ choice: "replace", all: false }).decide, + }, { waitJob: async () => "left" }); + expect(r.halted).toEqual(["/data/a/G"]); + expect(r.left).toEqual([]); + }); + + it("Stop / Files closing answers a waiting conflict with Skip for the rest", async () => { + const { skipPendingConflict } = await import("./clipboard"); + fakeConsole({ "/data/a/1": "file", "/data/a/2": "file", "/data/b/1": "file", "/data/b/2": "file" }); + const run = pasteItems( + ["1", "2"].map((n) => ({ path: `/data/a/${n}`, dir: false })), + "/data/b", + "copy", + { decide: askConflict }, + ); + for (let i = 0; i < 20 && !currentConflict(); i++) await Promise.resolve(); + expect(currentConflict()).not.toBeNull(); + skipPendingConflict(); + const r = await run; + expect(r.declined).toEqual(["/data/a/1", "/data/a/2"]); + expect(currentConflict()).toBeNull(); + expect(routes()).toEqual([]); + }); +}); diff --git a/client/src/console/files/paste.ts b/client/src/console/files/paste.ts index fca3ca4c..6400dd09 100644 --- a/client/src/console/files/paste.ts +++ b/client/src/console/files/paste.ts @@ -18,6 +18,14 @@ * refuses the rename (cross_mount) and a copy job with the move flag does it. * A folder never goes into itself (IntoItselfError); a cut item pasted into * its own folder stays as it is. + * + * "The item itself" is decided by identity (/api/fs/stat's dev + ino), not by + * the path's spelling: exFAT USB drives are case-insensitive, so a cut of + * /mnt/usb0/Games/x pasted into a typed /mnt/usb0/games finds x itself at the + * destination. Offering Replace there would delete the item (delete-first), so + * an identical entry is never replaceable, and for a cut it is "already here". + * The destination folder is compared with each item's folder the same way. A + * helper without ids falls back to the path compare. */ import { ApiError, call } from "../api"; import { @@ -63,6 +71,10 @@ export interface PasteItem { export interface PasteResult extends BatchResult { /** Items now in the folder (moved, or their job ended done). */ done: string[]; + /** Items whose job Stop left running (not known to be done: a cut keeps them in the clipboard). */ + left: string[]; + /** Items not pasted because Stop came while the delete for their Replace ran (that delete runs on). */ + halted: string[]; /** Conflicts the user skipped. */ declined: string[]; /** Cut items already in this folder (nothing to do). */ @@ -79,8 +91,16 @@ export interface PasteDeps { /** Keep both's tries before it gives up. */ const KEEP_BOTH_TRIES = 999; -interface Stat { +export interface Stat { kind: string; + /** Device and inode (decimal strings), from a helper that sends them. */ + dev?: string; + ino?: string; +} + +/** The same entry on disk (both stats carry ids and they match). */ +export function sameEntry(a: Stat | null | undefined, b: Stat | null | undefined): boolean { + return !!a && !!b && a.dev != null && a.ino != null && a.dev === b.dev && a.ino === b.ino; } /** The entry at `path`, or null when nothing is there (404). */ @@ -138,22 +158,55 @@ export async function pasteItems( const here: string[] = []; /** The item whose Replace delete was left running by Stop: it was not pasted. */ const halted: string[] = []; + /** Items whose job was still running when Stop let go of it. */ + const leftRunning: string[] = []; const kinds = new Map(items.map((i) => [i.path, i.dir])); const paths = items.map((i) => i.path); let always: ConflictChoice | null = null; + let current = ""; + /** Stats of folders (the destination, the items' folders), read once each. */ + const folderStats = new Map>(); + const folderStat = (p: string) => { + let s = folderStats.get(p); + if (!s) { + s = statOrNull(p).catch(() => null); + folderStats.set(p, s); + } + return s; + }; + const wait = opts?.waitJob; + const eachOpts: BatchOptions | undefined = opts && { + ...opts, + waitJob: wait + ? async (id) => { + const end = await wait(id); + if (end === "left") leftRunning.push(current); + return end; + } + : undefined, + }; - const r = await each(paths, opts, async (item, res) => { + const r = await each(paths, eachOpts, async (item, res) => { + current = item; if (isInside(dir, item)) throw new IntoItselfError(item); - if (mode === "cut" && parentPath(item) === dir) { - here.push(item); - return; + if (mode === "cut") { + const from = parentPath(item); + if (from === dir || sameEntry(await folderStat(from), await folderStat(dir))) { + here.push(item); + return; + } } const isDir = kinds.get(item) ?? false; let dest = joinPath(dir, baseName(item)); let overwrite = false; const existing = await statOrNull(dest); if (existing) { - const canReplace = !isInside(item, dest); + const self = sameEntry(existing, await statOrNull(item)); + if (self && mode === "cut") { + here.push(item); + return; + } + const canReplace = !self && !isInside(item, dest); let choice = always; if (!choice) { const d = await deps.decide({ @@ -200,11 +253,15 @@ export async function pasteItems( return startCopy(item, dest, true, res, overwrite); }); - const notDone = new Set(declined.concat(here, halted, r.skipped, r.jobFailed, r.failed.map((f) => f.item))); + const notDone = new Set( + declined.concat(here, halted, leftRunning, r.skipped, r.jobFailed, r.failed.map((f) => f.item)), + ); return { ...r, skipped: halted.concat(r.skipped), done: paths.filter((p) => !notDone.has(p)), + left: leftRunning, + halted, declined, here, }; diff --git a/client/src/console/locales/ar.json b/client/src/console/locales/ar.json index 317aaacf..2d0fa188 100644 --- a/client/src/console/locales/ar.json +++ b/client/src/console/locales/ar.json @@ -54,6 +54,7 @@ "cw_files_conflict_body_self": "هذا هو العنصر نفسه. تخطَّه أو احتفظ بالاثنين (تحصل النسخة على اسم جديد).", "cw_files_conflict_keep": "الاحتفاظ بالاثنين", "cw_files_conflict_replace": "استبدال", + "cw_files_conflict_replace_all_body": "يُكتب فوق الملفات الموجودة هنا؛ وتُحذف المجلدات بكل محتواها أولًا. لا يمكن التراجع عن ذلك.", "cw_files_conflict_replace_all_title": "استبدال «{name}» وكل التعارضات التالية؟", "cw_files_conflict_replace_dir_body": "يُحذف الموجود هنا بكل محتواه أولًا. لا يمكن التراجع عن ذلك.", "cw_files_conflict_replace_file_body": "سيُكتب فوق الملف الموجود. لا يمكن التراجع عن ذلك.", @@ -135,6 +136,7 @@ "cw_files_order_asc": "تصاعدي", "cw_files_order_desc": "تنازلي", "cw_files_paste_done_skipped": "تم. تخطي {n}.", + "cw_files_paste_halted": "توقف. لم يُلصق «{name}»؛ ولا يزال العنصر الذي كان سيستبدله قيد الحذف.", "cw_files_paste_here_already": "موجود في هذا المجلد بالفعل.", "cw_files_path": "مسار المجلد", "cw_files_path_more": "إظهار المجلدات المخفية ({n})", diff --git a/client/src/console/locales/bn.json b/client/src/console/locales/bn.json index a419202a..79e7cbb7 100644 --- a/client/src/console/locales/bn.json +++ b/client/src/console/locales/bn.json @@ -54,6 +54,7 @@ "cw_files_conflict_body_self": "এটি আইটেমটি নিজেই। বাদ দিন, অথবা দুটোই রাখুন (কপিটি নতুন নাম পাবে)।", "cw_files_conflict_keep": "দুটোই রাখুন", "cw_files_conflict_replace": "প্রতিস্থাপন", + "cw_files_conflict_replace_all_body": "এখানে থাকা ফাইলগুলো ওভাররাইট হবে; ফোল্ডারগুলো ভেতরের সবকিছুসহ আগে মুছে ফেলা হবে। এটি ফেরানো যাবে না।", "cw_files_conflict_replace_all_title": "“{name}” এবং পরের সব দ্বন্দ্ব প্রতিস্থাপন করবেন?", "cw_files_conflict_replace_dir_body": "এখানে যা আছে, ভেতরের সবকিছুসহ, আগে মুছে ফেলা হবে। এটি ফেরানো যাবে না।", "cw_files_conflict_replace_file_body": "এখানে থাকা ফাইলটি ওভাররাইট হবে। এটি ফেরানো যাবে না।", @@ -135,6 +136,7 @@ "cw_files_order_asc": "ঊর্ধ্বক্রম", "cw_files_order_desc": "নিম্নক্রম", "cw_files_paste_done_skipped": "হয়ে গেছে। {n}টি বাদ দেওয়া হয়েছে।", + "cw_files_paste_halted": "থামানো হয়েছে। “{name}” পেস্ট হয়নি; যে আইটেমটি এটি প্রতিস্থাপন করত সেটি এখনও মোছা হচ্ছে।", "cw_files_paste_here_already": "আগে থেকেই এই ফোল্ডারে আছে।", "cw_files_path": "ফোল্ডারের পাথ", "cw_files_path_more": "লুকানো {n}টি ফোল্ডার দেখান", diff --git a/client/src/console/locales/de.json b/client/src/console/locales/de.json index dea7c76e..a05e1e5c 100644 --- a/client/src/console/locales/de.json +++ b/client/src/console/locales/de.json @@ -54,6 +54,7 @@ "cw_files_conflict_body_self": "Das ist das Element selbst. Überspringen oder beide behalten (die Kopie bekommt einen neuen Namen).", "cw_files_conflict_keep": "Beide behalten", "cw_files_conflict_replace": "Ersetzen", + "cw_files_conflict_replace_all_body": "Vorhandene Dateien werden überschrieben; Ordner werden samt Inhalt zuerst gelöscht. Das lässt sich nicht rückgängig machen.", "cw_files_conflict_replace_all_title": "„{name}“ und alle weiteren Konflikte ersetzen?", "cw_files_conflict_replace_dir_body": "Was schon hier ist, wird samt Inhalt zuerst gelöscht. Das lässt sich nicht rückgängig machen.", "cw_files_conflict_replace_file_body": "Die vorhandene Datei wird überschrieben. Das lässt sich nicht rückgängig machen.", @@ -135,6 +136,7 @@ "cw_files_order_asc": "aufsteigend", "cw_files_order_desc": "absteigend", "cw_files_paste_done_skipped": "Fertig. {n} übersprungen.", + "cw_files_paste_halted": "Gestoppt. „{name}“ wurde nicht eingefügt; das Element, das es ersetzen sollte, wird noch gelöscht.", "cw_files_paste_here_already": "Schon in diesem Ordner.", "cw_files_path": "Ordnerpfad", "cw_files_path_more": "{n} ausgeblendete Ordner anzeigen", diff --git a/client/src/console/locales/en.json b/client/src/console/locales/en.json index 3ccbd21b..9057cc9e 100644 --- a/client/src/console/locales/en.json +++ b/client/src/console/locales/en.json @@ -54,6 +54,7 @@ "cw_files_conflict_body_self": "This is the item itself. Skip it, or keep both (the copy gets a new name).", "cw_files_conflict_keep": "Keep both", "cw_files_conflict_replace": "Replace", + "cw_files_conflict_replace_all_body": "Files already here are overwritten; folders, and everything in them, are deleted first. This can't be undone.", "cw_files_conflict_replace_all_title": "Replace “{name}” and every later conflict?", "cw_files_conflict_replace_dir_body": "What is already here, and everything in it, is deleted first. This can't be undone.", "cw_files_conflict_replace_file_body": "The file already here is overwritten. This can't be undone.", @@ -135,6 +136,7 @@ "cw_files_order_asc": "ascending", "cw_files_order_desc": "descending", "cw_files_paste_done_skipped": "Done. {n} skipped.", + "cw_files_paste_halted": "Stopped. “{name}” was not pasted; the item it was replacing is still being deleted.", "cw_files_paste_here_already": "Already in this folder.", "cw_files_path": "Folder path", "cw_files_path_more": "Show {n} hidden folders", diff --git a/client/src/console/locales/es.json b/client/src/console/locales/es.json index 24e7575f..d5c774b1 100644 --- a/client/src/console/locales/es.json +++ b/client/src/console/locales/es.json @@ -54,6 +54,7 @@ "cw_files_conflict_body_self": "Es el propio elemento. Omítelo o conserva ambos (la copia recibe un nombre nuevo).", "cw_files_conflict_keep": "Conservar ambos", "cw_files_conflict_replace": "Reemplazar", + "cw_files_conflict_replace_all_body": "Los archivos que ya están aquí se sobrescriben; las carpetas, con todo su contenido, se eliminan primero. No se puede deshacer.", "cw_files_conflict_replace_all_title": "¿Reemplazar «{name}» y todos los conflictos siguientes?", "cw_files_conflict_replace_dir_body": "Lo que ya está aquí, con todo su contenido, se elimina primero. No se puede deshacer.", "cw_files_conflict_replace_file_body": "El archivo que ya está aquí se sobrescribe. No se puede deshacer.", @@ -135,6 +136,7 @@ "cw_files_order_asc": "ascendente", "cw_files_order_desc": "descendente", "cw_files_paste_done_skipped": "Listo. {n} omitidos.", + "cw_files_paste_halted": "Detenido. «{name}» no se pegó; el elemento que iba a reemplazar todavía se está eliminando.", "cw_files_paste_here_already": "Ya está en esta carpeta.", "cw_files_path": "Ruta de la carpeta", "cw_files_path_more": "Mostrar {n} carpetas ocultas", diff --git a/client/src/console/locales/fa.json b/client/src/console/locales/fa.json index 80c204ed..ccf41caf 100644 --- a/client/src/console/locales/fa.json +++ b/client/src/console/locales/fa.json @@ -54,6 +54,7 @@ "cw_files_conflict_body_self": "این خود همان مورد است. رد شوید یا هر دو را نگه دارید (کپی نام تازه می‌گیرد).", "cw_files_conflict_keep": "نگه داشتن هر دو", "cw_files_conflict_replace": "جایگزینی", + "cw_files_conflict_replace_all_body": "فایل‌های موجود بازنویسی می‌شوند؛ پوشه‌ها با همه محتوایشان اول حذف می‌شوند. این کار برگشت‌پذیر نیست.", "cw_files_conflict_replace_all_title": "«{name}» و همه تداخل‌های بعدی جایگزین شوند؟", "cw_files_conflict_replace_dir_body": "آنچه اینجا هست، با همه محتوایش، اول حذف می‌شود. این کار برگشت‌پذیر نیست.", "cw_files_conflict_replace_file_body": "فایل موجود بازنویسی می‌شود. این کار برگشت‌پذیر نیست.", @@ -135,6 +136,7 @@ "cw_files_order_asc": "صعودی", "cw_files_order_desc": "نزولی", "cw_files_paste_done_skipped": "انجام شد. {n} مورد رد شد.", + "cw_files_paste_halted": "متوقف شد. «{name}» چسبانده نشد؛ موردی که قرار بود جایگزینش شود هنوز در حال حذف است.", "cw_files_paste_here_already": "از قبل در همین پوشه است.", "cw_files_path": "مسیر پوشه", "cw_files_path_more": "نمایش {n} پوشهٔ پنهان", diff --git a/client/src/console/locales/fr.json b/client/src/console/locales/fr.json index bf8aeb28..da0f70bf 100644 --- a/client/src/console/locales/fr.json +++ b/client/src/console/locales/fr.json @@ -54,6 +54,7 @@ "cw_files_conflict_body_self": "C’est l’élément lui-même. Ignorez-le ou gardez les deux (la copie reçoit un nouveau nom).", "cw_files_conflict_keep": "Garder les deux", "cw_files_conflict_replace": "Remplacer", + "cw_files_conflict_replace_all_body": "Les fichiers déjà présents sont écrasés ; les dossiers, avec tout leur contenu, sont d’abord supprimés. Action irréversible.", "cw_files_conflict_replace_all_title": "Remplacer « {name} » et tous les conflits suivants ?", "cw_files_conflict_replace_dir_body": "Ce qui est déjà ici, avec tout son contenu, est d’abord supprimé. Action irréversible.", "cw_files_conflict_replace_file_body": "Le fichier déjà présent est écrasé. Action irréversible.", @@ -135,6 +136,7 @@ "cw_files_order_asc": "croissant", "cw_files_order_desc": "décroissant", "cw_files_paste_done_skipped": "Terminé. {n} ignorés.", + "cw_files_paste_halted": "Arrêté. « {name} » n’a pas été collé ; l’élément qu’il devait remplacer est encore en cours de suppression.", "cw_files_paste_here_already": "Déjà dans ce dossier.", "cw_files_path": "Chemin du dossier", "cw_files_path_more": "Afficher {n} dossiers masqués", diff --git a/client/src/console/locales/hi.json b/client/src/console/locales/hi.json index f5bafe40..e4535a59 100644 --- a/client/src/console/locales/hi.json +++ b/client/src/console/locales/hi.json @@ -54,6 +54,7 @@ "cw_files_conflict_body_self": "यह वही आइटम है। छोड़ें, या दोनों रखें (कॉपी को नया नाम मिलेगा)।", "cw_files_conflict_keep": "दोनों रखें", "cw_files_conflict_replace": "बदलें", + "cw_files_conflict_replace_all_body": "यहाँ मौजूद फ़ाइलें ओवरराइट होंगी; फ़ोल्डर, उनके अंदर की हर चीज़ समेत, पहले हटा दिए जाएँगे। इसे पूर्ववत नहीं किया जा सकता।", "cw_files_conflict_replace_all_title": "“{name}” और आगे के सभी टकराव बदलें?", "cw_files_conflict_replace_dir_body": "यहाँ जो है, उसके अंदर की हर चीज़ समेत, पहले हटा दिया जाएगा। इसे पूर्ववत नहीं किया जा सकता।", "cw_files_conflict_replace_file_body": "यहाँ मौजूद फ़ाइल ओवरराइट हो जाएगी। इसे पूर्ववत नहीं किया जा सकता।", @@ -135,6 +136,7 @@ "cw_files_order_asc": "बढ़ता", "cw_files_order_desc": "घटता", "cw_files_paste_done_skipped": "हो गया। {n} छोड़े गए।", + "cw_files_paste_halted": "रोका गया। “{name}” पेस्ट नहीं हुआ; जिस आइटम को यह बदलने वाला था, वह अभी हटाया जा रहा है।", "cw_files_paste_here_already": "पहले से इसी फ़ोल्डर में है।", "cw_files_path": "फ़ोल्डर पथ", "cw_files_path_more": "{n} छिपे फ़ोल्डर दिखाएँ", diff --git a/client/src/console/locales/hu.json b/client/src/console/locales/hu.json index 5b2a73d1..49e43148 100644 --- a/client/src/console/locales/hu.json +++ b/client/src/console/locales/hu.json @@ -54,6 +54,7 @@ "cw_files_conflict_body_self": "Ez maga az elem. Hagyd ki, vagy tartsd meg mindkettőt (a másolat új nevet kap).", "cw_files_conflict_keep": "Mindkettő megtartása", "cw_files_conflict_replace": "Csere", + "cw_files_conflict_replace_all_body": "Az itt lévő fájlok felülíródnak; a mappák a teljes tartalmukkal együtt előbb törlődnek. Ez nem vonható vissza.", "cw_files_conflict_replace_all_title": "Lecseréled: „{name}”, és minden további ütközést?", "cw_files_conflict_replace_dir_body": "Ami már itt van, a teljes tartalmával együtt előbb törlődik. Ez nem vonható vissza.", "cw_files_conflict_replace_file_body": "Az itt lévő fájl felülíródik. Ez nem vonható vissza.", @@ -135,6 +136,7 @@ "cw_files_order_asc": "növekvő", "cw_files_order_desc": "csökkenő", "cw_files_paste_done_skipped": "Kész. {n} kihagyva.", + "cw_files_paste_halted": "Leállítva. „{name}” nem lett beillesztve; a lecserélendő elem törlése még folyamatban van.", "cw_files_paste_here_already": "Már ebben a mappában van.", "cw_files_path": "Mappa elérési útja", "cw_files_path_more": "{n} rejtett mappa megjelenítése", diff --git a/client/src/console/locales/id.json b/client/src/console/locales/id.json index 947c0a3d..187ed9fe 100644 --- a/client/src/console/locales/id.json +++ b/client/src/console/locales/id.json @@ -54,6 +54,7 @@ "cw_files_conflict_body_self": "Ini item itu sendiri. Lewati, atau simpan keduanya (salinannya diberi nama baru).", "cw_files_conflict_keep": "Simpan keduanya", "cw_files_conflict_replace": "Ganti", + "cw_files_conflict_replace_all_body": "File yang sudah ada akan ditimpa; folder beserta seluruh isinya dihapus terlebih dahulu. Ini tidak bisa dibatalkan.", "cw_files_conflict_replace_all_title": "Ganti “{name}” dan semua konflik berikutnya?", "cw_files_conflict_replace_dir_body": "Yang sudah ada di sini, beserta seluruh isinya, dihapus terlebih dahulu. Ini tidak bisa dibatalkan.", "cw_files_conflict_replace_file_body": "File yang sudah ada akan ditimpa. Ini tidak bisa dibatalkan.", @@ -135,6 +136,7 @@ "cw_files_order_asc": "naik", "cw_files_order_desc": "turun", "cw_files_paste_done_skipped": "Selesai. {n} dilewati.", + "cw_files_paste_halted": "Dihentikan. “{name}” tidak ditempel; item yang akan diganti masih sedang dihapus.", "cw_files_paste_here_already": "Sudah ada di folder ini.", "cw_files_path": "Jalur folder", "cw_files_path_more": "Tampilkan {n} folder tersembunyi", diff --git a/client/src/console/locales/it.json b/client/src/console/locales/it.json index c136603e..03acc4ea 100644 --- a/client/src/console/locales/it.json +++ b/client/src/console/locales/it.json @@ -54,6 +54,7 @@ "cw_files_conflict_body_self": "È l’elemento stesso. Saltalo o mantieni entrambi (la copia riceve un nuovo nome).", "cw_files_conflict_keep": "Mantieni entrambi", "cw_files_conflict_replace": "Sostituisci", + "cw_files_conflict_replace_all_body": "I file già presenti vengono sovrascritti; le cartelle, con tutto il contenuto, vengono prima eliminate. Non si può annullare.", "cw_files_conflict_replace_all_title": "Sostituire «{name}» e tutti i conflitti successivi?", "cw_files_conflict_replace_dir_body": "Ciò che è già qui, con tutto il contenuto, viene prima eliminato. Non si può annullare.", "cw_files_conflict_replace_file_body": "Il file già presente viene sovrascritto. Non si può annullare.", @@ -135,6 +136,7 @@ "cw_files_order_asc": "crescente", "cw_files_order_desc": "decrescente", "cw_files_paste_done_skipped": "Fatto. {n} saltati.", + "cw_files_paste_halted": "Interrotto. «{name}» non è stato incollato; l’elemento da sostituire è ancora in fase di eliminazione.", "cw_files_paste_here_already": "Già in questa cartella.", "cw_files_path": "Percorso della cartella", "cw_files_path_more": "Mostra {n} cartelle nascoste", diff --git a/client/src/console/locales/ja.json b/client/src/console/locales/ja.json index 43409ded..158d60ab 100644 --- a/client/src/console/locales/ja.json +++ b/client/src/console/locales/ja.json @@ -54,6 +54,7 @@ "cw_files_conflict_body_self": "これは項目そのものです。スキップするか、両方を残してください(コピーに新しい名前を付けます)。", "cw_files_conflict_keep": "両方を残す", "cw_files_conflict_replace": "置き換える", + "cw_files_conflict_replace_all_body": "既存のファイルは上書きされ、フォルダーは中身ごと先に削除されます。元に戻せません。", "cw_files_conflict_replace_all_title": "「{name}」と以降のすべての競合を置き換えますか?", "cw_files_conflict_replace_dir_body": "既存のものは中身ごと先に削除されます。元に戻せません。", "cw_files_conflict_replace_file_body": "既存のファイルは上書きされます。元に戻せません。", @@ -135,6 +136,7 @@ "cw_files_order_asc": "昇順", "cw_files_order_desc": "降順", "cw_files_paste_done_skipped": "完了。{n} 件をスキップしました。", + "cw_files_paste_halted": "停止しました。「{name}」は貼り付けられていません。置き換え対象の項目はまだ削除中です。", "cw_files_paste_here_already": "すでにこのフォルダーにあります。", "cw_files_path": "フォルダーのパス", "cw_files_path_more": "隠れているフォルダー {n} 個を表示", diff --git a/client/src/console/locales/ko.json b/client/src/console/locales/ko.json index 0d82e069..aef4542d 100644 --- a/client/src/console/locales/ko.json +++ b/client/src/console/locales/ko.json @@ -54,6 +54,7 @@ "cw_files_conflict_body_self": "항목 자기 자신입니다. 건너뛰거나 둘 다 유지하세요(복사본에 새 이름을 붙임).", "cw_files_conflict_keep": "둘 다 유지", "cw_files_conflict_replace": "바꾸기", + "cw_files_conflict_replace_all_body": "기존 파일은 덮어쓰고, 폴더는 그 안의 모든 것과 함께 먼저 삭제됩니다. 되돌릴 수 없습니다.", "cw_files_conflict_replace_all_title": "“{name}” 및 이후의 모든 충돌을 바꿀까요?", "cw_files_conflict_replace_dir_body": "기존 항목과 그 안의 모든 것이 먼저 삭제됩니다. 되돌릴 수 없습니다.", "cw_files_conflict_replace_file_body": "기존 파일을 덮어씁니다. 되돌릴 수 없습니다.", @@ -135,6 +136,7 @@ "cw_files_order_asc": "오름차순", "cw_files_order_desc": "내림차순", "cw_files_paste_done_skipped": "완료. {n}개 건너뜀.", + "cw_files_paste_halted": "중지했습니다. “{name}”은(는) 붙여넣지 않았습니다. 바꿀 항목은 아직 삭제 중입니다.", "cw_files_paste_here_already": "이미 이 폴더에 있습니다.", "cw_files_path": "폴더 경로", "cw_files_path_more": "숨겨진 폴더 {n}개 표시", diff --git a/client/src/console/locales/pl.json b/client/src/console/locales/pl.json index 862b788e..09d80e2b 100644 --- a/client/src/console/locales/pl.json +++ b/client/src/console/locales/pl.json @@ -54,6 +54,7 @@ "cw_files_conflict_body_self": "To jest ten sam element. Pomiń go albo zachowaj oba (kopia dostanie nową nazwę).", "cw_files_conflict_keep": "Zachowaj oba", "cw_files_conflict_replace": "Zastąp", + "cw_files_conflict_replace_all_body": "Pliki, które już tu są, zostaną nadpisane; foldery wraz z całą zawartością zostaną najpierw usunięte. Tego nie da się cofnąć.", "cw_files_conflict_replace_all_title": "Zastąpić „{name}” i wszystkie kolejne konflikty?", "cw_files_conflict_replace_dir_body": "To, co już tu jest, wraz z całą zawartością, zostanie najpierw usunięte. Tego nie da się cofnąć.", "cw_files_conflict_replace_file_body": "Plik, który już tu jest, zostanie nadpisany. Tego nie da się cofnąć.", @@ -135,6 +136,7 @@ "cw_files_order_asc": "rosnąco", "cw_files_order_desc": "malejąco", "cw_files_paste_done_skipped": "Gotowe. Pominięto: {n}.", + "cw_files_paste_halted": "Zatrzymano. „{name}” nie został wklejony; element, który miał zastąpić, jest jeszcze usuwany.", "cw_files_paste_here_already": "Już jest w tym folderze.", "cw_files_path": "Ścieżka folderu", "cw_files_path_more": "Pokaż ukryte foldery ({n})", diff --git a/client/src/console/locales/pt-BR.json b/client/src/console/locales/pt-BR.json index 58b40e75..022e45a9 100644 --- a/client/src/console/locales/pt-BR.json +++ b/client/src/console/locales/pt-BR.json @@ -54,6 +54,7 @@ "cw_files_conflict_body_self": "Este é o próprio item. Pule ou mantenha os dois (a cópia ganha um novo nome).", "cw_files_conflict_keep": "Manter os dois", "cw_files_conflict_replace": "Substituir", + "cw_files_conflict_replace_all_body": "Os arquivos que já estão aqui são sobrescritos; as pastas, com todo o conteúdo, são excluídas primeiro. Não dá para desfazer.", "cw_files_conflict_replace_all_title": "Substituir “{name}” e todos os conflitos seguintes?", "cw_files_conflict_replace_dir_body": "O que já está aqui, com todo o conteúdo, é excluído primeiro. Não dá para desfazer.", "cw_files_conflict_replace_file_body": "O arquivo que já está aqui é sobrescrito. Não dá para desfazer.", @@ -135,6 +136,7 @@ "cw_files_order_asc": "crescente", "cw_files_order_desc": "decrescente", "cw_files_paste_done_skipped": "Pronto. {n} pulados.", + "cw_files_paste_halted": "Parado. “{name}” não foi colado; o item que ele substituiria ainda está sendo excluído.", "cw_files_paste_here_already": "Já está nesta pasta.", "cw_files_path": "Caminho da pasta", "cw_files_path_more": "Mostrar {n} pastas ocultas", diff --git a/client/src/console/locales/ru.json b/client/src/console/locales/ru.json index 33b292e8..25eb95c7 100644 --- a/client/src/console/locales/ru.json +++ b/client/src/console/locales/ru.json @@ -54,6 +54,7 @@ "cw_files_conflict_body_self": "Это тот же самый элемент. Пропустите его или оставьте оба (копия получит новое имя).", "cw_files_conflict_keep": "Оставить оба", "cw_files_conflict_replace": "Заменить", + "cw_files_conflict_replace_all_body": "Файлы, которые уже здесь, будут перезаписаны; папки вместе со всем содержимым сначала удаляются. Это нельзя отменить.", "cw_files_conflict_replace_all_title": "Заменить «{name}» и все следующие конфликты?", "cw_files_conflict_replace_dir_body": "То, что уже здесь, вместе со всем содержимым, сначала удаляется. Это нельзя отменить.", "cw_files_conflict_replace_file_body": "Файл, который уже здесь, будет перезаписан. Это нельзя отменить.", @@ -135,6 +136,7 @@ "cw_files_order_asc": "по возрастанию", "cw_files_order_desc": "по убыванию", "cw_files_paste_done_skipped": "Готово. Пропущено: {n}.", + "cw_files_paste_halted": "Остановлено. «{name}» не вставлен; заменяемый элемент ещё удаляется.", "cw_files_paste_here_already": "Уже в этой папке.", "cw_files_path": "Путь к папке", "cw_files_path_more": "Показать скрытые папки: {n}", diff --git a/client/src/console/locales/th.json b/client/src/console/locales/th.json index 27b5d1ed..f1736377 100644 --- a/client/src/console/locales/th.json +++ b/client/src/console/locales/th.json @@ -54,6 +54,7 @@ "cw_files_conflict_body_self": "นี่คือรายการเดียวกัน ข้ามไป หรือเก็บทั้งคู่ (สำเนาจะได้ชื่อใหม่)", "cw_files_conflict_keep": "เก็บทั้งคู่", "cw_files_conflict_replace": "แทนที่", + "cw_files_conflict_replace_all_body": "ไฟล์ที่มีอยู่จะถูกเขียนทับ ส่วนโฟลเดอร์พร้อมทุกอย่างข้างในจะถูกลบก่อน ย้อนกลับไม่ได้", "cw_files_conflict_replace_all_title": "แทนที่ “{name}” และข้อขัดแย้งถัดไปทั้งหมดไหม", "cw_files_conflict_replace_dir_body": "รายการที่มีอยู่พร้อมทุกอย่างข้างในจะถูกลบก่อน ย้อนกลับไม่ได้", "cw_files_conflict_replace_file_body": "ไฟล์ที่มีอยู่จะถูกเขียนทับ ย้อนกลับไม่ได้", @@ -135,6 +136,7 @@ "cw_files_order_asc": "น้อยไปมาก", "cw_files_order_desc": "มากไปน้อย", "cw_files_paste_done_skipped": "เสร็จแล้ว ข้าม {n} รายการ", + "cw_files_paste_halted": "หยุดแล้ว ไม่ได้วาง “{name}” รายการที่จะถูกแทนที่ยังลบอยู่", "cw_files_paste_here_already": "อยู่ในโฟลเดอร์นี้แล้ว", "cw_files_path": "พาธของโฟลเดอร์", "cw_files_path_more": "แสดงโฟลเดอร์ที่ซ่อนอยู่ {n} โฟลเดอร์", diff --git a/client/src/console/locales/tr.json b/client/src/console/locales/tr.json index 34798de6..43a031b6 100644 --- a/client/src/console/locales/tr.json +++ b/client/src/console/locales/tr.json @@ -54,6 +54,7 @@ "cw_files_conflict_body_self": "Bu, öğenin kendisi. Atla ya da ikisini de tut (kopyaya yeni ad verilir).", "cw_files_conflict_keep": "İkisini de tut", "cw_files_conflict_replace": "Değiştir", + "cw_files_conflict_replace_all_body": "Buradaki dosyaların üzerine yazılır; klasörler içindeki her şeyle birlikte önce silinir. Bu geri alınamaz.", "cw_files_conflict_replace_all_title": "“{name}” ve sonraki tüm çakışmalar değiştirilsin mi?", "cw_files_conflict_replace_dir_body": "Burada olan, içindeki her şeyle birlikte önce silinir. Bu geri alınamaz.", "cw_files_conflict_replace_file_body": "Buradaki dosyanın üzerine yazılır. Bu geri alınamaz.", @@ -135,6 +136,7 @@ "cw_files_order_asc": "artan", "cw_files_order_desc": "azalan", "cw_files_paste_done_skipped": "Bitti. {n} atlandı.", + "cw_files_paste_halted": "Durduruldu. “{name}” yapıştırılmadı; yerine geçeceği öğe hâlâ siliniyor.", "cw_files_paste_here_already": "Zaten bu klasörde.", "cw_files_path": "Klasör yolu", "cw_files_path_more": "{n} gizli klasörü göster", diff --git a/client/src/console/locales/vi.json b/client/src/console/locales/vi.json index 9c6f9366..7ed77ad4 100644 --- a/client/src/console/locales/vi.json +++ b/client/src/console/locales/vi.json @@ -54,6 +54,7 @@ "cw_files_conflict_body_self": "Đây chính là mục đó. Bỏ qua hoặc giữ cả hai (bản sao sẽ có tên mới).", "cw_files_conflict_keep": "Giữ cả hai", "cw_files_conflict_replace": "Thay thế", + "cw_files_conflict_replace_all_body": "Các tệp đã có ở đây sẽ bị ghi đè; thư mục cùng toàn bộ nội dung sẽ bị xóa trước. Không thể hoàn tác.", "cw_files_conflict_replace_all_title": "Thay thế “{name}” và mọi xung đột sau đó?", "cw_files_conflict_replace_dir_body": "Mục đã có ở đây cùng toàn bộ nội dung sẽ bị xóa trước. Không thể hoàn tác.", "cw_files_conflict_replace_file_body": "Tệp đã có ở đây sẽ bị ghi đè. Không thể hoàn tác.", @@ -135,6 +136,7 @@ "cw_files_order_asc": "tăng dần", "cw_files_order_desc": "giảm dần", "cw_files_paste_done_skipped": "Xong. Đã bỏ qua {n}.", + "cw_files_paste_halted": "Đã dừng. “{name}” chưa được dán; mục cần thay thế vẫn đang bị xóa.", "cw_files_paste_here_already": "Đã ở trong thư mục này.", "cw_files_path": "Đường dẫn thư mục", "cw_files_path_more": "Hiện {n} thư mục bị ẩn", diff --git a/client/src/console/locales/zh-CN.json b/client/src/console/locales/zh-CN.json index e87f01e1..39e519e9 100644 --- a/client/src/console/locales/zh-CN.json +++ b/client/src/console/locales/zh-CN.json @@ -54,6 +54,7 @@ "cw_files_conflict_body_self": "这就是该项目本身。可以跳过或保留两者(副本会改名)。", "cw_files_conflict_keep": "保留两者", "cw_files_conflict_replace": "替换", + "cw_files_conflict_replace_all_body": "已有的文件会被覆盖;文件夹及其全部内容会先被删除。无法撤销。", "cw_files_conflict_replace_all_title": "替换“{name}”以及之后的所有冲突?", "cw_files_conflict_replace_dir_body": "已有的项目及其全部内容会先被删除,无法撤销。", "cw_files_conflict_replace_file_body": "已有的文件会被覆盖,无法撤销。", @@ -135,6 +136,7 @@ "cw_files_order_asc": "升序", "cw_files_order_desc": "降序", "cw_files_paste_done_skipped": "完成。已跳过 {n} 个。", + "cw_files_paste_halted": "已停止。“{name}”未粘贴;要被替换的项目仍在删除中。", "cw_files_paste_here_already": "已在此文件夹中。", "cw_files_path": "文件夹路径", "cw_files_path_more": "显示 {n} 个隐藏的文件夹", diff --git a/client/src/console/locales/zh-TW.json b/client/src/console/locales/zh-TW.json index 0dad200c..da695a40 100644 --- a/client/src/console/locales/zh-TW.json +++ b/client/src/console/locales/zh-TW.json @@ -54,6 +54,7 @@ "cw_files_conflict_body_self": "這就是該項目本身。可以略過或保留兩者(副本會改名)。", "cw_files_conflict_keep": "保留兩者", "cw_files_conflict_replace": "取代", + "cw_files_conflict_replace_all_body": "現有的檔案會被覆寫;資料夾及其全部內容會先被刪除。無法復原。", "cw_files_conflict_replace_all_title": "取代「{name}」以及之後的所有衝突?", "cw_files_conflict_replace_dir_body": "現有的項目及其全部內容會先被刪除,無法復原。", "cw_files_conflict_replace_file_body": "現有的檔案會被覆寫,無法復原。", @@ -135,6 +136,7 @@ "cw_files_order_asc": "遞增", "cw_files_order_desc": "遞減", "cw_files_paste_done_skipped": "完成。已略過 {n} 個。", + "cw_files_paste_halted": "已停止。「{name}」未貼上;要被取代的項目仍在刪除中。", "cw_files_paste_here_already": "已在此資料夾中。", "cw_files_path": "資料夾路徑", "cw_files_path_more": "顯示 {n} 個隱藏的資料夾", diff --git a/client/src/i18n/locales/ar.ts b/client/src/i18n/locales/ar.ts index 69d7d2cb..62f2d3e4 100644 --- a/client/src/i18n/locales/ar.ts +++ b/client/src/i18n/locales/ar.ts @@ -4346,6 +4346,8 @@ c2c_test_deep_bad: "لم يصل الإرسال التجريبي بحجم 4 KiB: "cw_files_paste_here_already": "موجود في هذا المجلد بالفعل.", "cw_files_paste_done_skipped": "تم. تخطي {n}.", "cw_files_replace_failed": "تعذّر حذف العنصر المراد استبداله، لذلك لم يُلصق.", +"cw_files_conflict_replace_all_body": "يُكتب فوق الملفات الموجودة هنا؛ وتُحذف المجلدات بكل محتواها أولًا. لا يمكن التراجع عن ذلك.", +"cw_files_paste_halted": "توقف. لم يُلصق «{name}»؛ ولا يزال العنصر الذي كان سيستبدله قيد الحذف.", }; export default ar; diff --git a/client/src/i18n/locales/bn.ts b/client/src/i18n/locales/bn.ts index f9ae509c..aa73d76a 100644 --- a/client/src/i18n/locales/bn.ts +++ b/client/src/i18n/locales/bn.ts @@ -4346,6 +4346,8 @@ c2c_test_deep_bad: "4 KiB-এর পরীক্ষামূলক পাঠা "cw_files_paste_here_already": "আগে থেকেই এই ফোল্ডারে আছে।", "cw_files_paste_done_skipped": "হয়ে গেছে। {n}টি বাদ দেওয়া হয়েছে।", "cw_files_replace_failed": "প্রতিস্থাপনের আইটেমটি মোছা যায়নি, তাই পেস্ট করা হয়নি।", +"cw_files_conflict_replace_all_body": "এখানে থাকা ফাইলগুলো ওভাররাইট হবে; ফোল্ডারগুলো ভেতরের সবকিছুসহ আগে মুছে ফেলা হবে। এটি ফেরানো যাবে না।", +"cw_files_paste_halted": "থামানো হয়েছে। “{name}” পেস্ট হয়নি; যে আইটেমটি এটি প্রতিস্থাপন করত সেটি এখনও মোছা হচ্ছে।", }; export default bn; diff --git a/client/src/i18n/locales/de.ts b/client/src/i18n/locales/de.ts index b678b244..2381cb1e 100644 --- a/client/src/i18n/locales/de.ts +++ b/client/src/i18n/locales/de.ts @@ -4354,6 +4354,8 @@ c2c_test_deep_bad: "Die 4-KiB-Testsendung ist nicht angekommen: {detail}", "cw_files_paste_here_already": "Schon in diesem Ordner.", "cw_files_paste_done_skipped": "Fertig. {n} übersprungen.", "cw_files_replace_failed": "Das zu ersetzende Element ließ sich nicht löschen, daher wurde nichts eingefügt.", +"cw_files_conflict_replace_all_body": "Vorhandene Dateien werden überschrieben; Ordner werden samt Inhalt zuerst gelöscht. Das lässt sich nicht rückgängig machen.", +"cw_files_paste_halted": "Gestoppt. „{name}“ wurde nicht eingefügt; das Element, das es ersetzen sollte, wird noch gelöscht.", }; export default de; diff --git a/client/src/i18n/locales/en.ts b/client/src/i18n/locales/en.ts index 6e189125..47c1739e 100644 --- a/client/src/i18n/locales/en.ts +++ b/client/src/i18n/locales/en.ts @@ -4669,6 +4669,8 @@ c2c_test_deep_bad: "The 4 KiB test send did not arrive: {detail}", "cw_files_paste_here_already": "Already in this folder.", "cw_files_paste_done_skipped": "Done. {n} skipped.", "cw_files_replace_failed": "Couldn't delete the item being replaced, so it was not pasted.", +"cw_files_conflict_replace_all_body": "Files already here are overwritten; folders, and everything in them, are deleted first. This can't be undone.", +"cw_files_paste_halted": "Stopped. “{name}” was not pasted; the item it was replacing is still being deleted.", }; export default en; diff --git a/client/src/i18n/locales/es.ts b/client/src/i18n/locales/es.ts index ce94ed82..6ce75474 100644 --- a/client/src/i18n/locales/es.ts +++ b/client/src/i18n/locales/es.ts @@ -4296,6 +4296,8 @@ c2c_test_deep_bad: "El envío de prueba de 4 KiB no llegó: {detail}", "cw_files_paste_here_already": "Ya está en esta carpeta.", "cw_files_paste_done_skipped": "Listo. {n} omitidos.", "cw_files_replace_failed": "No se pudo eliminar el elemento a reemplazar, así que no se pegó.", +"cw_files_conflict_replace_all_body": "Los archivos que ya están aquí se sobrescriben; las carpetas, con todo su contenido, se eliminan primero. No se puede deshacer.", +"cw_files_paste_halted": "Detenido. «{name}» no se pegó; el elemento que iba a reemplazar todavía se está eliminando.", }; export default es; diff --git a/client/src/i18n/locales/fa.ts b/client/src/i18n/locales/fa.ts index df5a1e58..d6141ef7 100644 --- a/client/src/i18n/locales/fa.ts +++ b/client/src/i18n/locales/fa.ts @@ -4647,6 +4647,8 @@ c2c_test_deep_bad: "ارسال آزمایشی ۴ KiB نرسید: {detail}", "cw_files_paste_here_already": "از قبل در همین پوشه است.", "cw_files_paste_done_skipped": "انجام شد. {n} مورد رد شد.", "cw_files_replace_failed": "حذف مورد جایگزین‌شونده ممکن نشد، پس چسبانده نشد.", +"cw_files_conflict_replace_all_body": "فایل‌های موجود بازنویسی می‌شوند؛ پوشه‌ها با همه محتوایشان اول حذف می‌شوند. این کار برگشت‌پذیر نیست.", +"cw_files_paste_halted": "متوقف شد. «{name}» چسبانده نشد؛ موردی که قرار بود جایگزینش شود هنوز در حال حذف است.", }; export default fa; diff --git a/client/src/i18n/locales/fr.ts b/client/src/i18n/locales/fr.ts index 1b769043..b45e9f62 100644 --- a/client/src/i18n/locales/fr.ts +++ b/client/src/i18n/locales/fr.ts @@ -4364,6 +4364,8 @@ c2c_test_deep_bad: "L’envoi test de 4 Kio n’est pas arrivé : {detail}", "cw_files_paste_here_already": "Déjà dans ce dossier.", "cw_files_paste_done_skipped": "Terminé. {n} ignorés.", "cw_files_replace_failed": "Impossible de supprimer l’élément à remplacer : rien n’a été collé.", +"cw_files_conflict_replace_all_body": "Les fichiers déjà présents sont écrasés ; les dossiers, avec tout leur contenu, sont d’abord supprimés. Action irréversible.", +"cw_files_paste_halted": "Arrêté. « {name} » n’a pas été collé ; l’élément qu’il devait remplacer est encore en cours de suppression.", }; export default fr; diff --git a/client/src/i18n/locales/hi.ts b/client/src/i18n/locales/hi.ts index 7e81e402..d9c3e998 100644 --- a/client/src/i18n/locales/hi.ts +++ b/client/src/i18n/locales/hi.ts @@ -4362,6 +4362,8 @@ c2c_test_deep_bad: "4 KiB का परीक्षण नहीं पहुँ "cw_files_paste_here_already": "पहले से इसी फ़ोल्डर में है।", "cw_files_paste_done_skipped": "हो गया। {n} छोड़े गए।", "cw_files_replace_failed": "बदला जाने वाला आइटम हटाया नहीं जा सका, इसलिए पेस्ट नहीं किया गया।", +"cw_files_conflict_replace_all_body": "यहाँ मौजूद फ़ाइलें ओवरराइट होंगी; फ़ोल्डर, उनके अंदर की हर चीज़ समेत, पहले हटा दिए जाएँगे। इसे पूर्ववत नहीं किया जा सकता।", +"cw_files_paste_halted": "रोका गया। “{name}” पेस्ट नहीं हुआ; जिस आइटम को यह बदलने वाला था, वह अभी हटाया जा रहा है।", }; export default hi; diff --git a/client/src/i18n/locales/hu.ts b/client/src/i18n/locales/hu.ts index d52641ca..84ada99f 100644 --- a/client/src/i18n/locales/hu.ts +++ b/client/src/i18n/locales/hu.ts @@ -4661,6 +4661,8 @@ c2c_test_deep_bad: "A 4 KiB-os tesztküldés nem érkezett meg: {detail}", "cw_files_paste_here_already": "Már ebben a mappában van.", "cw_files_paste_done_skipped": "Kész. {n} kihagyva.", "cw_files_replace_failed": "A lecserélendő elemet nem sikerült törölni, ezért nem lett beillesztve.", +"cw_files_conflict_replace_all_body": "Az itt lévő fájlok felülíródnak; a mappák a teljes tartalmukkal együtt előbb törlődnek. Ez nem vonható vissza.", +"cw_files_paste_halted": "Leállítva. „{name}” nem lett beillesztve; a lecserélendő elem törlése még folyamatban van.", }; export default hu; diff --git a/client/src/i18n/locales/id.ts b/client/src/i18n/locales/id.ts index ec5b0182..18ed82ba 100644 --- a/client/src/i18n/locales/id.ts +++ b/client/src/i18n/locales/id.ts @@ -4355,6 +4355,8 @@ c2c_test_deep_bad: "Kiriman uji 4 KiB tidak tiba: {detail}", "cw_files_paste_here_already": "Sudah ada di folder ini.", "cw_files_paste_done_skipped": "Selesai. {n} dilewati.", "cw_files_replace_failed": "Item yang akan diganti tidak bisa dihapus, jadi tidak ditempel.", +"cw_files_conflict_replace_all_body": "File yang sudah ada akan ditimpa; folder beserta seluruh isinya dihapus terlebih dahulu. Ini tidak bisa dibatalkan.", +"cw_files_paste_halted": "Dihentikan. “{name}” tidak ditempel; item yang akan diganti masih sedang dihapus.", }; export default id; diff --git a/client/src/i18n/locales/it.ts b/client/src/i18n/locales/it.ts index 446c0869..96cee543 100644 --- a/client/src/i18n/locales/it.ts +++ b/client/src/i18n/locales/it.ts @@ -4296,6 +4296,8 @@ c2c_test_deep_bad: "L’invio di prova di 4 KiB non è arrivato: {detail}", "cw_files_paste_here_already": "Già in questa cartella.", "cw_files_paste_done_skipped": "Fatto. {n} saltati.", "cw_files_replace_failed": "Impossibile eliminare l’elemento da sostituire, quindi non è stato incollato.", +"cw_files_conflict_replace_all_body": "I file già presenti vengono sovrascritti; le cartelle, con tutto il contenuto, vengono prima eliminate. Non si può annullare.", +"cw_files_paste_halted": "Interrotto. «{name}» non è stato incollato; l’elemento da sostituire è ancora in fase di eliminazione.", }; export default it; diff --git a/client/src/i18n/locales/ja.ts b/client/src/i18n/locales/ja.ts index 6ebc3afd..826a88a2 100644 --- a/client/src/i18n/locales/ja.ts +++ b/client/src/i18n/locales/ja.ts @@ -4367,6 +4367,8 @@ c2c_test_deep_bad: "4 KiB のテスト送信が届きませんでした: {detail "cw_files_paste_here_already": "すでにこのフォルダーにあります。", "cw_files_paste_done_skipped": "完了。{n} 件をスキップしました。", "cw_files_replace_failed": "置き換える項目を削除できなかったため、貼り付けませんでした。", +"cw_files_conflict_replace_all_body": "既存のファイルは上書きされ、フォルダーは中身ごと先に削除されます。元に戻せません。", +"cw_files_paste_halted": "停止しました。「{name}」は貼り付けられていません。置き換え対象の項目はまだ削除中です。", }; export default ja; diff --git a/client/src/i18n/locales/ko.ts b/client/src/i18n/locales/ko.ts index 50b4677e..8c4b6a5f 100644 --- a/client/src/i18n/locales/ko.ts +++ b/client/src/i18n/locales/ko.ts @@ -4365,6 +4365,8 @@ c2c_test_deep_bad: "4 KiB 테스트 전송이 도착하지 않았습니다: {det "cw_files_paste_here_already": "이미 이 폴더에 있습니다.", "cw_files_paste_done_skipped": "완료. {n}개 건너뜀.", "cw_files_replace_failed": "바꿀 항목을 삭제하지 못해 붙여넣지 않았습니다.", +"cw_files_conflict_replace_all_body": "기존 파일은 덮어쓰고, 폴더는 그 안의 모든 것과 함께 먼저 삭제됩니다. 되돌릴 수 없습니다.", +"cw_files_paste_halted": "중지했습니다. “{name}”은(는) 붙여넣지 않았습니다. 바꿀 항목은 아직 삭제 중입니다.", }; export default ko; diff --git a/client/src/i18n/locales/pl.ts b/client/src/i18n/locales/pl.ts index f30829c0..645fd36b 100644 --- a/client/src/i18n/locales/pl.ts +++ b/client/src/i18n/locales/pl.ts @@ -4601,6 +4601,8 @@ c2c_test_deep_bad: "Testowa wysyłka 4 KiB nie dotarła: {detail}", "cw_files_paste_here_already": "Już jest w tym folderze.", "cw_files_paste_done_skipped": "Gotowe. Pominięto: {n}.", "cw_files_replace_failed": "Nie udało się usunąć zastępowanego elementu, więc nic nie wklejono.", +"cw_files_conflict_replace_all_body": "Pliki, które już tu są, zostaną nadpisane; foldery wraz z całą zawartością zostaną najpierw usunięte. Tego nie da się cofnąć.", +"cw_files_paste_halted": "Zatrzymano. „{name}” nie został wklejony; element, który miał zastąpić, jest jeszcze usuwany.", }; export default pl; diff --git a/client/src/i18n/locales/pt-BR.ts b/client/src/i18n/locales/pt-BR.ts index 4c6d21d8..224dc678 100644 --- a/client/src/i18n/locales/pt-BR.ts +++ b/client/src/i18n/locales/pt-BR.ts @@ -4363,6 +4363,8 @@ c2c_test_deep_bad: "O envio de teste de 4 KiB não chegou: {detail}", "cw_files_paste_here_already": "Já está nesta pasta.", "cw_files_paste_done_skipped": "Pronto. {n} pulados.", "cw_files_replace_failed": "Não foi possível excluir o item a ser substituído, então ele não foi colado.", +"cw_files_conflict_replace_all_body": "Os arquivos que já estão aqui são sobrescritos; as pastas, com todo o conteúdo, são excluídas primeiro. Não dá para desfazer.", +"cw_files_paste_halted": "Parado. “{name}” não foi colado; o item que ele substituiria ainda está sendo excluído.", }; export default pt_BR; diff --git a/client/src/i18n/locales/ru.ts b/client/src/i18n/locales/ru.ts index 06d2a0e2..d5bf9ede 100644 --- a/client/src/i18n/locales/ru.ts +++ b/client/src/i18n/locales/ru.ts @@ -4296,6 +4296,8 @@ c2c_test_deep_bad: "Тестовая отправка 4 КиБ не дошла: "cw_files_paste_here_already": "Уже в этой папке.", "cw_files_paste_done_skipped": "Готово. Пропущено: {n}.", "cw_files_replace_failed": "Не удалось удалить заменяемый элемент, поэтому вставка не выполнена.", +"cw_files_conflict_replace_all_body": "Файлы, которые уже здесь, будут перезаписаны; папки вместе со всем содержимым сначала удаляются. Это нельзя отменить.", +"cw_files_paste_halted": "Остановлено. «{name}» не вставлен; заменяемый элемент ещё удаляется.", }; export default ru; diff --git a/client/src/i18n/locales/th.ts b/client/src/i18n/locales/th.ts index 0910f479..f18e387b 100644 --- a/client/src/i18n/locales/th.ts +++ b/client/src/i18n/locales/th.ts @@ -4355,6 +4355,8 @@ c2c_test_deep_bad: "การส่งทดสอบ 4 KiB ไปไม่ถ "cw_files_paste_here_already": "อยู่ในโฟลเดอร์นี้แล้ว", "cw_files_paste_done_skipped": "เสร็จแล้ว ข้าม {n} รายการ", "cw_files_replace_failed": "ลบรายการที่จะแทนที่ไม่ได้ จึงไม่ได้วาง", +"cw_files_conflict_replace_all_body": "ไฟล์ที่มีอยู่จะถูกเขียนทับ ส่วนโฟลเดอร์พร้อมทุกอย่างข้างในจะถูกลบก่อน ย้อนกลับไม่ได้", +"cw_files_paste_halted": "หยุดแล้ว ไม่ได้วาง “{name}” รายการที่จะถูกแทนที่ยังลบอยู่", }; export default th; diff --git a/client/src/i18n/locales/tr.ts b/client/src/i18n/locales/tr.ts index 265f363a..96d0d3ee 100644 --- a/client/src/i18n/locales/tr.ts +++ b/client/src/i18n/locales/tr.ts @@ -4353,6 +4353,8 @@ c2c_test_deep_bad: "4 KiB'lik test gönderimi ulaşmadı: {detail}", "cw_files_paste_here_already": "Zaten bu klasörde.", "cw_files_paste_done_skipped": "Bitti. {n} atlandı.", "cw_files_replace_failed": "Değiştirilecek öğe silinemedi, bu yüzden yapıştırılmadı.", +"cw_files_conflict_replace_all_body": "Buradaki dosyaların üzerine yazılır; klasörler içindeki her şeyle birlikte önce silinir. Bu geri alınamaz.", +"cw_files_paste_halted": "Durduruldu. “{name}” yapıştırılmadı; yerine geçeceği öğe hâlâ siliniyor.", }; export default tr; diff --git a/client/src/i18n/locales/vi.ts b/client/src/i18n/locales/vi.ts index bb5a6991..d1a972f3 100644 --- a/client/src/i18n/locales/vi.ts +++ b/client/src/i18n/locales/vi.ts @@ -4348,6 +4348,8 @@ c2c_test_deep_bad: "Lượt gửi thử 4 KiB không tới: {detail}", "cw_files_paste_here_already": "Đã ở trong thư mục này.", "cw_files_paste_done_skipped": "Xong. Đã bỏ qua {n}.", "cw_files_replace_failed": "Không xóa được mục cần thay thế nên chưa dán.", +"cw_files_conflict_replace_all_body": "Các tệp đã có ở đây sẽ bị ghi đè; thư mục cùng toàn bộ nội dung sẽ bị xóa trước. Không thể hoàn tác.", +"cw_files_paste_halted": "Đã dừng. “{name}” chưa được dán; mục cần thay thế vẫn đang bị xóa.", }; export default vi; diff --git a/client/src/i18n/locales/zh-CN.ts b/client/src/i18n/locales/zh-CN.ts index 78b68e61..37acca57 100644 --- a/client/src/i18n/locales/zh-CN.ts +++ b/client/src/i18n/locales/zh-CN.ts @@ -4302,6 +4302,8 @@ c2c_test_deep_bad: "4 KiB 测试发送未到达:{detail}", "cw_files_paste_here_already": "已在此文件夹中。", "cw_files_paste_done_skipped": "完成。已跳过 {n} 个。", "cw_files_replace_failed": "无法删除要替换的项目,因此未粘贴。", +"cw_files_conflict_replace_all_body": "已有的文件会被覆盖;文件夹及其全部内容会先被删除。无法撤销。", +"cw_files_paste_halted": "已停止。“{name}”未粘贴;要被替换的项目仍在删除中。", }; export default zh_CN; diff --git a/client/src/i18n/locales/zh-TW.ts b/client/src/i18n/locales/zh-TW.ts index 26ce2a27..8d90873b 100644 --- a/client/src/i18n/locales/zh-TW.ts +++ b/client/src/i18n/locales/zh-TW.ts @@ -4296,6 +4296,8 @@ c2c_test_deep_bad: "4 KiB 測試傳送未抵達:{detail}", "cw_files_paste_here_already": "已在此資料夾中。", "cw_files_paste_done_skipped": "完成。已略過 {n} 個。", "cw_files_replace_failed": "無法刪除要取代的項目,因此未貼上。", +"cw_files_conflict_replace_all_body": "現有的檔案會被覆寫;資料夾及其全部內容會先被刪除。無法復原。", +"cw_files_paste_halted": "已停止。「{name}」未貼上;要被取代的項目仍在刪除中。", }; export default zh_TW; diff --git a/payload/src/webui_api.c b/payload/src/webui_api.c index b9b3d5db..4c55153e 100644 --- a/payload/src/webui_api.c +++ b/payload/src/webui_api.c @@ -12,6 +12,7 @@ #include #include #include +#include #include #include "ava1_data.h" @@ -347,9 +348,10 @@ static void r_fs_list(const char *b, size_t n, uint8_t *out, int fd) { } static void r_fs_stat(const char *b, size_t n, uint8_t *out, int fd) { - char path[API_PATH], req[API_PATH + 16], json[160]; + char path[API_PATH], req[API_PATH + 16], json[256]; ava1_fs_path_t q; ava1_fs_stat_t s; + struct stat ls; ava1_w_t w; size_t ol; int st, k; @@ -371,8 +373,22 @@ static void r_fs_stat(const char *b, size_t n, uint8_t *out, int fd) { send_error(fd, 500, "bad_stat_reply", 14); return; } - k = snprintf(json, sizeof json, "{\"kind\":\"%s\",\"size\":%llu,\"mtime\":%llu,\"mode\":%lu}", kind_name(s.kind), - (unsigned long long)s.size, (unsigned long long)s.mtime, (unsigned long)s.mode); + /* The entry's identity (device + inode, as strings: an inode can pass 2^53) so the page can tell + * "the item itself" from a different entry by more than its spelling: exFAT USB is + * case-insensitive, so /mnt/usb0/Games and /mnt/usb0/games are one folder. lstat (the + * entry itself, not a link's target) of the path the FS_STAT above already accepted. */ + if (lstat(path, &ls) == 0) + k = snprintf(json, sizeof json, + "{\"kind\":\"%s\",\"size\":%llu,\"mtime\":%llu,\"mode\":%lu,\"dev\":\"%llu\",\"ino\":\"%llu\"}", + kind_name(s.kind), (unsigned long long)s.size, (unsigned long long)s.mtime, (unsigned long)s.mode, + (unsigned long long)ls.st_dev, (unsigned long long)ls.st_ino); + else + k = snprintf(json, sizeof json, "{\"kind\":\"%s\",\"size\":%llu,\"mtime\":%llu,\"mode\":%lu}", kind_name(s.kind), + (unsigned long long)s.size, (unsigned long long)s.mtime, (unsigned long)s.mode); + if (k < 0 || (size_t)k >= sizeof json) { + send_error(fd, 500, "bad_stat_reply", 14); + return; + } send_json(fd, 200, json, (size_t)k); }