diff --git a/client/src/console/files/Files.tsx b/client/src/console/files/Files.tsx index 0213fe35..8f78447e 100644 --- a/client/src/console/files/Files.tsx +++ b/client/src/console/files/Files.tsx @@ -6,12 +6,14 @@ import { ClipboardPaste, ClipboardX, Ellipsis, + ChartBar, Filter, FilterX, FolderPlus, ListChecks, ListX, RotateCw, + Search, Square, SquareCheck, Star, @@ -79,6 +81,8 @@ import { useDir, type DirState, type Entry } from "./useDir"; import { Viewer, type ViewKind } from "./Viewer"; import { GameBanner } from "./viewer/GameBanner"; import { sceSysName } from "./viewer/gameFolder"; +import { SearchDialog } from "./search/SearchDialog"; +import { UsageDialog } from "./usage/UsageDialog"; export { upFrom, viewKind }; @@ -100,6 +104,9 @@ export type FilesAsk = | { kind: "pick"; op: "copy" | "move"; names: string[] } | { kind: "info"; name: string } | { kind: "view"; name: string; type: ViewKind } + /** Search by name (the helper's index) and Disk usage: Files tools over the folder shown. */ + | { kind: "search" } + | { kind: "usage" } /** A pinned or recent folder's menu (the drive list). */ | { kind: "place"; path: string } /** Eject a USB drive; `force` after the helper said it is still in use. */ @@ -247,6 +254,8 @@ export interface FilesViewProps { onUnpack?: (name: string, outName: string | null) => void; onMkdir: (name: string) => void; onPicked: (op: "copy" | "move", names: string[], destDir: string) => void; + /** Open `dir` in Files with `name` focused (a search result); null name: the folder itself. */ + onReveal?: (dir: string, name: string | null) => void; } /** "Copying 3 of 40" (item `at` is being sent or is running), or "Stopping…". */ @@ -577,6 +586,17 @@ function askView(p: FilesViewProps) { onClose={p.onDismiss} /> ); + case "search": + return ( + p.onReveal?.(dir, name)} + onClose={p.onDismiss} + /> + ); + case "usage": + return p.onReveal?.(dir, null)} onClose={p.onDismiss} />; default: return null; } @@ -785,6 +805,12 @@ export function FilesView(p: FilesViewProps) { onClick={() => p.onAsk({ kind: "mkdir" })} /> + {p.onReveal ? ( + <> + p.onAsk({ kind: "search" })} /> + p.onAsk({ kind: "usage" })} /> + + ) : null} {p.onView ? ( <> { + setAsk(null); + go(dir, name ?? ""); + }, + [go], + ); + return (
); diff --git a/client/src/console/files/search/SearchDialog.tsx b/client/src/console/files/search/SearchDialog.tsx new file mode 100644 index 00000000..f7c88ecb --- /dev/null +++ b/client/src/console/files/search/SearchDialog.tsx @@ -0,0 +1,251 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { File, RotateCw, Search, Square } from "lucide-react"; +import { formatBytes } from "../../../lib/format"; +import { errorText, type Volume } from "../../home/Home"; +import { t } from "../../strings"; +import { Modal } from "../../ui/Modal"; +import { + createSearchFlow, + driveRoots, + INITIAL_SEARCH, + namePattern, + searchApi, + type Hit, + type Scope, + type SearchFlow, + type SearchState, +} from "./search"; +import "./search.css"; + +/** The index line: none yet, building (with its count), or ready (and how complete it is). */ +export function indexText(s: SearchState): string | null { + if (s.unsupported) return t("cw_files_search_unsupported"); + const i = s.index; + if (!i) return t("cw_home_loading"); + const n = i.files.toLocaleString(); + if (i.phase === "building") return s.stopping ? t("cw_files_search_stopping") : t("cw_files_search_building", { n }); + if (i.phase === "idle" || (i.phase === "ready" && !i.roots)) return t("cw_files_search_no_index"); + const notes = [t("cw_files_search_ready", { n })]; + if (i.cancelled) notes.push(t("cw_files_search_partial")); + else if (i.truncated) notes.push(t("cw_files_search_capped")); + return notes.join(" "); +} + +/** The results line: how many, whether there are more, or nothing found. */ +export function resultsText(s: SearchState): string | null { + if (s.searching) return t("cw_files_search_searching"); + if (!s.done) return null; + const n = s.done.results.hits.length; + if (!n) return t("cw_files_search_none", { q: s.done.pattern }); + return s.done.results.truncated ? t("cw_files_search_more", { n }) : t("cw_files_search_found", { n }); +} + +/** The props-only view (tests render it). */ +export function SearchView({ + state, + field, + scope, + folder, + onField, + onScope, + onSubmit, + onCancel, + onRebuild, + onReveal, + onClose, +}: { + state: SearchState; + field: string; + scope: Scope; + folder: string; + onField: (v: string) => void; + onScope: (s: Scope) => void; + onSubmit: () => void; + onCancel: () => void; + onRebuild: () => void; + onReveal: (hit: Hit) => void; + onClose: () => void; +}) { + const building = state.index?.phase === "building"; + const bad = field.trim() !== "" && namePattern(field) == null; + const idx = indexText(state); + const res = resultsText(state); + const err = state.error ? errorText(state.error) : null; + return ( + <> +
+

{t("cw_files_search")}

+ +
+
{ + e.preventDefault(); + if (!bad) onSubmit(); + }} + > + + + + + + +
+

+ {scope === "folder" ? {folder} · : null} + {t("cw_files_search_hint")} +

+ {bad ? ( +

+ {t("cw_files_search_bad")} +

+ ) : null} +
+ {idx} + {building && !state.stopping ? ( + + ) : null} + {!building && state.index?.phase === "ready" && state.index.roots ? ( + + ) : null} + {building ? ( +
+
+
+ ) : null} + {state.pending && building ? {t("cw_files_search_waiting", { q: state.pending })} : null} +
+ {err ? ( +

+ {err} +

+ ) : null} +
+ {res ? ( +

+ {res} +

+ ) : null} + {state.done && state.done.results.hits.length ? ( +
    + {state.done.results.hits.map((h) => ( +
  • + +
  • + ))} +
+ ) : null} +
+ + ); +} + +/** + * Search (a Files tool): a name or pattern, all drives or this folder. The first + * search builds the helper's index for that scope (progress, Cancel); results + * open in Files at the item (✕). Closing leaves a running build to finish in the + * helper (the next search finds it ready). + */ +export function SearchDialog({ + folder, + volumes, + onReveal, + onClose, + flowFactory, +}: { + folder: string; + volumes: Volume[]; + onReveal: (dir: string, name: string) => void; + onClose: () => void; + /** Tests: a flow over fake calls. */ + flowFactory?: (onChange: (s: SearchState) => void) => SearchFlow; +}) { + const [state, setState] = useState(INITIAL_SEARCH); + const [field, setField] = useState(""); + const [scope, setScope] = useState("all"); + const flow = useRef(null); + const roots = useMemo(() => (scope === "all" ? driveRoots(volumes) : [folder]), [scope, volumes, folder]); + useEffect(() => { + const f = flowFactory + ? flowFactory(setState) + : createSearchFlow( + { api: searchApi, setTimer: (fn, ms) => setTimeout(fn, ms), clearTimer: (x) => clearTimeout(x as ReturnType) }, + setState, + ); + flow.current = f; + void f.open(); + return () => f.close(); + }, [flowFactory]); + return ( + + void flow.current?.submit(field, scope, roots, scope === "folder" ? folder : null)} + onCancel={() => void flow.current?.cancel()} + onRebuild={() => void flow.current?.rebuild(roots)} + onReveal={(h) => onReveal(h.folder, h.name)} + onClose={onClose} + /> + + ); +} diff --git a/client/src/console/files/search/search.css b/client/src/console/files/search/search.css new file mode 100644 index 00000000..f4c05bfb --- /dev/null +++ b/client/src/console/files/search/search.css @@ -0,0 +1,79 @@ +/* ---- Files: search and disk usage ---- */ +.cw-dialog.cw-search, +.cw-dialog.cw-usage { + display: flex; + flex-direction: column; +} +.cw-search-field { + width: 24rem; +} +.cw-search-scope { + display: inline-flex; + margin-left: 0.4rem; +} +.cw-search-scope .cw-button { + margin-right: 0.4rem; +} +.cw-search-scope [aria-pressed="true"] { + background: var(--color-accent-soft); + color: var(--color-accent); +} +.cw-search-hint { + margin: 0 0 0.5rem; + font-size: 0.9rem; +} +.cw-search-index { + flex: none; +} +.cw-search-index .cw-bar { + margin-top: 0.4rem; +} +.cw-search-pending { + display: block; + margin-top: 0.3rem; +} +.cw-search-body, +.cw-usage-body { + flex: 1 1 auto; + min-height: 0; + overflow: auto; +} +.cw-search-body > p, +.cw-usage-body > p { + margin: 0 0 0.4rem; +} +.cw-usage-row .cw-file-main { + flex: 1 1 auto; + min-width: 0; +} +.cw-usage-bar { + display: block; + flex: 0 0 22rem; + margin: 0 1rem; +} +.cw-usage-bar .cw-bar { + display: block; + height: 0.7rem; + margin: 0; +} +.cw-usage-size { + flex: none; + width: 7.5rem; + text-align: right; +} +.cw-usage-path { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.cw-usage-row .cw-button, +.cw-usage-spacer { + flex: none; + box-sizing: border-box; + width: 2.8rem; + margin-left: 0.4rem; + padding: 0.3rem 0; + justify-content: center; +} diff --git a/client/src/console/files/search/search.test.ts b/client/src/console/files/search/search.test.ts new file mode 100644 index 00000000..856619c6 --- /dev/null +++ b/client/src/console/files/search/search.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it, vi } from "vitest"; +import { ApiError } from "../../api"; +import type { Volume } from "../../home/Home"; +import { + covers, + createSearchFlow, + driveRoots, + namePattern, + POLL_MS, + toResults, + toStatus, + type IndexStatus, + type Results, + type SearchApi, + type SearchState, +} from "./search"; + +const vol = (path: string, mount_from = "/dev/x"): Volume => ({ path, mount_from, fs_type: "exfatfs", total_bytes: 1, free_bytes: 1, writable: true }); + +describe("search helpers", () => { + it("a word matches anywhere in the name; a pattern is used as typed", () => { + expect(namePattern(" eboot ")).toBe("*eboot*"); + expect(namePattern("*.pkg")).toBe("*.pkg"); + expect(namePattern("CUSA0784?")).toBe("CUSA0784?"); + expect(namePattern("")).toBeNull(); + expect(namePattern(" ")).toBeNull(); + }); + + it("refuses what the helper's quote-to-quote scan would misread, and long patterns", () => { + expect(namePattern('a"b')).toBeNull(); + expect(namePattern("a\\b")).toBeNull(); + expect(namePattern("a\tb")).toBeNull(); + expect(namePattern("x".repeat(253))).toBe(`*${"x".repeat(253)}*`); // 255 bytes: the helper's limit + expect(namePattern("x".repeat(254))).toBeNull(); // 256 bytes + expect(namePattern("é".repeat(127))).toBeNull(); // 254 UTF-8 bytes + 2 + }); + + it("all drives = the drive list's drives (internal, extended, USB), never the system volumes", () => { + const v = [vol("/data", "/dev/ssd0.user"), vol("/system"), vol("/mnt/ext0", "/dev/nvme0"), vol("/mnt/usb0", "/dev/da0s1"), vol("/mnt/ps5upload/img")]; + expect(driveRoots(v)).toEqual(["/data", "/mnt/ext0", "/mnt/usb0"]); + }); + + it("an index covers a scope inside one of its roots", () => { + expect(covers(["/data", "/mnt/usb0"], ["/data/games"])).toBe(true); + expect(covers(["/data", "/mnt/usb0"], ["/data", "/mnt/usb0"])).toBe(true); + expect(covers(["/data/games"], ["/data"])).toBe(false); + expect(covers(["/data"], ["/datax"])).toBe(false); + expect(covers(null, ["/data"])).toBe(false); + expect(covers(["/data"], [])).toBe(false); + }); + + it("status and results replies are checked", () => { + expect(toStatus({ phase: "ready", files: 12, truncated: true, cancelled: false, roots: ["/data", 3] })).toEqual({ + phase: "ready", + files: 12, + truncated: true, + cancelled: false, + roots: ["/data"], + }); + expect(toStatus({ phase: "done" })).toBeNull(); + expect(toStatus(null)).toBeNull(); + expect(toResults({ results: [{ path: "/data/a/b.pkg", size: 5 }, { path: "rel" }, { size: 1 }, null], truncated: true })).toEqual({ + hits: [{ path: "/data/a/b.pkg", name: "b.pkg", folder: "/data/a", size: 5 }], + truncated: true, + }); + expect(toResults({})).toEqual({ hits: [], truncated: false }); + }); +}); + +/* ---- the flow, over a fake helper and a manual clock ---- */ + +function fakeHelper(start: Partial = {}) { + let status: IndexStatus = { phase: "idle", files: 0, truncated: false, cancelled: false, roots: null, ...start }; + const results: Results = { hits: [{ path: "/data/g/eboot.bin", name: "eboot.bin", folder: "/data/g", size: 9 }], truncated: false }; + const api = { + status: vi.fn(async () => status), + start: vi.fn(async (roots: string[]) => { + status = { phase: "building", files: 0, truncated: false, cancelled: false, roots }; + }), + cancel: vi.fn(async () => { + status = { ...status, phase: "ready", cancelled: true }; + }), + search: vi.fn(async () => results), + } satisfies SearchApi; + return { + api, + set(s: Partial) { + status = { ...status, ...s }; + }, + }; +} + +function flowOver(api: SearchApi) { + const timers: (() => void)[] = []; + const states: SearchState[] = []; + const flow = createSearchFlow( + { + api, + setTimer: (fn, ms) => { + expect(ms).toBe(POLL_MS); + timers.push(fn); + return timers.length; + }, + clearTimer: () => undefined, + }, + (s) => states.push(s), + ); + const tick = async () => { + const fn = timers.shift(); + if (fn) fn(); + await new Promise((r) => setTimeout(r, 0)); + }; + return { flow, timers, states, tick }; +} + +describe("search flow", () => { + it("first use builds the index for the scope (progress), then searches", async () => { + const h = fakeHelper(); + const { flow, timers, tick } = flowOver(h.api); + await flow.open(); + expect(flow.state().index?.phase).toBe("idle"); + await flow.submit("eboot", "all", ["/data", "/mnt/usb0"], null); + expect(h.api.start).toHaveBeenCalledWith(["/data", "/mnt/usb0"]); + expect(flow.state().index?.phase).toBe("building"); + expect(flow.state().pending).toBe("*eboot*"); + expect(h.api.search).not.toHaveBeenCalled(); + h.set({ files: 5000 }); + await tick(); + expect(flow.state().index?.files).toBe(5000); + h.set({ phase: "ready", files: 9000 }); + await tick(); + expect(h.api.search).toHaveBeenCalledWith("*eboot*", null); + expect(flow.state().done?.results.hits[0].name).toBe("eboot.bin"); + expect(flow.state().pending).toBeNull(); + expect(timers).toHaveLength(0); // no polling once ready + }); + + it("an index that covers the scope is searched at once (a folder scope filters by `under`)", async () => { + const h = fakeHelper({ phase: "ready", files: 10, roots: ["/data", "/mnt/usb0"] }); + const { flow } = flowOver(h.api); + await flow.open(); + await flow.submit("*.pkg", "folder", ["/data/games"], "/data/games"); + expect(h.api.start).not.toHaveBeenCalled(); + expect(h.api.search).toHaveBeenCalledWith("*.pkg", "/data/games"); + expect(flow.state().done?.scope).toBe("folder"); + }); + + it("an index that does not cover the scope (or another client's) is rebuilt for it", async () => { + const h = fakeHelper({ phase: "ready", files: 10, roots: ["/data/games"] }); + const { flow } = flowOver(h.api); + await flow.open(); + await flow.submit("a", "all", ["/data"], null); + expect(h.api.start).toHaveBeenCalledWith(["/data"]); + }); + + it("a search while it builds waits for the build; another client's build gets one build of ours", async () => { + const h = fakeHelper({ phase: "building", roots: null }); + const { flow, tick } = flowOver(h.api); + await flow.open(); + await flow.submit("x", "all", ["/data"], null); + expect(h.api.start).not.toHaveBeenCalled(); + h.set({ phase: "ready", roots: null }); + await tick(); // not ours: build once for the scope + expect(h.api.start).toHaveBeenCalledTimes(1); + h.set({ phase: "ready" }); + await tick(); + expect(h.api.search).toHaveBeenCalledTimes(1); + expect(h.api.start).toHaveBeenCalledTimes(1); + }); + + it("Cancel stops the build: the partial index stays, nothing is searched", async () => { + const h = fakeHelper(); + const { flow, tick } = flowOver(h.api); + await flow.open(); + await flow.submit("x", "all", ["/data"], null); + await flow.cancel(); + expect(h.api.cancel).toHaveBeenCalled(); + expect(flow.state().stopping).toBe(true); + expect(flow.state().pending).toBeNull(); + await tick(); + expect(flow.state().index).toMatchObject({ phase: "ready", cancelled: true }); + expect(flow.state().stopping).toBe(false); + expect(h.api.search).not.toHaveBeenCalled(); + // the partial index covers the scope: the next search uses it + await flow.submit("x", "all", ["/data"], null); + expect(h.api.search).toHaveBeenCalledTimes(1); + expect(h.api.start).toHaveBeenCalledTimes(1); + }); + + it("another build already running (409 busy) is waited on instead", async () => { + const h = fakeHelper(); + h.api.start.mockImplementationOnce(async () => { + h.set({ phase: "building", roots: ["/data"] }); + throw new ApiError(409, "busy"); + }); + const { flow, tick } = flowOver(h.api); + await flow.open(); + await flow.submit("x", "all", ["/data"], null); + expect(flow.state().error).toBeNull(); + h.set({ phase: "ready" }); + await tick(); + expect(h.api.search).toHaveBeenCalled(); + }); + + it("an older helper (no index routes) says so; nothing else is tried", async () => { + const h = fakeHelper(); + h.api.status.mockRejectedValue(new ApiError(404, "not_found")); + const { flow } = flowOver(h.api); + await flow.open(); + expect(flow.state().unsupported).toBe(true); + await flow.submit("x", "all", ["/data"], null); + expect(h.api.start).not.toHaveBeenCalled(); + }); + + it("a failed start shows its error and forgets the pending search", async () => { + const h = fakeHelper(); + h.api.start.mockRejectedValueOnce(new ApiError(403, "path_not_allowed")); + const { flow } = flowOver(h.api); + await flow.open(); + await flow.submit("x", "folder", ["/data/ps5upload"], "/data/ps5upload"); + expect(flow.state().error).toBeInstanceOf(ApiError); + expect(flow.state().pending).toBeNull(); + }); + + it("closing stops the polling (the build itself goes on in the helper)", async () => { + const h = fakeHelper(); + const { flow, timers, states } = flowOver(h.api); + await flow.open(); + await flow.submit("x", "all", ["/data"], null); + expect(timers).toHaveLength(1); + flow.close(); + const seen = states.length; + timers.shift()?.(); + await new Promise((r) => setTimeout(r, 0)); + expect(states.length).toBe(seen); + expect(h.api.cancel).not.toHaveBeenCalled(); + }); + + it("an invalid pattern is not sent", async () => { + const h = fakeHelper({ phase: "ready", roots: ["/data"] }); + const { flow } = flowOver(h.api); + await flow.open(); + await flow.submit('say "hi"', "all", ["/data"], null); + expect(h.api.search).not.toHaveBeenCalled(); + expect(h.api.start).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/console/files/search/search.ts b/client/src/console/files/search/search.ts new file mode 100644 index 00000000..9d8eff7d --- /dev/null +++ b/client/src/console/files/search/search.ts @@ -0,0 +1,319 @@ +/** + * Search by name with the helper's file index (/api/index/*, over INDEX_START / + * STATUS / SEARCH / CANCEL). One index for the whole helper: built by a thread + * of its own over up to 8 roots (at most 200,000 files and 32 MiB of paths, ten + * folders deep, dot names skipped), cancellable between folders. A search + * matches the whole file NAME against a pattern (* and ?, ASCII + * case-insensitive); a plain word is looked for anywhere in the name. + * + * The flow (createSearchFlow) is a small state machine with injected calls, so + * the page's behaviour is tested without a DOM: first use builds the index for + * the scope (progress + Cancel), then searches; a later search reuses an index + * that covers the scope. + */ +import { ApiError, call } from "../../api"; +import { isDrive, type Volume } from "../../home/Home"; +import { baseName, parentPath } from "../move"; + +export interface IndexStatus { + phase: "idle" | "building" | "ready"; + files: number; + truncated: boolean; + /** The page stopped the build: what it found is all there is. */ + cancelled: boolean; + /** The roots the page built this index for; null when it is not the page's (never built, or another client's). */ + roots: string[] | null; +} + +export interface Hit { + path: string; + name: string; + folder: string; + size: number; +} + +export interface Results { + hits: Hit[]; + /** There may be more than these (the helper's limit, or the page's 500). */ + truncated: boolean; +} + +/** A root or pattern the helper's scan can carry: no quote, backslash or control character, < 256 bytes. */ +function plain(s: string): boolean { + if (!s) return false; + for (let i = 0; i < s.length; i++) { + const c = s.charCodeAt(i); + if (c < 0x20 || c === 0x7f || c === 0x22 || c === 0x5c) return false; + } + return new TextEncoder().encode(s).length < 256; +} + +/** + * What the field holds as the helper's pattern: as written when it has * or ?, + * else "*word*" (anywhere in the name). null: empty, or a character the helper + * cannot take (" \ or a control character), or too long. + */ +export function namePattern(text: string): string | null { + const q = text.trim(); + if (!q) return null; + const p = /[*?]/.test(q) ? q : `*${q}*`; + return plain(p) ? p : null; +} + +/** The drives the drive list shows (internal, M.2/extended, USB), as index roots (at most 8). */ +export function driveRoots(volumes: Volume[]): string[] { + return volumes + .filter(isDrive) + .map((v) => v.path.replace(/\/+$/, "") || "/") + .filter(plain) + .slice(0, 8); +} + +/** `path` is `root` or inside it. */ +export function isUnder(path: string, root: string): boolean { + const r = root.replace(/\/+$/, ""); + if (r === "") return true; + return path === r || path.startsWith(`${r}/`); +} + +/** The index (its roots) covers every one of `scope`'s roots. */ +export function covers(roots: string[] | null, scope: string[]): boolean { + if (!roots || !roots.length || !scope.length) return false; + return scope.every((s) => roots.some((r) => isUnder(s, r))); +} + +/** /api/index/status's reply, checked. */ +export function toStatus(raw: unknown): IndexStatus | null { + if (!raw || typeof raw !== "object") return null; + const o = raw as Record; + if (o.phase !== "idle" && o.phase !== "building" && o.phase !== "ready") return null; + return { + phase: o.phase, + files: typeof o.files === "number" && o.files >= 0 ? o.files : 0, + truncated: o.truncated === true, + cancelled: o.cancelled === true, + roots: Array.isArray(o.roots) ? o.roots.filter((r): r is string => typeof r === "string") : null, + }; +} + +/** /api/index/search's reply as hits (entries without a usable path are left out). */ +export function toResults(raw: unknown): Results { + const o = (raw && typeof raw === "object" ? raw : {}) as Record; + const list = Array.isArray(o.results) ? o.results : []; + const hits: Hit[] = []; + for (const r of list) { + if (!r || typeof r !== "object") continue; + const x = r as Record; + if (typeof x.path !== "string" || !x.path.startsWith("/")) continue; + const name = baseName(x.path); + if (!name) continue; + hits.push({ path: x.path, name, folder: parentPath(x.path), size: typeof x.size === "number" ? x.size : 0 }); + } + return { hits, truncated: o.truncated === true }; +} + +export interface SearchApi { + status(): Promise; + start(roots: string[]): Promise; + cancel(): Promise; + search(pattern: string, under: string | null): Promise; +} + +export const searchApi: SearchApi = { + async status() { + const s = toStatus(await call("/api/index/status")); + if (!s) throw new ApiError(500, "bad_index_reply"); + return s; + }, + async start(roots) { + await call("/api/index/start", { roots }); + }, + async cancel() { + await call("/api/index/cancel"); + }, + async search(pattern, under) { + return toResults(await call("/api/index/search", under ? { query: pattern, under } : { query: pattern })); + }, +}; + +/* ---- the flow ---- */ + +export type Scope = "all" | "folder"; + +export interface SearchState { + /** null until the first status read. */ + index: IndexStatus | null; + /** The helper has no index routes (an older helper). */ + unsupported: boolean; + /** The last call's failure (shown as a line), or null. */ + error: unknown; + /** The search that runs once the index is built (the field's pattern), or null. */ + pending: string | null; + /** A search request is running. */ + searching: boolean; + /** The last search's pattern and scope, with its results. */ + done: { pattern: string; scope: Scope; results: Results } | null; + /** Cancel was asked and the build has not stopped yet. */ + stopping: boolean; +} + +export const INITIAL_SEARCH: SearchState = { + index: null, + unsupported: false, + error: null, + pending: null, + searching: false, + done: null, + stopping: false, +}; + +/** How often the status is read while the index builds. */ +export const POLL_MS = 1000; + +export interface FlowDeps { + api: SearchApi; + setTimer: (fn: () => void, ms: number) => unknown; + clearTimer: (t: unknown) => void; +} + +export interface SearchFlow { + state(): SearchState; + /** Read the index status (on open); polls while it builds. */ + open(): Promise; + /** + * Search for `text` in `scope` (`roots` its index roots, `under` the folder + * to search within, or null for every root): builds the index first when the + * one there does not cover `roots`. + */ + submit(text: string, scope: Scope, roots: string[], under: string | null): Promise; + /** Build the index again for `roots` (results may be stale). */ + rebuild(roots: string[]): Promise; + /** Stop the build: what it found so far stays searchable. */ + cancel(): Promise; + /** The dialog closed: stop polling and forget the pending search (a build keeps running in the helper). */ + close(): void; +} + +const isMissingRoute = (e: unknown) => e instanceof ApiError && e.status === 404 && e.code === "not_found"; + +export function createSearchFlow(deps: FlowDeps, onChange: (s: SearchState) => void): SearchFlow { + let s: SearchState = INITIAL_SEARCH; + let timer: unknown = null; + let closed = false; + /** The scope the pending search runs in once the build ends. */ + let pendingScope: { scope: Scope; under: string | null; roots: string[]; built: boolean } | null = null; + const set = (patch: Partial) => { + if (closed) return; + s = { ...s, ...patch }; + onChange(s); + }; + const stopPoll = () => { + if (timer != null) deps.clearTimer(timer); + timer = null; + }; + + const runSearch = async (pattern: string, scope: Scope, under: string | null) => { + set({ searching: true, error: null, pending: null }); + try { + const results = await deps.api.search(pattern, under); + set({ searching: false, done: { pattern, scope, results } }); + } catch (e) { + set({ searching: false, error: e }); + } + }; + + const poll = async (): Promise => { + timer = null; + if (closed) return; + let st: IndexStatus; + try { + st = await deps.api.status(); + } catch (e) { + if (isMissingRoute(e)) return set({ unsupported: true }); + set({ error: e }); + // A read that failed (the helper busy, a blip) is tried again while the build may still run. + if (!closed) timer = deps.setTimer(() => void poll(), POLL_MS); + return; + } + set({ index: st, error: null, ...(st.phase !== "building" ? { stopping: false } : {}) }); + if (st.phase === "building") { + if (!closed) timer = deps.setTimer(() => void poll(), POLL_MS); + return; + } + const pending = s.pending; + const where = pendingScope; + pendingScope = null; + if (!pending || !where) return; + // The build this search asked for (or one that covers it) ended: search. A build that was + // someone else's and does not cover the scope: build once for it, then search. + if (where.built || covers(st.roots, where.roots)) await runSearch(pending, where.scope, where.under); + else { + pendingScope = { ...where, built: true }; + await build(where.roots); + } + }; + + const build = async (roots: string[]): Promise => { + stopPoll(); + try { + await deps.api.start(roots); + } catch (e) { + // Already building (another start won the race): wait for that one instead. + if (!(e instanceof ApiError && e.status === 409)) { + pendingScope = null; + if (isMissingRoute(e)) set({ unsupported: true, pending: null }); + else set({ error: e, pending: null }); + return false; + } + } + set({ index: { phase: "building", files: 0, truncated: false, cancelled: false, roots }, stopping: false, done: null }); + await poll(); + return true; + }; + + return { + state: () => s, + async open() { + closed = false; + await poll(); + }, + async submit(text, scope, roots, under) { + const pattern = namePattern(text); + if (!pattern || s.unsupported) return; + set({ error: null }); + const idx = s.index; + if (idx?.phase === "building") { + // Searched while it builds: run it when the build ends (the latest one wins). + pendingScope = { scope, under, roots, built: false }; + set({ pending: pattern }); + return; + } + if (idx?.phase === "ready" && covers(idx.roots, roots)) { + await runSearch(pattern, scope, under); + return; + } + pendingScope = { scope, under, roots, built: true }; + set({ pending: pattern }); + await build(roots); + }, + async rebuild(roots) { + if (s.index?.phase === "building") return; + await build(roots); + }, + async cancel() { + if (s.index?.phase !== "building") return; + set({ stopping: true, pending: null }); + pendingScope = null; + try { + await deps.api.cancel(); + } catch (e) { + set({ stopping: false, error: e }); + } + }, + close() { + closed = true; + stopPoll(); + pendingScope = null; + }, + }; +} diff --git a/client/src/console/files/tools.test.tsx b/client/src/console/files/tools.test.tsx new file mode 100644 index 00000000..6ca4ab5b --- /dev/null +++ b/client/src/console/files/tools.test.tsx @@ -0,0 +1,197 @@ +import { isValidElement, type ReactElement, type ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../api", async (importOriginal) => { + const real = await importOriginal(); + return { ...real, call: vi.fn(), callBlob: vi.fn() }; +}); + +import { formatBytes } from "../../lib/format"; +import { ApiError } from "../api"; +import type { Volume } from "../home/Home"; +import { t } from "../strings"; +import { FilesView, type FilesViewProps } from "./Files"; +import { SearchDialog, SearchView, indexText, resultsText } from "./search/SearchDialog"; +import { INITIAL_SEARCH, type SearchState } from "./search/search"; +import type { DirState, Entry } from "./useDir"; +import { UsageDialog, UsageView, rowSize, usageLine } from "./usage/UsageDialog"; +import type { UsageSnapshot } from "./usage/usage"; + +function elements(node: ReactNode): ReactElement>[] { + const out: ReactElement>[] = []; + const walk = (n: ReactNode) => { + if (Array.isArray(n)) return n.forEach(walk); + if (!isValidElement(n)) return; + const el = n as ReactElement>; + out.push(el); + walk(el.props.children as ReactNode); + }; + walk(node); + return out; +} +function find(node: ReactNode, type: T): ReactElement> { + const el = elements(node).find((e) => e.type === type); + if (!el) throw new Error("not rendered"); + return el; +} + +const entry = (name: string, kind: Entry["kind"] = "file", size = 10): Entry => ({ name, kind, size, mtime: 0 }); +const dir = (entries: Entry[]): DirState => ({ entries, more: false, loading: false, loaded: true, error: null }); +const vol = (path: string): Volume => ({ path, mount_from: "/dev/ssd0.user", fs_type: "ufs", total_bytes: 1000, free_bytes: 400, writable: true }); +const ENTRIES = [entry("sce_sys", "dir"), entry("b.png"), entry("notes.txt"), entry("a.jpg"), entry("eboot.bin"), entry("c.PNG")]; + +function props(over: Partial = {}): FilesViewProps { + return { + path: "/data/games/Astro", + volumes: [vol("/data")], + volumesError: false, + dir: dir(ENTRIES), + selecting: false, + selected: [], + ask: null, + busy: null, + message: null, + tasks: [], + onOpen: vi.fn(), + onUp: vi.fn(), + onEntry: vi.fn(), + onLoadMore: vi.fn(), + onToggleSelecting: vi.fn(), + onToggleItem: vi.fn(), + onAsk: vi.fn(), + onDismiss: vi.fn(), + onConfirmDelete: vi.fn(), + onConfirmFixPerm: vi.fn(), + onRename: vi.fn(), + onMkdir: vi.fn(), + onPicked: vi.fn(), + ...over, + }; +} + +describe("search page", () => { + it("is a Files tool: the toolbar button, and the dialog for the folder shown", () => { + const onReveal = vi.fn(); + const html = renderToStaticMarkup(<>{FilesView(props({ onReveal }))}); + expect(html).toContain('data-action="search"'); + expect(html).toContain('data-action="usage"'); + expect(renderToStaticMarkup(<>{FilesView(props())})).not.toContain('data-action="search"'); + const d = find(FilesView(props({ onReveal, ask: { kind: "search" } })), SearchDialog); + expect(d.props.folder).toBe("/data/games/Astro"); + (d.props.onReveal as (dir: string, name: string) => void)("/data/x", "eboot.bin"); + expect(onReveal).toHaveBeenCalledWith("/data/x", "eboot.bin"); + }); + + const base = (over: Partial): SearchState => ({ ...INITIAL_SEARCH, ...over }); + const view = (s: SearchState, field = "eboot") => + renderToStaticMarkup( + , + ); + + it("building: the count, a Cancel button, and the search that waits", () => { + const s = base({ index: { phase: "building", files: 12345, truncated: false, cancelled: false, roots: ["/data"] }, pending: "*eboot*" }); + const html = view(s); + expect(html).toContain(t("cw_files_search_building", { n: (12345).toLocaleString() })); + expect(html).toContain('data-action="index-cancel"'); + expect(html).toContain(t("cw_files_search_waiting", { q: "*eboot*" })); + expect(indexText(base({ ...s, stopping: true }))).toBe(t("cw_files_search_stopping")); + }); + + it("results: name, folder and size per row; more-than-shown said", () => { + const s = base({ + index: { phase: "ready", files: 9, truncated: false, cancelled: true, roots: ["/data"] }, + done: { pattern: "*eboot*", scope: "all", results: { hits: [{ path: "/data/g/eboot.bin", name: "eboot.bin", folder: "/data/g", size: 2048 }], truncated: true } }, + }); + const html = view(s); + expect(html).toContain('data-hit="/data/g/eboot.bin"'); + expect(html).toContain("eboot.bin"); + expect(html).toContain("/data/g"); + expect(html).toContain("2.00 KiB"); + expect(html).toContain(t("cw_files_search_more", { n: 1 })); + expect(html).toContain(t("cw_files_search_partial")); + expect(html).toContain('data-action="index-rebuild"'); + expect(resultsText(base({ done: { pattern: "*x*", scope: "all", results: { hits: [], truncated: false } } }))).toBe( + t("cw_files_search_none", { q: "*x*" }), + ); + }); + + it("an older helper, a bad pattern, an error", () => { + expect(view(base({ unsupported: true }))).toContain(t("cw_files_search_unsupported")); + const bad = view(base({ index: { phase: "idle", files: 0, truncated: false, cancelled: false, roots: null } }), 'a"b'); + expect(bad).toContain(t("cw_files_search_bad")); + expect(bad).toMatch(/data-action="search-go"[^>]*disabled/); + expect(bad).toContain(t("cw_files_search_no_index")); + expect(view(base({ error: new ApiError(500, "oom") }))).toContain("oom"); + }); +}); + +describe("disk usage page", () => { + it("is a Files tool; a row opens its folder in Files", () => { + const onReveal = vi.fn(); + const d = find(FilesView(props({ onReveal, ask: { kind: "usage" } })), UsageDialog); + expect(d.props.folder).toBe("/data/games/Astro"); + (d.props.onOpen as (p: string) => void)("/data/games"); + expect(onReveal).toHaveBeenCalledWith("/data/games", null); + }); + + const snap: UsageSnapshot = { + root: "/data", + rows: [ + { name: "", path: "/data", files: true, bytes: 150, entries: 2, state: "done" }, + { name: "music", path: "/data/music", files: false, bytes: 700, entries: 1, state: "done" }, + { name: "games", path: "/data/games", files: false, bytes: 8000, entries: 3, state: "walking" }, + { name: "zz", path: "/data/zz", files: false, bytes: 0, entries: 0, state: "waiting" }, + ], + measured: 1, + folders: 3, + entries: 6, + end: null, + }; + + it("bars biggest first, drill-down and open buttons, Cancel while measuring", () => { + const html = renderToStaticMarkup( + , + ); + const order = ["games", "music", "", "zz"].map((n) => html.indexOf(`data-usage-row="${n}"`)); + expect(order).toEqual([...order].sort((a, b) => a - b)); + expect(html).toContain('style="display:block;width:100%"'); + expect(html).toContain('data-usage-drill="/data/games"'); + expect(html).not.toContain('data-usage-drill="/data"'); + expect(html).toContain('data-action="usage-cancel"'); + expect(html).toMatch(/data-action="usage-up"[^>]*disabled/); + expect(html).toContain(t("cw_files_usage_running", { at: 1, n: 3, items: "6" })); + expect(rowSize(snap.rows[2])).toMatch(/\+$/); + expect(rowSize(snap.rows[3])).toBe("…"); + }); + + it("how it ended", () => { + expect(usageLine({ ...snap, end: "done" }, false)?.text).toBe(t("cw_files_usage_done", { size: formatBytes(8850), items: "6" })); + expect(usageLine({ ...snap, end: "cancelled" }, false)?.tone).toBe("info"); + expect(usageLine(null, true)?.text).toBe(t("cw_files_usage_busy")); + expect(usageLine({ ...snap, end: "error", error: new ApiError(401, "token") }, false)).toBeNull(); + }); +}); diff --git a/client/src/console/files/usage/UsageDialog.tsx b/client/src/console/files/usage/UsageDialog.tsx new file mode 100644 index 00000000..51fa1550 --- /dev/null +++ b/client/src/console/files/usage/UsageDialog.tsx @@ -0,0 +1,240 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { ArrowUp, ChevronRight, Files as FilesIcon, Folder, FolderOpen, RotateCw, Square } from "lucide-react"; +import { formatBytes } from "../../../lib/format"; +import { errorText, type Volume } from "../../home/Home"; +import { focusElement } from "../../nav"; +import { t } from "../../strings"; +import { Modal } from "../../ui/Modal"; +import { parentPath, volumeOf } from "../move"; +import { barPercent, sortRows, startUsage, stopUsage, type UsageRow, type UsageSnapshot } from "./usage"; +import "../search/search.css"; + +/** The status line under the path: progress while measuring, else how it ended. */ +export function usageLine(s: UsageSnapshot | null, refused: boolean): { text: string; tone: "info" | "error" | "plain" } | null { + if (refused) return { text: t("cw_files_usage_busy"), tone: "error" }; + if (!s) return { text: t("cw_home_loading"), tone: "plain" }; + const total = s.rows.reduce((a, r) => a + r.bytes, 0); + const items = s.entries.toLocaleString(); + switch (s.end) { + case null: + return { text: t("cw_files_usage_running", { at: s.measured, n: s.folders, items }), tone: "plain" }; + case "done": + return { text: t("cw_files_usage_done", { size: formatBytes(total), items }), tone: "plain" }; + case "capped": + return { text: t("cw_files_usage_capped", { size: formatBytes(total), items }), tone: "info" }; + case "timeout": + return { text: t("cw_files_usage_timeout", { size: formatBytes(total) }), tone: "info" }; + case "cancelled": + return { text: t("cw_files_usage_cancelled", { size: formatBytes(total) }), tone: "info" }; + default: { + const why = errorText(s.error); + return why ? { text: why, tone: "error" } : null; + } + } +} + +/** A row's size column: its size, with a mark when it is not the whole story. */ +export function rowSize(r: UsageRow): string { + if (r.state === "waiting") return "…"; + if (r.state === "skipped") return t("cw_files_usage_skipped"); + if (r.state === "unreadable") return t("cw_files_usage_unreadable"); + const s = formatBytes(r.bytes); + return r.state === "partial" || r.state === "walking" ? `${s}+` : s; +} + +/** The props-only view (tests render it). */ +export function UsageView({ + root, + canUp, + snap, + refused, + onUp, + onDrill, + onOpen, + onCancel, + onAgain, + onClose, +}: { + root: string; + canUp: boolean; + snap: UsageSnapshot | null; + refused: boolean; + onUp: () => void; + onDrill: (path: string) => void; + onOpen: (path: string) => void; + onCancel: () => void; + onAgain: () => void; + onClose: () => void; +}) { + const rows = snap ? sortRows(snap.rows).filter((r) => !r.files || r.bytes > 0 || r.entries > 0) : []; + const max = rows.reduce((a, r) => Math.max(a, r.bytes), 0); + const line = usageLine(snap, refused); + const running = snap != null && snap.end == null; + return ( + <> +
+

{t("cw_files_usage")}

+ +
+
+ + + {root} + + +
+ {line ? ( +
+ {line.text} + {running ? ( + + ) : ( + + )} +
+ ) : null} +
+ {snap && snap.end != null && !rows.length ?

{t("cw_files_empty")}

: null} +
    + {rows.map((r) => { + const Icon = r.files ? FilesIcon : Folder; + const pct = barPercent(r.bytes, max); + return ( +
  • + + {!r.files ? ( + + ) : ( + + )} +
  • + ); + })} +
+
+ + ); +} + +/** + * Disk usage (a Files tool): the subfolders of a folder (at first the drive + * Files is on) as bars, biggest first, measured one after another. "›" looks + * inside a folder (a new measurement; ○ comes back up); ✕ on a row opens it in + * Files. One measurement at a time; closing the dialog cancels it. + */ +export function UsageDialog({ + folder, + volumes, + onOpen, + onClose, +}: { + /** The folder Files shows: the measurement starts at its drive. */ + folder: string; + volumes: Volume[]; + onOpen: (path: string) => void; + onClose: () => void; +}) { + const top = (volumeOf(folder, volumes)?.path ?? folder).replace(/\/+$/, "") || "/"; + const [root, setRoot] = useState(top); + const [snap, setSnap] = useState(null); + const [refused, setRefused] = useState(false); + const [tick, setTick] = useState(0); + useEffect(() => { + let run: ReturnType = null; + let gone = false; + setSnap(null); + setRefused(false); + // One at a time: the measurement shown before (a drill or Up) ends first. + void stopUsage().then(() => { + if (gone) return; + run = startUsage(root, (s) => { + if (!gone) setSnap(s); + }); + if (!run) setRefused(true); + }); + return () => { + gone = true; + run?.cancel(); + }; + }, [root, tick]); + /** Where focus goes once it can: Up after looking inside, the folder we came out of after Up. */ + const focusNext = useRef<[string, string] | null>(null); + useEffect(() => { + const want = focusNext.current; + if (!want || typeof document === "undefined") return; + const [attr, value] = want; + // Compared as-is (no CSS escaping of folder names). + const el = Array.from(document.querySelectorAll(`[${attr}]`)).find((x) => x.getAttribute(attr) === value); + if (el && !(el as HTMLButtonElement).disabled) { + focusNext.current = null; + focusElement(el); + } else if (snap?.end != null) focusNext.current = null; + }, [root, snap]); + + const canUp = root !== top && root !== "/"; + const onUp = useCallback(() => { + if (root === top) return; + focusNext.current = ["data-usage-drill", root]; + setRoot(parentPath(root)); + }, [root, top]); + const onDrill = useCallback((path: string) => { + focusNext.current = ["data-action", "usage-up"]; + setRoot(path); + }, []); + return ( + + void stopUsage()} + onAgain={() => setTick((n) => n + 1)} + onClose={onClose} + /> + + ); +} diff --git a/client/src/console/files/usage/usage.test.ts b/client/src/console/files/usage/usage.test.ts new file mode 100644 index 00000000..efca7491 --- /dev/null +++ b/client/src/console/files/usage/usage.test.ts @@ -0,0 +1,167 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ApiError } from "../../api"; +import { startFolderSize, type ListPage, type WalkDeps } from "../folderSize"; +import type { Entry } from "../useDir"; +import { barPercent, measureUsage, sortRows, startUsage, stopUsage, usageRunning, type UsageSnapshot } from "./usage"; + +const f = (name: string, size: number): Entry => ({ name, kind: "file", size, mtime: 0 }); +const d = (name: string): Entry => ({ name, kind: "dir", size: 0, mtime: 0 }); + +/** A fake tree: path -> entries; pages of `page` entries. */ +function tree(t: Record, opts: { page?: number; fail?: Record; clock?: { t: number; per: number } } = {}) { + const page = opts.page ?? 2; + const calls: string[] = []; + const deps: WalkDeps = { + list: vi.fn(async (path: string, offset: number): Promise => { + calls.push(`${path}@${offset}`); + if (opts.clock) opts.clock.t += opts.clock.per; + if (opts.fail && path in opts.fail) throw opts.fail[path]; + const all = t[path]; + if (!all) throw new ApiError(404, "not_found"); + const entries = all.slice(offset, offset + page); + return { entries, more: offset + page < all.length, next: offset + entries.length }; + }), + now: () => opts.clock?.t ?? 0, + }; + return { deps, calls }; +} + +const TREE: Record = { + "/data": [d("games"), d("music"), f("a.bin", 100), f("b.bin", 50), d("empty")], + "/data/games": [d("A"), f("g.pkg", 5000)], + "/data/games/A": [f("eboot.bin", 3000), f("x", 1)], + "/data/music": [f("m.mp3", 700)], + "/data/empty": [], +}; + +describe("disk usage", () => { + afterEach(async () => { + await stopUsage(); + }); + + it("each subfolder's size (all of its tree) and the files here, with progress", async () => { + const { deps } = tree(TREE); + const updates: UsageSnapshot[] = []; + const s = await measureUsage("/data", { cancelled: () => false, onUpdate: (u) => updates.push(u) }, deps); + expect(s.end).toBe("done"); + const by = Object.fromEntries(s.rows.map((r) => [r.files ? "(files)" : r.name, [r.bytes, r.state]])); + expect(by).toEqual({ + "(files)": [150, "done"], + games: [8001, "done"], + music: [700, "done"], + empty: [0, "done"], + }); + expect(s.folders).toBe(3); + expect(s.measured).toBe(3); + expect(updates.length).toBeGreaterThan(3); + // a row is "walking" while its walk runs + expect(updates.some((u) => u.rows.some((r) => r.name === "games" && r.state === "walking"))).toBe(true); + }); + + it("biggest first; waiting rows after the measured ones; bars against the biggest", () => { + const rows = sortRows([ + { name: "a", path: "/a", files: false, bytes: 5, entries: 1, state: "done" }, + { name: "b", path: "/b", files: false, bytes: 0, entries: 0, state: "waiting" }, + { name: "c", path: "/c", files: false, bytes: 50, entries: 1, state: "done" }, + ]); + expect(rows.map((r) => r.name)).toEqual(["c", "a", "b"]); + expect(barPercent(50, 50)).toBe(100); + expect(barPercent(1, 1000)).toBe(1); // tiny but not invisible + expect(barPercent(0, 1000)).toBe(0); + expect(barPercent(5, 0)).toBe(0); + }); + + it("is bounded for the whole measurement: an entry cap stops it, the rest is not measured", async () => { + const { deps } = tree(TREE); + const s = await measureUsage("/data", { cancelled: () => false, onUpdate: () => undefined, maxEntries: 8 }, deps); + expect(s.end).toBe("capped"); + expect(s.entries).toBeLessThanOrEqual(8); + expect(s.rows.find((r) => r.name === "games")?.state).toBe("partial"); + expect(s.rows.find((r) => r.name === "music")?.state).toBe("skipped"); + }); + + it("and by time", async () => { + const clock = { t: 0, per: 1000 }; + const { deps } = tree(TREE, { clock }); + const s = await measureUsage("/data", { cancelled: () => false, onUpdate: () => undefined, maxMs: 4500 }, deps); + expect(s.end).toBe("timeout"); + expect(s.rows.some((r) => r.state === "skipped")).toBe(true); + }); + + it("subfolders past the folder cap are not listed, and the end says capped", async () => { + const big: Record = { "/x": [d("1"), d("2"), d("3")], "/x/1": [], "/x/2": [], "/x/3": [] }; + const { deps } = tree(big); + const s = await measureUsage("/x", { cancelled: () => false, onUpdate: () => undefined, maxFolders: 2 }, deps); + expect(s.folders).toBe(2); + expect(s.end).toBe("capped"); + }); + + it("Cancel stops before the next page", async () => { + let n = 0; + const { deps, calls } = tree(TREE); + const s = await measureUsage("/data", { cancelled: () => ++n > 4, onUpdate: () => undefined }, deps); + expect(s.end).toBe("cancelled"); + const after = calls.length; + expect(after).toBeLessThan(8); + }); + + it("an unreadable subfolder is marked and skipped; the folder itself failing is an error", async () => { + const { deps } = tree(TREE, { fail: { "/data/music": new ApiError(403, "path_not_allowed") } }); + const s = await measureUsage("/data", { cancelled: () => false, onUpdate: () => undefined }, deps); + expect(s.end).toBe("done"); + expect(s.rows.find((r) => r.name === "music")?.state).toBe("unreadable"); + const bad = tree(TREE, { fail: { "/data": new ApiError(404, "not_found") } }); + const e = await measureUsage("/data", { cancelled: () => false, onUpdate: () => undefined }, bad.deps); + expect(e.end).toBe("error"); + expect(e.error).toBeInstanceOf(ApiError); + }); + + it("a 401 inside stops it (the page must reload)", async () => { + const { deps } = tree(TREE, { fail: { "/data/games": new ApiError(401, "token") } }); + const s = await measureUsage("/data", { cancelled: () => false, onUpdate: () => undefined }, deps); + expect(s.end).toBe("error"); + }); + + it("one at a time: a second start, or a folder-size walk, is refused; stopUsage waits for the end", async () => { + let release: () => void = () => undefined; + const gate = new Promise((r) => { + release = r; + }); + const slow: WalkDeps = { + list: async () => { + await gate; + return { entries: [], more: false, next: 0 }; + }, + now: () => 0, + }; + const run = startUsage("/data", () => undefined, slow); + expect(run).not.toBeNull(); + expect(usageRunning()).toBe(true); + expect(startUsage("/mnt/usb0", () => undefined, slow)).toBeNull(); + const stopping = stopUsage(); + release(); + await stopping; + expect(usageRunning()).toBe(false); + expect((await run?.done)?.end).toBe("cancelled"); + + // a "Calculate folder size" walk running blocks a measurement too + let release2: () => void = () => undefined; + const gate2 = new Promise((r) => { + release2 = r; + }); + const walk = startFolderSize("/data", () => undefined, { + list: async () => { + await gate2; + return { entries: [], more: false }; + }, + now: () => 0, + }); + expect(startUsage("/data", () => undefined, slow)).toBeNull(); + walk?.cancel(); + release2(); + await walk?.done; + const again = startUsage("/data", () => undefined, slow); + expect(again).not.toBeNull(); + await again?.done; + }); +}); diff --git a/client/src/console/files/usage/usage.ts b/client/src/console/files/usage/usage.ts new file mode 100644 index 00000000..70308a04 --- /dev/null +++ b/client/src/console/files/usage/usage.ts @@ -0,0 +1,217 @@ +/** + * Disk usage: how much each subfolder of a folder holds, walked with + * /api/fs/list paging (folderSize.ts's walker, one subfolder after another). + * Bounded for the whole measurement (USAGE_MAX_ENTRIES entries, USAGE_MAX_MS), + * cancellable between pages, and one at a time page-wide: it refuses to start + * while another measurement or a "Calculate folder size" runs. + */ +import { ApiError, call } from "../../api"; +import { folderSizeRunning, walkFolder, type ListPage, type WalkDeps } from "../folderSize"; +import { joinPath } from "../move"; +import { LIST_PAGE } from "../useDir"; + +export const USAGE_MAX_ENTRIES = 500_000; +export const USAGE_MAX_MS = 180_000; +/** Subfolders measured at most (the rest are listed as not measured). */ +export const USAGE_MAX_FOLDERS = 2000; + +export type RowState = "waiting" | "walking" | "done" | "partial" | "unreadable" | "skipped"; + +export interface UsageRow { + /** A subfolder's name; "" for the files directly in the folder. */ + name: string; + path: string; + files: boolean; + bytes: number; + /** Entries counted inside it. */ + entries: number; + state: RowState; +} + +/** How a measurement ended (null while it runs). */ +export type UsageEnd = "done" | "capped" | "timeout" | "cancelled" | "error" | null; + +export interface UsageSnapshot { + root: string; + rows: UsageRow[]; + /** Subfolders measured so far, and how many there are. */ + measured: number; + folders: number; + entries: number; + end: UsageEnd; + error?: unknown; +} + +export interface UsageLimits { + maxEntries?: number; + maxMs?: number; + maxFolders?: number; +} + +const defaultDeps: WalkDeps = { + list: (path, offset, limit) => call("/api/fs/list", { path, offset, limit }), + now: () => Date.now(), +}; + +/** Biggest first; rows still waiting (or skipped) after the measured ones, by name. */ +export function sortRows(rows: UsageRow[]): UsageRow[] { + const rank = (r: UsageRow) => (r.state === "waiting" || r.state === "skipped" ? 1 : 0); + return rows.slice().sort((a, b) => rank(a) - rank(b) || b.bytes - a.bytes || a.name.localeCompare(b.name)); +} + +/** A row's bar, 0-100, against the biggest row. */ +export function barPercent(bytes: number, max: number): number { + if (!(max > 0) || !(bytes > 0)) return 0; + return Math.max(1, Math.round((bytes / max) * 100)); +} + +/** Measure `root` (see the module comment). Never throws: a failure of `root` itself is end "error". */ +export async function measureUsage( + root: string, + opts: { cancelled: () => boolean; onUpdate: (s: UsageSnapshot) => void } & UsageLimits, + deps: WalkDeps = defaultDeps, +): Promise { + const maxEntries = opts.maxEntries ?? USAGE_MAX_ENTRIES; + const maxMs = opts.maxMs ?? USAGE_MAX_MS; + const maxFolders = opts.maxFolders ?? USAGE_MAX_FOLDERS; + const started = deps.now(); + const here: UsageRow = { name: "", path: root, files: true, bytes: 0, entries: 0, state: "walking" }; + const subs: UsageRow[] = []; + let entries = 0; + const snap = (end: UsageEnd, error?: unknown): UsageSnapshot => { + const s: UsageSnapshot = { + root, + rows: [here, ...subs].map((r) => ({ ...r })), + measured: subs.filter((r) => r.state !== "waiting" && r.state !== "walking").length, + folders: subs.length, + entries, + end, + }; + if (error !== undefined) s.error = error; + return s; + }; + const finish = (end: Exclude, error?: unknown) => { + for (const r of subs) if (r.state === "waiting" || r.state === "walking") r.state = "skipped"; + if (here.state === "walking") here.state = end === "done" ? "done" : "partial"; + const s = snap(end, error); + opts.onUpdate(s); + return s; + }; + + // 1. The folder itself: its files add up here, its subfolders become rows. + let offset = 0; + let extra = 0; + for (;;) { + if (opts.cancelled()) return finish("cancelled"); + if (deps.now() - started > maxMs) return finish("timeout"); + let page: ListPage; + try { + page = await deps.list(root, offset, LIST_PAGE); + } catch (e) { + return finish("error", e); + } + if (opts.cancelled()) return finish("cancelled"); + const list = Array.isArray(page?.entries) ? page.entries : []; + for (const e of list) { + entries++; + if (e.kind === "dir") { + if (subs.length < maxFolders) { + subs.push({ name: e.name, path: joinPath(root, e.name), files: false, bytes: 0, entries: 0, state: "waiting" }); + } else extra++; + } else if (e.kind === "file" && typeof e.size === "number" && e.size > 0) { + here.bytes += e.size; + here.entries++; + } else here.entries++; + if (entries >= maxEntries) return finish("capped"); + } + const next = typeof page?.next === "number" ? page.next : offset + list.length; + if (!page?.more || next <= offset) break; + offset = next; + } + here.state = extra ? "partial" : "done"; + opts.onUpdate(snap(null)); + + // 2. Each subfolder, one after another, out of what is left of the budget. + for (const row of subs) { + if (opts.cancelled()) return finish("cancelled"); + const left = maxEntries - entries; + const msLeft = maxMs - (deps.now() - started); + if (left <= 0) return finish("capped"); + if (msLeft <= 0) return finish("timeout"); + row.state = "walking"; + opts.onUpdate(snap(null)); + const r = await walkFolder( + row.path, + { + cancelled: opts.cancelled, + maxEntries: left, + maxMs: msLeft, + onProgress: (c) => { + row.bytes = c.bytes; + row.entries = c.entries; + opts.onUpdate(snap(null)); + }, + }, + deps, + ); + row.bytes = r.bytes; + row.entries = r.entries; + entries += r.entries; + if (r.end === "error") { + // The token is gone: stop; another failure of this one folder: it is unreadable, go on. + if (r.error instanceof ApiError && r.error.status === 401) return finish("error", r.error); + row.state = "unreadable"; + continue; + } + if (r.end === "cancelled") { + row.state = "partial"; + return finish("cancelled"); + } + if (r.end === "capped" || r.end === "timeout") { + row.state = "partial"; + return finish(r.end); + } + row.state = r.unreadable ? "partial" : "done"; + opts.onUpdate(snap(null)); + } + return finish(extra ? "capped" : "done"); +} + +/* ---- one at a time ---- */ + +let running: { cancel: () => void; done: Promise } | null = null; + +/** A disk usage measurement runs now. */ +export function usageRunning(): boolean { + return running != null; +} + +/** + * Start the page's one measurement of `root`: null while another (or a folder + * size walk) runs. `cancel()` stops it before its next page. + */ +export function startUsage( + root: string, + onUpdate: (s: UsageSnapshot) => void, + deps?: WalkDeps, + limits?: UsageLimits, +): { done: Promise; cancel: () => void } | null { + if (running || folderSizeRunning()) return null; + let cancelled = false; + const cancel = () => { + cancelled = true; + }; + const done = measureUsage(root, { cancelled: () => cancelled, onUpdate, ...limits }, deps).finally(() => { + if (running?.done === done) running = null; + }); + running = { cancel, done }; + return { done, cancel }; +} + +/** Stop the running measurement (if any) and wait until it has ended. */ +export async function stopUsage(): Promise { + const r = running; + if (!r) return; + r.cancel(); + await r.done.catch(() => undefined); +} diff --git a/client/src/console/locales/ar.json b/client/src/console/locales/ar.json index 68edc8ec..a7656f7f 100644 --- a/client/src/console/locales/ar.json +++ b/client/src/console/locales/ar.json @@ -188,6 +188,29 @@ "cw_files_replace_failed": "تعذّر حذف العنصر المراد استبداله، لذلك لم يُلصق.", "cw_files_retry": "إعادة المحاولة", "cw_files_rows_more": "عرض المزيد من الصفوف (تبقى {n})", + "cw_files_search": "بحث", + "cw_files_search_all": "كل الأقراص", + "cw_files_search_bad": "لا يمكن البحث عن علامات الاقتباس والشرطات المائلة العكسية.", + "cw_files_search_building": "جارٍ إنشاء الفهرس… {n} ملف حتى الآن.", + "cw_files_search_cancel": "إلغاء", + "cw_files_search_capped": "امتلأ (200,000 ملف)، لذا تنقصه بعض الملفات.", + "cw_files_search_field": "الاسم أو النمط", + "cw_files_search_found": "عُثر على {n}.", + "cw_files_search_go": "بحث", + "cw_files_search_here": "هذا المجلد", + "cw_files_search_hint": "يبحث عن الملفات بالاسم. تطابق الكلمة أي جزء من الاسم؛ استخدم * و ? للنمط (*.pkg).", + "cw_files_search_more": "تُعرض أول {n} نتيجة. هناك المزيد: جرّب اسمًا أطول.", + "cw_files_search_no_index": "لا يوجد فهرس بعد. أول بحث ينشئ فهرسًا: يقرأ كل مجلد وقد يستغرق بضع دقائق.", + "cw_files_search_none": "لا شيء يطابق «{q}».", + "cw_files_search_partial": "أُوقف مبكرًا، لذا تنقصه بعض الملفات.", + "cw_files_search_ready": "الفهرس: {n} ملف.", + "cw_files_search_rebuild": "إنشاء من جديد", + "cw_files_search_results": "نتائج البحث", + "cw_files_search_scope": "مكان البحث", + "cw_files_search_searching": "جارٍ البحث…", + "cw_files_search_stopping": "جارٍ إيقاف الفهرس…", + "cw_files_search_unsupported": "إصدار PS5Upload على هذا الجهاز قديم جدًا للبحث. حدّثه لاستخدام البحث.", + "cw_files_search_waiting": "سيبحث عن «{q}» عندما يجهز الفهرس.", "cw_files_select_all": "تحديد الكل", "cw_files_select_invert": "عكس التحديد", "cw_files_select_none": "إلغاء التحديد", @@ -251,6 +274,20 @@ "cw_files_unpin": "إلغاء التثبيت", "cw_files_unpin_done": "تم إلغاء تثبيت {name}.", "cw_files_up": "لأعلى", + "cw_files_usage": "استخدام القرص", + "cw_files_usage_again": "القياس مجددًا", + "cw_files_usage_busy": "يجري قياس حجم مجلد آخر. أعد المحاولة عند انتهائه.", + "cw_files_usage_cancelled": "أُلغي: حُسب {size}، ولم يُقس الباقي.", + "cw_files_usage_capped": "توقف عند {items} عنصر: حُسب {size}، والمجلد يحوي المزيد.", + "cw_files_usage_done": "{size} في {items} عنصر.", + "cw_files_usage_files_here": "الملفات في هذا المجلد", + "cw_files_usage_inside": "عرض محتوى {name}", + "cw_files_usage_open": "فتح في الملفات", + "cw_files_usage_open_row": "فتح {name} في الملفات", + "cw_files_usage_running": "جارٍ القياس… {at} من {n} مجلد، {items} عنصر.", + "cw_files_usage_skipped": "لم يُقس", + "cw_files_usage_timeout": "توقف بعد 3 دقائق: حُسب {size}، ولم يُقس الباقي.", + "cw_files_usage_unreadable": "غير قابل للقراءة", "cw_files_verify": "تحقق (مجموع اختباري)", "cw_files_verify_blake3": "BLAKE3", "cw_files_verify_cancelled": "أُلغي.", diff --git a/client/src/console/locales/bn.json b/client/src/console/locales/bn.json index e993728e..10fc7d60 100644 --- a/client/src/console/locales/bn.json +++ b/client/src/console/locales/bn.json @@ -188,6 +188,29 @@ "cw_files_replace_failed": "প্রতিস্থাপনের আইটেমটি মোছা যায়নি, তাই পেস্ট করা হয়নি।", "cw_files_retry": "আবার চেষ্টা করুন", "cw_files_rows_more": "আরও সারি দেখান ({n}টি বাকি)", + "cw_files_search": "খুঁজুন", + "cw_files_search_all": "সব ড্রাইভ", + "cw_files_search_bad": "উদ্ধৃতিচিহ্ন ও ব্যাকস্ল্যাশ খোঁজা যায় না।", + "cw_files_search_building": "ইনডেক্স তৈরি হচ্ছে… এখন পর্যন্ত {n}টি ফাইল।", + "cw_files_search_cancel": "বাতিল", + "cw_files_search_capped": "এটি পূর্ণ (200,000টি ফাইল), তাই কিছু ফাইল নেই।", + "cw_files_search_field": "নাম বা প্যাটার্ন", + "cw_files_search_found": "{n}টি পাওয়া গেছে।", + "cw_files_search_go": "খুঁজুন", + "cw_files_search_here": "এই ফোল্ডার", + "cw_files_search_hint": "নাম ধরে ফাইল খোঁজে। একটি শব্দ নামের যেকোনো জায়গায় মিলে যায়; প্যাটার্নের জন্য * ও ? ব্যবহার করুন (*.pkg)।", + "cw_files_search_more": "প্রথম {n}টি দেখানো হচ্ছে। আরও আছে: লম্বা নাম দিয়ে চেষ্টা করুন।", + "cw_files_search_no_index": "এখনও কোনো ইনডেক্স নেই। প্রথম খোঁজ একটি তৈরি করে: এটি প্রতিটি ফোল্ডার পড়ে এবং কয়েক মিনিট লাগতে পারে।", + "cw_files_search_none": "“{q}”-এর সাথে কিছু মেলে না।", + "cw_files_search_partial": "এটি আগেই থামানো হয়েছিল, তাই কিছু ফাইল নেই।", + "cw_files_search_ready": "ইনডেক্স: {n}টি ফাইল।", + "cw_files_search_rebuild": "আবার তৈরি করুন", + "cw_files_search_results": "খোঁজের ফলাফল", + "cw_files_search_scope": "কোথায় খুঁজবেন", + "cw_files_search_searching": "খোঁজা হচ্ছে…", + "cw_files_search_stopping": "ইনডেক্স থামানো হচ্ছে…", + "cw_files_search_unsupported": "এই কনসোলের PS5Upload খোঁজার জন্য খুব পুরনো। খোঁজ ব্যবহার করতে এটি আপডেট করুন।", + "cw_files_search_waiting": "ইনডেক্স তৈরি হলে “{q}” খুঁজবে।", "cw_files_select_all": "সব বাছুন", "cw_files_select_invert": "উল্টে দিন", "cw_files_select_none": "বাছাই সরান", @@ -251,6 +274,20 @@ "cw_files_unpin": "পিন সরান", "cw_files_unpin_done": "{name}-এর পিন সরানো হয়েছে।", "cw_files_up": "উপরে", + "cw_files_usage": "ডিস্ক ব্যবহার", + "cw_files_usage_again": "আবার মাপুন", + "cw_files_usage_busy": "অন্য একটি ফোল্ডারের আকার মাপা হচ্ছে। সেটি শেষ হলে আবার চেষ্টা করুন।", + "cw_files_usage_cancelled": "বাতিল হয়েছে: {size} গোনা হয়েছে, বাকিটা মাপা হয়নি।", + "cw_files_usage_capped": "{items}টি আইটেমে থেমেছে: {size} গোনা হয়েছে, ফোল্ডারে আরও আছে।", + "cw_files_usage_done": "{items}টি আইটেমে {size}।", + "cw_files_usage_files_here": "এই ফোল্ডারের ফাইল", + "cw_files_usage_inside": "{name}-এর ভেতরে দেখুন", + "cw_files_usage_open": "ফাইল-এ খুলুন", + "cw_files_usage_open_row": "{name} ফাইল-এ খুলুন", + "cw_files_usage_running": "মাপা হচ্ছে… {n}টি ফোল্ডারের মধ্যে {at}, {items}টি আইটেম।", + "cw_files_usage_skipped": "মাপা হয়নি", + "cw_files_usage_timeout": "৩ মিনিট পর থেমেছে: {size} গোনা হয়েছে, বাকিটা মাপা হয়নি।", + "cw_files_usage_unreadable": "পড়া যায় না", "cw_files_verify": "যাচাই (চেকসাম)", "cw_files_verify_blake3": "BLAKE3", "cw_files_verify_cancelled": "বাতিল করা হয়েছে।", diff --git a/client/src/console/locales/de.json b/client/src/console/locales/de.json index aaf47c6e..7b60ef56 100644 --- a/client/src/console/locales/de.json +++ b/client/src/console/locales/de.json @@ -188,6 +188,29 @@ "cw_files_replace_failed": "Das zu ersetzende Element ließ sich nicht löschen, daher wurde nichts eingefügt.", "cw_files_retry": "Erneut versuchen", "cw_files_rows_more": "Mehr Zeilen anzeigen ({n} übrig)", + "cw_files_search": "Suchen", + "cw_files_search_all": "Alle Laufwerke", + "cw_files_search_bad": "Nach Anführungszeichen und Backslashes kann nicht gesucht werden.", + "cw_files_search_building": "Index wird erstellt… bisher {n} Dateien.", + "cw_files_search_cancel": "Abbrechen", + "cw_files_search_capped": "Er ist voll (200.000 Dateien), daher fehlen einige Dateien.", + "cw_files_search_field": "Name oder Muster", + "cw_files_search_found": "{n} gefunden.", + "cw_files_search_go": "Suchen", + "cw_files_search_here": "Dieser Ordner", + "cw_files_search_hint": "Findet Dateien nach Namen. Ein Wort passt überall im Namen; mit * und ? als Muster (*.pkg).", + "cw_files_search_more": "Die ersten {n} werden angezeigt. Es gibt mehr: Versuche einen längeren Namen.", + "cw_files_search_no_index": "Noch kein Index. Die erste Suche erstellt einen: Sie liest jeden Ordner und kann einige Minuten dauern.", + "cw_files_search_none": "Nichts passt zu „{q}“.", + "cw_files_search_partial": "Er wurde vorzeitig gestoppt, daher fehlen einige Dateien.", + "cw_files_search_ready": "Index: {n} Dateien.", + "cw_files_search_rebuild": "Neu erstellen", + "cw_files_search_results": "Suchergebnisse", + "cw_files_search_scope": "Wo suchen", + "cw_files_search_searching": "Suche…", + "cw_files_search_stopping": "Index wird gestoppt…", + "cw_files_search_unsupported": "PS5Upload auf dieser Konsole ist zu alt für die Suche. Aktualisiere es, um zu suchen.", + "cw_files_search_waiting": "Sucht nach „{q}“, sobald der Index fertig ist.", "cw_files_select_all": "Alle auswählen", "cw_files_select_invert": "Umkehren", "cw_files_select_none": "Auswahl aufheben", @@ -251,6 +274,20 @@ "cw_files_unpin": "Lösen", "cw_files_unpin_done": "{name} gelöst.", "cw_files_up": "Nach oben", + "cw_files_usage": "Speicherbelegung", + "cw_files_usage_again": "Erneut messen", + "cw_files_usage_busy": "Gerade wird eine andere Ordnergröße gemessen. Versuche es danach erneut.", + "cw_files_usage_cancelled": "Abgebrochen: {size} gezählt, der Rest wurde nicht gemessen.", + "cw_files_usage_capped": "Bei {items} Elementen gestoppt: {size} gezählt, der Ordner enthält mehr.", + "cw_files_usage_done": "{size} in {items} Elementen.", + "cw_files_usage_files_here": "Dateien in diesem Ordner", + "cw_files_usage_inside": "In {name} hineinsehen", + "cw_files_usage_open": "In Dateien öffnen", + "cw_files_usage_open_row": "{name} in Dateien öffnen", + "cw_files_usage_running": "Messe… {at} von {n} Ordnern, {items} Elemente.", + "cw_files_usage_skipped": "nicht gemessen", + "cw_files_usage_timeout": "Nach 3 Minuten gestoppt: {size} gezählt, der Rest wurde nicht gemessen.", + "cw_files_usage_unreadable": "nicht lesbar", "cw_files_verify": "Prüfen (Prüfsumme)", "cw_files_verify_blake3": "BLAKE3", "cw_files_verify_cancelled": "Abgebrochen.", diff --git a/client/src/console/locales/en.json b/client/src/console/locales/en.json index 5e8b4173..9e56c475 100644 --- a/client/src/console/locales/en.json +++ b/client/src/console/locales/en.json @@ -188,6 +188,29 @@ "cw_files_replace_failed": "Couldn't delete the item being replaced, so it was not pasted.", "cw_files_retry": "Try again", "cw_files_rows_more": "Show more rows ({n} left)", + "cw_files_search": "Search", + "cw_files_search_all": "All drives", + "cw_files_search_bad": "Quotes and backslashes cannot be searched for.", + "cw_files_search_building": "Building the index… {n} files so far.", + "cw_files_search_cancel": "Cancel", + "cw_files_search_capped": "It is full (200,000 files), so some files are missing.", + "cw_files_search_field": "Name or pattern", + "cw_files_search_found": "{n} found.", + "cw_files_search_go": "Search", + "cw_files_search_here": "This folder", + "cw_files_search_hint": "Finds files by name. A word matches anywhere in the name; use * and ? for a pattern (*.pkg).", + "cw_files_search_more": "Showing the first {n}. There are more: try a longer name.", + "cw_files_search_no_index": "No index yet. The first search builds one: it reads every folder and can take a few minutes.", + "cw_files_search_none": "Nothing matches “{q}”.", + "cw_files_search_partial": "It was stopped early, so some files are missing.", + "cw_files_search_ready": "Index: {n} files.", + "cw_files_search_rebuild": "Build again", + "cw_files_search_results": "Search results", + "cw_files_search_scope": "Where to search", + "cw_files_search_searching": "Searching…", + "cw_files_search_stopping": "Stopping the index…", + "cw_files_search_unsupported": "PS5Upload on this console is too old to search. Update it to use search.", + "cw_files_search_waiting": "Searches for “{q}” when the index is ready.", "cw_files_select_all": "Select all", "cw_files_select_invert": "Invert", "cw_files_select_none": "Select none", @@ -251,6 +274,20 @@ "cw_files_unpin": "Unpin", "cw_files_unpin_done": "Unpinned {name}.", "cw_files_up": "Up", + "cw_files_usage": "Disk usage", + "cw_files_usage_again": "Measure again", + "cw_files_usage_busy": "Another folder size is being measured. Try again when it ends.", + "cw_files_usage_cancelled": "Cancelled: {size} counted, the rest was not measured.", + "cw_files_usage_capped": "Stopped at {items} items: {size} counted, the folder holds more.", + "cw_files_usage_done": "{size} in {items} items.", + "cw_files_usage_files_here": "Files in this folder", + "cw_files_usage_inside": "Look inside {name}", + "cw_files_usage_open": "Open in Files", + "cw_files_usage_open_row": "Open {name} in Files", + "cw_files_usage_running": "Measuring… {at} of {n} folders, {items} items.", + "cw_files_usage_skipped": "not measured", + "cw_files_usage_timeout": "Stopped after 3 minutes: {size} counted, the rest was not measured.", + "cw_files_usage_unreadable": "unreadable", "cw_files_verify": "Verify (checksum)", "cw_files_verify_blake3": "BLAKE3", "cw_files_verify_cancelled": "Cancelled.", diff --git a/client/src/console/locales/es.json b/client/src/console/locales/es.json index 722fc11c..f3d9a749 100644 --- a/client/src/console/locales/es.json +++ b/client/src/console/locales/es.json @@ -188,6 +188,29 @@ "cw_files_replace_failed": "No se pudo eliminar el elemento a reemplazar, así que no se pegó.", "cw_files_retry": "Reintentar", "cw_files_rows_more": "Mostrar más filas (quedan {n})", + "cw_files_search": "Buscar", + "cw_files_search_all": "Todas las unidades", + "cw_files_search_bad": "No se pueden buscar comillas ni barras invertidas.", + "cw_files_search_building": "Creando el índice… {n} archivos hasta ahora.", + "cw_files_search_cancel": "Cancelar", + "cw_files_search_capped": "Está lleno (200.000 archivos), así que faltan algunos archivos.", + "cw_files_search_field": "Nombre o patrón", + "cw_files_search_found": "{n} encontrados.", + "cw_files_search_go": "Buscar", + "cw_files_search_here": "Esta carpeta", + "cw_files_search_hint": "Busca archivos por nombre. Una palabra coincide en cualquier parte del nombre; usa * y ? para un patrón (*.pkg).", + "cw_files_search_more": "Se muestran los primeros {n}. Hay más: prueba con un nombre más largo.", + "cw_files_search_no_index": "Aún no hay índice. La primera búsqueda crea uno: lee todas las carpetas y puede tardar unos minutos.", + "cw_files_search_none": "Nada coincide con «{q}».", + "cw_files_search_partial": "Se detuvo antes de tiempo, así que faltan algunos archivos.", + "cw_files_search_ready": "Índice: {n} archivos.", + "cw_files_search_rebuild": "Crear de nuevo", + "cw_files_search_results": "Resultados de la búsqueda", + "cw_files_search_scope": "Dónde buscar", + "cw_files_search_searching": "Buscando…", + "cw_files_search_stopping": "Deteniendo el índice…", + "cw_files_search_unsupported": "PS5Upload en esta consola es demasiado antiguo para buscar. Actualízalo para usar la búsqueda.", + "cw_files_search_waiting": "Buscará «{q}» cuando el índice esté listo.", "cw_files_select_all": "Seleccionar todo", "cw_files_select_invert": "Invertir", "cw_files_select_none": "Quitar selección", @@ -251,6 +274,20 @@ "cw_files_unpin": "Quitar fijación", "cw_files_unpin_done": "{name} ya no está fijada.", "cw_files_up": "Subir", + "cw_files_usage": "Uso del disco", + "cw_files_usage_again": "Medir de nuevo", + "cw_files_usage_busy": "Se está midiendo el tamaño de otra carpeta. Vuelve a intentarlo cuando termine.", + "cw_files_usage_cancelled": "Cancelado: {size} contados, el resto no se midió.", + "cw_files_usage_capped": "Detenido en {items} elementos: {size} contados, la carpeta contiene más.", + "cw_files_usage_done": "{size} en {items} elementos.", + "cw_files_usage_files_here": "Archivos de esta carpeta", + "cw_files_usage_inside": "Ver dentro de {name}", + "cw_files_usage_open": "Abrir en Archivos", + "cw_files_usage_open_row": "Abrir {name} en Archivos", + "cw_files_usage_running": "Midiendo… {at} de {n} carpetas, {items} elementos.", + "cw_files_usage_skipped": "sin medir", + "cw_files_usage_timeout": "Detenido tras 3 minutos: {size} contados, el resto no se midió.", + "cw_files_usage_unreadable": "ilegible", "cw_files_verify": "Verificar (suma de comprobación)", "cw_files_verify_blake3": "BLAKE3", "cw_files_verify_cancelled": "Cancelado.", diff --git a/client/src/console/locales/fa.json b/client/src/console/locales/fa.json index 263a34ba..f5bd9751 100644 --- a/client/src/console/locales/fa.json +++ b/client/src/console/locales/fa.json @@ -188,6 +188,29 @@ "cw_files_replace_failed": "حذف مورد جایگزین‌شونده ممکن نشد، پس چسبانده نشد.", "cw_files_retry": "تلاش دوباره", "cw_files_rows_more": "نمایش ردیف‌های بیشتر ({n} مانده)", + "cw_files_search": "جستجو", + "cw_files_search_all": "همهٔ درایوها", + "cw_files_search_bad": "نمی‌توان نقل‌قول و بک‌اسلش را جستجو کرد.", + "cw_files_search_building": "در حال ساخت نمایه… تاکنون {n} فایل.", + "cw_files_search_cancel": "لغو", + "cw_files_search_capped": "پر شده است (۲۰۰٬۰۰۰ فایل)، پس برخی فایل‌ها در آن نیستند.", + "cw_files_search_field": "نام یا الگو", + "cw_files_search_found": "{n} مورد پیدا شد.", + "cw_files_search_go": "جستجو", + "cw_files_search_here": "همین پوشه", + "cw_files_search_hint": "فایل‌ها را بر اساس نام پیدا می‌کند. یک واژه با هر بخشی از نام جور می‌شود؛ برای الگو از * و ? استفاده کنید (*.pkg).", + "cw_files_search_more": "{n} مورد نخست نمایش داده می‌شود. موارد بیشتری هست: نام بلندتری امتحان کنید.", + "cw_files_search_no_index": "هنوز نمایه‌ای نیست. نخستین جستجو آن را می‌سازد: همهٔ پوشه‌ها را می‌خواند و ممکن است چند دقیقه طول بکشد.", + "cw_files_search_none": "چیزی با «{q}» جور نیست.", + "cw_files_search_partial": "زودتر متوقف شد، پس برخی فایل‌ها در آن نیستند.", + "cw_files_search_ready": "نمایه: {n} فایل.", + "cw_files_search_rebuild": "ساخت دوباره", + "cw_files_search_results": "نتایج جستجو", + "cw_files_search_scope": "محل جستجو", + "cw_files_search_searching": "در حال جستجو…", + "cw_files_search_stopping": "در حال توقف نمایه…", + "cw_files_search_unsupported": "PS5Upload روی این کنسول برای جستجو قدیمی است. برای جستجو آن را به‌روز کنید.", + "cw_files_search_waiting": "وقتی نمایه آماده شد «{q}» را جستجو می‌کند.", "cw_files_select_all": "انتخاب همه", "cw_files_select_invert": "معکوس کردن", "cw_files_select_none": "لغو انتخاب", @@ -251,6 +274,20 @@ "cw_files_unpin": "برداشتن سنجاق", "cw_files_unpin_done": "سنجاق {name} برداشته شد.", "cw_files_up": "بالا", + "cw_files_usage": "فضای اشغال‌شده", + "cw_files_usage_again": "اندازه‌گیری دوباره", + "cw_files_usage_busy": "اندازهٔ پوشهٔ دیگری در حال اندازه‌گیری است. پس از پایان آن دوباره امتحان کنید.", + "cw_files_usage_cancelled": "لغو شد: {size} شمرده شد، بقیه اندازه‌گیری نشد.", + "cw_files_usage_capped": "در {items} مورد متوقف شد: {size} شمرده شد، پوشه بیشتر دارد.", + "cw_files_usage_done": "{size} در {items} مورد.", + "cw_files_usage_files_here": "فایل‌های این پوشه", + "cw_files_usage_inside": "دیدن درون {name}", + "cw_files_usage_open": "باز کردن در فایل‌ها", + "cw_files_usage_open_row": "باز کردن {name} در فایل‌ها", + "cw_files_usage_running": "در حال اندازه‌گیری… {at} از {n} پوشه، {items} مورد.", + "cw_files_usage_skipped": "اندازه‌گیری نشده", + "cw_files_usage_timeout": "پس از ۳ دقیقه متوقف شد: {size} شمرده شد، بقیه اندازه‌گیری نشد.", + "cw_files_usage_unreadable": "خواندنی نیست", "cw_files_verify": "بررسی (چک‌سام)", "cw_files_verify_blake3": "BLAKE3", "cw_files_verify_cancelled": "لغو شد.", diff --git a/client/src/console/locales/fr.json b/client/src/console/locales/fr.json index 8d5950c5..d7a8f38d 100644 --- a/client/src/console/locales/fr.json +++ b/client/src/console/locales/fr.json @@ -188,6 +188,29 @@ "cw_files_replace_failed": "Impossible de supprimer l’élément à remplacer : rien n’a été collé.", "cw_files_retry": "Réessayer", "cw_files_rows_more": "Afficher plus de lignes ({n} restantes)", + "cw_files_search": "Rechercher", + "cw_files_search_all": "Tous les disques", + "cw_files_search_bad": "Impossible de rechercher des guillemets ou des barres obliques inverses.", + "cw_files_search_building": "Création de l’index… {n} fichiers jusqu’ici.", + "cw_files_search_cancel": "Annuler", + "cw_files_search_capped": "Il est plein (200 000 fichiers) ; il manque donc des fichiers.", + "cw_files_search_field": "Nom ou motif", + "cw_files_search_found": "{n} trouvés.", + "cw_files_search_go": "Rechercher", + "cw_files_search_here": "Ce dossier", + "cw_files_search_hint": "Trouve des fichiers par leur nom. Un mot correspond n’importe où dans le nom ; utilisez * et ? pour un motif (*.pkg).", + "cw_files_search_more": "Les {n} premiers sont affichés. Il y en a d’autres : essayez un nom plus long.", + "cw_files_search_no_index": "Pas encore d’index. La première recherche en crée un : elle lit chaque dossier et peut prendre quelques minutes.", + "cw_files_search_none": "Rien ne correspond à « {q} ».", + "cw_files_search_partial": "Il a été arrêté avant la fin ; il manque donc des fichiers.", + "cw_files_search_ready": "Index : {n} fichiers.", + "cw_files_search_rebuild": "Recréer", + "cw_files_search_results": "Résultats de la recherche", + "cw_files_search_scope": "Où chercher", + "cw_files_search_searching": "Recherche…", + "cw_files_search_stopping": "Arrêt de l’index…", + "cw_files_search_unsupported": "PS5Upload sur cette console est trop ancien pour rechercher. Mettez-le à jour pour utiliser la recherche.", + "cw_files_search_waiting": "Recherchera « {q} » quand l’index sera prêt.", "cw_files_select_all": "Tout sélectionner", "cw_files_select_invert": "Inverser", "cw_files_select_none": "Tout désélectionner", @@ -251,6 +274,20 @@ "cw_files_unpin": "Désépingler", "cw_files_unpin_done": "{name} désépinglé.", "cw_files_up": "Remonter", + "cw_files_usage": "Utilisation du disque", + "cw_files_usage_again": "Mesurer à nouveau", + "cw_files_usage_busy": "La taille d’un autre dossier est en cours de mesure. Réessayez quand elle sera terminée.", + "cw_files_usage_cancelled": "Annulé : {size} comptés, le reste n’a pas été mesuré.", + "cw_files_usage_capped": "Arrêté à {items} éléments : {size} comptés, le dossier en contient davantage.", + "cw_files_usage_done": "{size} en {items} éléments.", + "cw_files_usage_files_here": "Fichiers de ce dossier", + "cw_files_usage_inside": "Voir le contenu de {name}", + "cw_files_usage_open": "Ouvrir dans Fichiers", + "cw_files_usage_open_row": "Ouvrir {name} dans Fichiers", + "cw_files_usage_running": "Mesure… {at} sur {n} dossiers, {items} éléments.", + "cw_files_usage_skipped": "non mesuré", + "cw_files_usage_timeout": "Arrêté après 3 minutes : {size} comptés, le reste n’a pas été mesuré.", + "cw_files_usage_unreadable": "illisible", "cw_files_verify": "Vérifier (somme de contrôle)", "cw_files_verify_blake3": "BLAKE3", "cw_files_verify_cancelled": "Annulé.", diff --git a/client/src/console/locales/hi.json b/client/src/console/locales/hi.json index 71fbc546..eecb816f 100644 --- a/client/src/console/locales/hi.json +++ b/client/src/console/locales/hi.json @@ -188,6 +188,29 @@ "cw_files_replace_failed": "बदला जाने वाला आइटम हटाया नहीं जा सका, इसलिए पेस्ट नहीं किया गया।", "cw_files_retry": "फिर से कोशिश करें", "cw_files_rows_more": "और पंक्तियाँ दिखाएँ ({n} बाकी)", + "cw_files_search": "खोजें", + "cw_files_search_all": "सभी ड्राइव", + "cw_files_search_bad": "उद्धरण चिह्न और बैकस्लैश नहीं खोजे जा सकते।", + "cw_files_search_building": "इंडेक्स बन रहा है… अब तक {n} फ़ाइलें।", + "cw_files_search_cancel": "रद्द करें", + "cw_files_search_capped": "यह भर गया है (200,000 फ़ाइलें), इसलिए कुछ फ़ाइलें नहीं हैं।", + "cw_files_search_field": "नाम या पैटर्न", + "cw_files_search_found": "{n} मिले।", + "cw_files_search_go": "खोजें", + "cw_files_search_here": "यह फ़ोल्डर", + "cw_files_search_hint": "नाम से फ़ाइलें ढूँढता है। कोई शब्द नाम में कहीं भी मेल खाता है; पैटर्न के लिए * और ? का उपयोग करें (*.pkg)।", + "cw_files_search_more": "पहले {n} दिखाए जा रहे हैं। और भी हैं: लंबा नाम आज़माएँ।", + "cw_files_search_no_index": "अभी कोई इंडेक्स नहीं है। पहली खोज इसे बनाती है: यह हर फ़ोल्डर पढ़ती है और कुछ मिनट लग सकते हैं।", + "cw_files_search_none": "“{q}” से कुछ मेल नहीं खाता।", + "cw_files_search_partial": "इसे पहले ही रोक दिया गया, इसलिए कुछ फ़ाइलें नहीं हैं।", + "cw_files_search_ready": "इंडेक्स: {n} फ़ाइलें।", + "cw_files_search_rebuild": "फिर से बनाएँ", + "cw_files_search_results": "खोज परिणाम", + "cw_files_search_scope": "कहाँ खोजें", + "cw_files_search_searching": "खोज रहा है…", + "cw_files_search_stopping": "इंडेक्स रोका जा रहा है…", + "cw_files_search_unsupported": "इस कंसोल पर PS5Upload खोज के लिए बहुत पुराना है। खोज इस्तेमाल करने के लिए इसे अपडेट करें।", + "cw_files_search_waiting": "इंडेक्स तैयार होने पर “{q}” खोजेगा।", "cw_files_select_all": "सब चुनें", "cw_files_select_invert": "चयन उलटें", "cw_files_select_none": "चयन हटाएँ", @@ -251,6 +274,20 @@ "cw_files_unpin": "पिन हटाएँ", "cw_files_unpin_done": "{name} का पिन हटाया गया।", "cw_files_up": "ऊपर", + "cw_files_usage": "डिस्क उपयोग", + "cw_files_usage_again": "फिर से मापें", + "cw_files_usage_busy": "किसी दूसरे फ़ोल्डर का आकार मापा जा रहा है। उसके खत्म होने पर फिर कोशिश करें।", + "cw_files_usage_cancelled": "रद्द किया गया: {size} गिना गया, बाकी नहीं मापा गया।", + "cw_files_usage_capped": "{items} आइटम पर रुका: {size} गिना गया, फ़ोल्डर में और है।", + "cw_files_usage_done": "{items} आइटम में {size}।", + "cw_files_usage_files_here": "इस फ़ोल्डर की फ़ाइलें", + "cw_files_usage_inside": "{name} के अंदर देखें", + "cw_files_usage_open": "फ़ाइलें सेक्शन में खोलें", + "cw_files_usage_open_row": "{name} को फ़ाइलें सेक्शन में खोलें", + "cw_files_usage_running": "माप रहा है… {n} में से {at} फ़ोल्डर, {items} आइटम।", + "cw_files_usage_skipped": "नहीं मापा गया", + "cw_files_usage_timeout": "3 मिनट बाद रुका: {size} गिना गया, बाकी नहीं मापा गया।", + "cw_files_usage_unreadable": "पढ़ा नहीं जा सकता", "cw_files_verify": "जाँचें (चेकसम)", "cw_files_verify_blake3": "BLAKE3", "cw_files_verify_cancelled": "रद्द किया।", diff --git a/client/src/console/locales/hu.json b/client/src/console/locales/hu.json index b8c8bf19..2e939362 100644 --- a/client/src/console/locales/hu.json +++ b/client/src/console/locales/hu.json @@ -188,6 +188,29 @@ "cw_files_replace_failed": "A lecserélendő elemet nem sikerült törölni, ezért nem lett beillesztve.", "cw_files_retry": "Újra", "cw_files_rows_more": "További sorok ({n} maradt)", + "cw_files_search": "Keresés", + "cw_files_search_all": "Minden meghajtó", + "cw_files_search_bad": "Idézőjelre és fordított perjelre nem lehet keresni.", + "cw_files_search_building": "Index készítése… eddig {n} fájl.", + "cw_files_search_cancel": "Mégse", + "cw_files_search_capped": "Megtelt (200 000 fájl), ezért néhány fájl hiányzik.", + "cw_files_search_field": "Név vagy minta", + "cw_files_search_found": "{n} találat.", + "cw_files_search_go": "Keresés", + "cw_files_search_here": "Ez a mappa", + "cw_files_search_hint": "Fájlokat keres név alapján. Egy szó a név bármely részén egyezhet; mintához használd a * és ? jelet (*.pkg).", + "cw_files_search_more": "Az első {n} látszik. Van több is: próbálj hosszabb nevet.", + "cw_files_search_no_index": "Még nincs index. Az első keresés létrehozza: minden mappát beolvas, és néhány percig tarthat.", + "cw_files_search_none": "Semmi sem egyezik ezzel: „{q}”.", + "cw_files_search_partial": "Idő előtt leállt, ezért néhány fájl hiányzik.", + "cw_files_search_ready": "Index: {n} fájl.", + "cw_files_search_rebuild": "Újraépítés", + "cw_files_search_results": "Keresési eredmények", + "cw_files_search_scope": "Hol keressen", + "cw_files_search_searching": "Keresés…", + "cw_files_search_stopping": "Index leállítása…", + "cw_files_search_unsupported": "A konzolon lévő PS5Upload túl régi a kereséshez. Frissítsd, hogy kereshess.", + "cw_files_search_waiting": "Rákeres erre: „{q}”, amint kész az index.", "cw_files_select_all": "Összes kijelölése", "cw_files_select_invert": "Megfordítás", "cw_files_select_none": "Kijelölés törlése", @@ -251,6 +274,20 @@ "cw_files_unpin": "Kitűzés megszüntetése", "cw_files_unpin_done": "{name} kitűzése megszüntetve.", "cw_files_up": "Fel", + "cw_files_usage": "Lemezhasználat", + "cw_files_usage_again": "Mérés újra", + "cw_files_usage_busy": "Most egy másik mappa mérete mérődik. Próbáld újra, ha végzett.", + "cw_files_usage_cancelled": "Megszakítva: {size} megszámolva, a többit nem mértük.", + "cw_files_usage_capped": "{items} elemnél leállt: {size} megszámolva, a mappában több van.", + "cw_files_usage_done": "{size}, {items} elemben.", + "cw_files_usage_files_here": "A mappa fájljai", + "cw_files_usage_inside": "Belenézés: {name}", + "cw_files_usage_open": "Megnyitás a Fájlokban", + "cw_files_usage_open_row": "{name} megnyitása a Fájlokban", + "cw_files_usage_running": "Mérés… {at}/{n} mappa, {items} elem.", + "cw_files_usage_skipped": "nincs mérve", + "cw_files_usage_timeout": "3 perc után leállt: {size} megszámolva, a többit nem mértük.", + "cw_files_usage_unreadable": "nem olvasható", "cw_files_verify": "Ellenőrzés (ellenőrzőösszeg)", "cw_files_verify_blake3": "BLAKE3", "cw_files_verify_cancelled": "Megszakítva.", diff --git a/client/src/console/locales/id.json b/client/src/console/locales/id.json index e4dae154..bf6d8b09 100644 --- a/client/src/console/locales/id.json +++ b/client/src/console/locales/id.json @@ -188,6 +188,29 @@ "cw_files_replace_failed": "Item yang akan diganti tidak bisa dihapus, jadi tidak ditempel.", "cw_files_retry": "Coba lagi", "cw_files_rows_more": "Tampilkan baris lainnya (sisa {n})", + "cw_files_search": "Cari", + "cw_files_search_all": "Semua drive", + "cw_files_search_bad": "Tanda kutip dan garis miring terbalik tidak dapat dicari.", + "cw_files_search_building": "Membuat indeks… {n} file sejauh ini.", + "cw_files_search_cancel": "Batal", + "cw_files_search_capped": "Indeks penuh (200.000 file), jadi beberapa file tidak ada.", + "cw_files_search_field": "Nama atau pola", + "cw_files_search_found": "{n} ditemukan.", + "cw_files_search_go": "Cari", + "cw_files_search_here": "Folder ini", + "cw_files_search_hint": "Mencari file menurut nama. Sebuah kata cocok di bagian mana pun dari nama; gunakan * dan ? untuk pola (*.pkg).", + "cw_files_search_more": "Menampilkan {n} pertama. Masih ada lagi: coba nama yang lebih panjang.", + "cw_files_search_no_index": "Belum ada indeks. Pencarian pertama membuatnya: membaca setiap folder dan bisa memakan beberapa menit.", + "cw_files_search_none": "Tidak ada yang cocok dengan “{q}”.", + "cw_files_search_partial": "Indeks dihentikan lebih awal, jadi beberapa file tidak ada.", + "cw_files_search_ready": "Indeks: {n} file.", + "cw_files_search_rebuild": "Buat ulang", + "cw_files_search_results": "Hasil pencarian", + "cw_files_search_scope": "Tempat mencari", + "cw_files_search_searching": "Mencari…", + "cw_files_search_stopping": "Menghentikan indeks…", + "cw_files_search_unsupported": "PS5Upload di konsol ini terlalu lama untuk mencari. Perbarui untuk memakai pencarian.", + "cw_files_search_waiting": "Akan mencari “{q}” saat indeks siap.", "cw_files_select_all": "Pilih semua", "cw_files_select_invert": "Balikkan", "cw_files_select_none": "Batalkan pilihan", @@ -251,6 +274,20 @@ "cw_files_unpin": "Lepas sematan", "cw_files_unpin_done": "Sematan {name} dilepas.", "cw_files_up": "Naik", + "cw_files_usage": "Penggunaan disk", + "cw_files_usage_again": "Ukur lagi", + "cw_files_usage_busy": "Ukuran folder lain sedang diukur. Coba lagi setelah selesai.", + "cw_files_usage_cancelled": "Dibatalkan: {size} terhitung, sisanya tidak diukur.", + "cw_files_usage_capped": "Berhenti di {items} item: {size} terhitung, folder berisi lebih banyak.", + "cw_files_usage_done": "{size} dalam {items} item.", + "cw_files_usage_files_here": "File di folder ini", + "cw_files_usage_inside": "Lihat isi {name}", + "cw_files_usage_open": "Buka di File", + "cw_files_usage_open_row": "Buka {name} di File", + "cw_files_usage_running": "Mengukur… {at} dari {n} folder, {items} item.", + "cw_files_usage_skipped": "tidak diukur", + "cw_files_usage_timeout": "Berhenti setelah 3 menit: {size} terhitung, sisanya tidak diukur.", + "cw_files_usage_unreadable": "tidak terbaca", "cw_files_verify": "Verifikasi (checksum)", "cw_files_verify_blake3": "BLAKE3", "cw_files_verify_cancelled": "Dibatalkan.", diff --git a/client/src/console/locales/it.json b/client/src/console/locales/it.json index b02142ef..e10ac607 100644 --- a/client/src/console/locales/it.json +++ b/client/src/console/locales/it.json @@ -188,6 +188,29 @@ "cw_files_replace_failed": "Impossibile eliminare l’elemento da sostituire, quindi non è stato incollato.", "cw_files_retry": "Riprova", "cw_files_rows_more": "Mostra altre righe ({n} rimaste)", + "cw_files_search": "Cerca", + "cw_files_search_all": "Tutte le unità", + "cw_files_search_bad": "Non si possono cercare virgolette o barre rovesciate.", + "cw_files_search_building": "Creazione dell’indice… {n} file finora.", + "cw_files_search_cancel": "Annulla", + "cw_files_search_capped": "È pieno (200.000 file), quindi mancano alcuni file.", + "cw_files_search_field": "Nome o modello", + "cw_files_search_found": "{n} trovati.", + "cw_files_search_go": "Cerca", + "cw_files_search_here": "Questa cartella", + "cw_files_search_hint": "Trova i file per nome. Una parola corrisponde in qualsiasi punto del nome; usa * e ? per un modello (*.pkg).", + "cw_files_search_more": "Vengono mostrati i primi {n}. Ce ne sono altri: prova un nome più lungo.", + "cw_files_search_no_index": "Nessun indice ancora. La prima ricerca ne crea uno: legge ogni cartella e può richiedere alcuni minuti.", + "cw_files_search_none": "Nessun risultato per «{q}».", + "cw_files_search_partial": "È stato fermato prima della fine, quindi mancano alcuni file.", + "cw_files_search_ready": "Indice: {n} file.", + "cw_files_search_rebuild": "Ricrea", + "cw_files_search_results": "Risultati della ricerca", + "cw_files_search_scope": "Dove cercare", + "cw_files_search_searching": "Ricerca…", + "cw_files_search_stopping": "Arresto dell’indice…", + "cw_files_search_unsupported": "PS5Upload su questa console è troppo vecchio per cercare. Aggiornalo per usare la ricerca.", + "cw_files_search_waiting": "Cercherà «{q}» quando l’indice sarà pronto.", "cw_files_select_all": "Seleziona tutto", "cw_files_select_invert": "Inverti", "cw_files_select_none": "Deseleziona tutto", @@ -251,6 +274,20 @@ "cw_files_unpin": "Rimuovi", "cw_files_unpin_done": "{name} rimossa dalle fissate.", "cw_files_up": "Su", + "cw_files_usage": "Utilizzo del disco", + "cw_files_usage_again": "Misura di nuovo", + "cw_files_usage_busy": "È in corso la misura di un’altra cartella. Riprova quando è finita.", + "cw_files_usage_cancelled": "Annullato: {size} contati, il resto non è stato misurato.", + "cw_files_usage_capped": "Fermato a {items} elementi: {size} contati, la cartella ne contiene di più.", + "cw_files_usage_done": "{size} in {items} elementi.", + "cw_files_usage_files_here": "File in questa cartella", + "cw_files_usage_inside": "Guarda dentro {name}", + "cw_files_usage_open": "Apri in File", + "cw_files_usage_open_row": "Apri {name} in File", + "cw_files_usage_running": "Misurazione… {at} di {n} cartelle, {items} elementi.", + "cw_files_usage_skipped": "non misurato", + "cw_files_usage_timeout": "Fermato dopo 3 minuti: {size} contati, il resto non è stato misurato.", + "cw_files_usage_unreadable": "illeggibile", "cw_files_verify": "Verifica (checksum)", "cw_files_verify_blake3": "BLAKE3", "cw_files_verify_cancelled": "Annullato.", diff --git a/client/src/console/locales/ja.json b/client/src/console/locales/ja.json index 0cd1b649..5975cad8 100644 --- a/client/src/console/locales/ja.json +++ b/client/src/console/locales/ja.json @@ -188,6 +188,29 @@ "cw_files_replace_failed": "置き換える項目を削除できなかったため、貼り付けませんでした。", "cw_files_retry": "再試行", "cw_files_rows_more": "行をさらに表示 (残り {n})", + "cw_files_search": "検索", + "cw_files_search_all": "すべてのドライブ", + "cw_files_search_bad": "引用符とバックスラッシュは検索できません。", + "cw_files_search_building": "インデックスを作成中… これまでに {n} ファイル。", + "cw_files_search_cancel": "キャンセル", + "cw_files_search_capped": "上限(200,000 ファイル)に達したため、一部のファイルがありません。", + "cw_files_search_field": "名前またはパターン", + "cw_files_search_found": "{n} 件見つかりました。", + "cw_files_search_go": "検索", + "cw_files_search_here": "このフォルダー", + "cw_files_search_hint": "名前でファイルを探します。単語は名前のどこにあっても一致します。パターンには * と ? を使います(*.pkg)。", + "cw_files_search_more": "最初の {n} 件を表示しています。さらにあります。もっと長い名前で試してください。", + "cw_files_search_no_index": "まだインデックスがありません。最初の検索で作成します。すべてのフォルダーを読むため数分かかることがあります。", + "cw_files_search_none": "「{q}」に一致するものはありません。", + "cw_files_search_partial": "途中で停止したため、一部のファイルがありません。", + "cw_files_search_ready": "インデックス: {n} ファイル。", + "cw_files_search_rebuild": "作り直す", + "cw_files_search_results": "検索結果", + "cw_files_search_scope": "検索する場所", + "cw_files_search_searching": "検索中…", + "cw_files_search_stopping": "インデックスを停止中…", + "cw_files_search_unsupported": "この本体の PS5Upload は古いため検索できません。検索を使うには更新してください。", + "cw_files_search_waiting": "インデックスの準備ができたら「{q}」を検索します。", "cw_files_select_all": "すべて選択", "cw_files_select_invert": "選択を反転", "cw_files_select_none": "選択を解除", @@ -251,6 +274,20 @@ "cw_files_unpin": "ピン留めを外す", "cw_files_unpin_done": "{name} のピン留めを外しました。", "cw_files_up": "上へ", + "cw_files_usage": "ディスク使用量", + "cw_files_usage_again": "もう一度計測", + "cw_files_usage_busy": "別のフォルダーのサイズを計測中です。終わってから再試行してください。", + "cw_files_usage_cancelled": "キャンセルしました: {size} を計上、残りは計測していません。", + "cw_files_usage_capped": "{items} 項目で停止: {size} を計上、フォルダーにはさらにあります。", + "cw_files_usage_done": "{items} 項目で {size}。", + "cw_files_usage_files_here": "このフォルダー内のファイル", + "cw_files_usage_inside": "{name} の中を見る", + "cw_files_usage_open": "ファイルで開く", + "cw_files_usage_open_row": "{name} をファイルで開く", + "cw_files_usage_running": "計測中… {n} フォルダー中 {at}、{items} 項目。", + "cw_files_usage_skipped": "未計測", + "cw_files_usage_timeout": "3 分で停止: {size} を計上、残りは計測していません。", + "cw_files_usage_unreadable": "読み取れません", "cw_files_verify": "検証(チェックサム)", "cw_files_verify_blake3": "BLAKE3", "cw_files_verify_cancelled": "キャンセルしました。", diff --git a/client/src/console/locales/ko.json b/client/src/console/locales/ko.json index b77c95df..ec90bd71 100644 --- a/client/src/console/locales/ko.json +++ b/client/src/console/locales/ko.json @@ -188,6 +188,29 @@ "cw_files_replace_failed": "바꿀 항목을 삭제하지 못해 붙여넣지 않았습니다.", "cw_files_retry": "다시 시도", "cw_files_rows_more": "행 더 보기 ({n}개 남음)", + "cw_files_search": "검색", + "cw_files_search_all": "모든 드라이브", + "cw_files_search_bad": "따옴표와 백슬래시는 검색할 수 없습니다.", + "cw_files_search_building": "색인 생성 중… 지금까지 {n}개 파일.", + "cw_files_search_cancel": "취소", + "cw_files_search_capped": "가득 차서(200,000개 파일) 일부 파일이 빠져 있습니다.", + "cw_files_search_field": "이름 또는 패턴", + "cw_files_search_found": "{n}개 찾음.", + "cw_files_search_go": "검색", + "cw_files_search_here": "이 폴더", + "cw_files_search_hint": "이름으로 파일을 찾습니다. 단어는 이름의 어느 부분과도 일치하며, 패턴에는 *와 ?를 사용합니다(*.pkg).", + "cw_files_search_more": "처음 {n}개를 표시합니다. 더 있습니다. 더 긴 이름으로 시도하세요.", + "cw_files_search_no_index": "아직 색인이 없습니다. 첫 검색에서 색인을 만듭니다. 모든 폴더를 읽으므로 몇 분 걸릴 수 있습니다.", + "cw_files_search_none": "“{q}”과(와) 일치하는 항목이 없습니다.", + "cw_files_search_partial": "일찍 중지되어 일부 파일이 빠져 있습니다.", + "cw_files_search_ready": "색인: {n}개 파일.", + "cw_files_search_rebuild": "다시 만들기", + "cw_files_search_results": "검색 결과", + "cw_files_search_scope": "검색 위치", + "cw_files_search_searching": "검색 중…", + "cw_files_search_stopping": "색인 중지 중…", + "cw_files_search_unsupported": "이 콘솔의 PS5Upload가 오래되어 검색할 수 없습니다. 검색을 사용하려면 업데이트하세요.", + "cw_files_search_waiting": "색인이 준비되면 “{q}”을(를) 검색합니다.", "cw_files_select_all": "모두 선택", "cw_files_select_invert": "선택 반전", "cw_files_select_none": "선택 해제", @@ -251,6 +274,20 @@ "cw_files_unpin": "고정 해제", "cw_files_unpin_done": "{name} 고정을 해제했습니다.", "cw_files_up": "위로", + "cw_files_usage": "디스크 사용량", + "cw_files_usage_again": "다시 측정", + "cw_files_usage_busy": "다른 폴더 크기를 측정하는 중입니다. 끝난 뒤 다시 시도하세요.", + "cw_files_usage_cancelled": "취소됨: {size} 집계, 나머지는 측정하지 않았습니다.", + "cw_files_usage_capped": "항목 {items}개에서 중지: {size} 집계, 폴더에 더 있습니다.", + "cw_files_usage_done": "항목 {items}개에 {size}.", + "cw_files_usage_files_here": "이 폴더의 파일", + "cw_files_usage_inside": "{name} 안 보기", + "cw_files_usage_open": "파일에서 열기", + "cw_files_usage_open_row": "{name}을(를) 파일에서 열기", + "cw_files_usage_running": "측정 중… 폴더 {n}개 중 {at}개, 항목 {items}개.", + "cw_files_usage_skipped": "측정 안 됨", + "cw_files_usage_timeout": "3분 후 중지: {size} 집계, 나머지는 측정하지 않았습니다.", + "cw_files_usage_unreadable": "읽을 수 없음", "cw_files_verify": "확인(체크섬)", "cw_files_verify_blake3": "BLAKE3", "cw_files_verify_cancelled": "취소했습니다.", diff --git a/client/src/console/locales/pl.json b/client/src/console/locales/pl.json index 9b6b7e0e..0fa7bae0 100644 --- a/client/src/console/locales/pl.json +++ b/client/src/console/locales/pl.json @@ -188,6 +188,29 @@ "cw_files_replace_failed": "Nie udało się usunąć zastępowanego elementu, więc nic nie wklejono.", "cw_files_retry": "Spróbuj ponownie", "cw_files_rows_more": "Pokaż więcej wierszy (zostało {n})", + "cw_files_search": "Szukaj", + "cw_files_search_all": "Wszystkie dyski", + "cw_files_search_bad": "Nie można szukać cudzysłowów ani ukośników wstecznych.", + "cw_files_search_building": "Budowanie indeksu… dotąd {n} plików.", + "cw_files_search_cancel": "Anuluj", + "cw_files_search_capped": "Jest pełny (200 000 plików), więc brakuje niektórych plików.", + "cw_files_search_field": "Nazwa lub wzorzec", + "cw_files_search_found": "Znaleziono: {n}.", + "cw_files_search_go": "Szukaj", + "cw_files_search_here": "Ten folder", + "cw_files_search_hint": "Znajduje pliki po nazwie. Słowo pasuje w dowolnym miejscu nazwy; użyj * i ? jako wzorca (*.pkg).", + "cw_files_search_more": "Pokazano pierwsze {n}. Jest ich więcej: spróbuj dłuższej nazwy.", + "cw_files_search_no_index": "Nie ma jeszcze indeksu. Pierwsze wyszukiwanie go zbuduje: odczyta każdy folder i może potrwać kilka minut.", + "cw_files_search_none": "Nic nie pasuje do „{q}”.", + "cw_files_search_partial": "Zatrzymano go przed końcem, więc brakuje niektórych plików.", + "cw_files_search_ready": "Indeks: {n} plików.", + "cw_files_search_rebuild": "Zbuduj ponownie", + "cw_files_search_results": "Wyniki wyszukiwania", + "cw_files_search_scope": "Gdzie szukać", + "cw_files_search_searching": "Wyszukiwanie…", + "cw_files_search_stopping": "Zatrzymywanie indeksu…", + "cw_files_search_unsupported": "PS5Upload na tej konsoli jest za stary, by szukać. Zaktualizuj go, aby korzystać z wyszukiwania.", + "cw_files_search_waiting": "Wyszuka „{q}”, gdy indeks będzie gotowy.", "cw_files_select_all": "Zaznacz wszystko", "cw_files_select_invert": "Odwróć", "cw_files_select_none": "Odznacz wszystko", @@ -251,6 +274,20 @@ "cw_files_unpin": "Odepnij", "cw_files_unpin_done": "Odpięto: {name}.", "cw_files_up": "W górę", + "cw_files_usage": "Zajęte miejsce", + "cw_files_usage_again": "Zmierz ponownie", + "cw_files_usage_busy": "Trwa mierzenie rozmiaru innego folderu. Spróbuj ponownie, gdy się skończy.", + "cw_files_usage_cancelled": "Anulowano: policzono {size}, reszty nie zmierzono.", + "cw_files_usage_capped": "Zatrzymano przy {items} elementach: policzono {size}, folder zawiera więcej.", + "cw_files_usage_done": "{size} w {items} elementach.", + "cw_files_usage_files_here": "Pliki w tym folderze", + "cw_files_usage_inside": "Zajrzyj do {name}", + "cw_files_usage_open": "Otwórz w Plikach", + "cw_files_usage_open_row": "Otwórz {name} w Plikach", + "cw_files_usage_running": "Mierzenie… {at} z {n} folderów, {items} elementów.", + "cw_files_usage_skipped": "nie zmierzono", + "cw_files_usage_timeout": "Zatrzymano po 3 minutach: policzono {size}, reszty nie zmierzono.", + "cw_files_usage_unreadable": "nieczytelny", "cw_files_verify": "Sprawdź (suma kontrolna)", "cw_files_verify_blake3": "BLAKE3", "cw_files_verify_cancelled": "Anulowano.", diff --git a/client/src/console/locales/pt-BR.json b/client/src/console/locales/pt-BR.json index 49fd377f..3c7a4294 100644 --- a/client/src/console/locales/pt-BR.json +++ b/client/src/console/locales/pt-BR.json @@ -188,6 +188,29 @@ "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_retry": "Tentar de novo", "cw_files_rows_more": "Mostrar mais linhas ({n} restantes)", + "cw_files_search": "Pesquisar", + "cw_files_search_all": "Todas as unidades", + "cw_files_search_bad": "Não é possível pesquisar aspas nem barras invertidas.", + "cw_files_search_building": "Criando o índice… {n} arquivos até agora.", + "cw_files_search_cancel": "Cancelar", + "cw_files_search_capped": "Ele está cheio (200.000 arquivos), então faltam alguns arquivos.", + "cw_files_search_field": "Nome ou padrão", + "cw_files_search_found": "{n} encontrados.", + "cw_files_search_go": "Pesquisar", + "cw_files_search_here": "Esta pasta", + "cw_files_search_hint": "Encontra arquivos pelo nome. Uma palavra corresponde em qualquer parte do nome; use * e ? para um padrão (*.pkg).", + "cw_files_search_more": "Mostrando os primeiros {n}. Há mais: tente um nome mais longo.", + "cw_files_search_no_index": "Ainda não há índice. A primeira pesquisa cria um: ela lê todas as pastas e pode levar alguns minutos.", + "cw_files_search_none": "Nada corresponde a “{q}”.", + "cw_files_search_partial": "Ele foi interrompido antes do fim, então faltam alguns arquivos.", + "cw_files_search_ready": "Índice: {n} arquivos.", + "cw_files_search_rebuild": "Criar de novo", + "cw_files_search_results": "Resultados da pesquisa", + "cw_files_search_scope": "Onde pesquisar", + "cw_files_search_searching": "Pesquisando…", + "cw_files_search_stopping": "Parando o índice…", + "cw_files_search_unsupported": "O PS5Upload deste console é antigo demais para pesquisar. Atualize-o para usar a pesquisa.", + "cw_files_search_waiting": "Vai pesquisar “{q}” quando o índice estiver pronto.", "cw_files_select_all": "Selecionar tudo", "cw_files_select_invert": "Inverter", "cw_files_select_none": "Limpar seleção", @@ -251,6 +274,20 @@ "cw_files_unpin": "Desafixar", "cw_files_unpin_done": "{name} desafixada.", "cw_files_up": "Acima", + "cw_files_usage": "Uso do disco", + "cw_files_usage_again": "Medir de novo", + "cw_files_usage_busy": "O tamanho de outra pasta está sendo medido. Tente de novo quando terminar.", + "cw_files_usage_cancelled": "Cancelado: {size} contados, o resto não foi medido.", + "cw_files_usage_capped": "Parou em {items} itens: {size} contados, a pasta tem mais.", + "cw_files_usage_done": "{size} em {items} itens.", + "cw_files_usage_files_here": "Arquivos nesta pasta", + "cw_files_usage_inside": "Ver dentro de {name}", + "cw_files_usage_open": "Abrir em Arquivos", + "cw_files_usage_open_row": "Abrir {name} em Arquivos", + "cw_files_usage_running": "Medindo… {at} de {n} pastas, {items} itens.", + "cw_files_usage_skipped": "não medido", + "cw_files_usage_timeout": "Parou após 3 minutos: {size} contados, o resto não foi medido.", + "cw_files_usage_unreadable": "ilegível", "cw_files_verify": "Verificar (checksum)", "cw_files_verify_blake3": "BLAKE3", "cw_files_verify_cancelled": "Cancelado.", diff --git a/client/src/console/locales/ru.json b/client/src/console/locales/ru.json index eb158415..ffdbf971 100644 --- a/client/src/console/locales/ru.json +++ b/client/src/console/locales/ru.json @@ -188,6 +188,29 @@ "cw_files_replace_failed": "Не удалось удалить заменяемый элемент, поэтому вставка не выполнена.", "cw_files_retry": "Повторить", "cw_files_rows_more": "Показать ещё строки (осталось {n})", + "cw_files_search": "Поиск", + "cw_files_search_all": "Все диски", + "cw_files_search_bad": "Кавычки и обратную косую черту искать нельзя.", + "cw_files_search_building": "Создание индекса… пока {n} файлов.", + "cw_files_search_cancel": "Отмена", + "cw_files_search_capped": "Он заполнен (200 000 файлов), поэтому некоторых файлов нет.", + "cw_files_search_field": "Имя или шаблон", + "cw_files_search_found": "Найдено: {n}.", + "cw_files_search_go": "Найти", + "cw_files_search_here": "Эта папка", + "cw_files_search_hint": "Ищет файлы по имени. Слово совпадает в любом месте имени; для шаблона используйте * и ? (*.pkg).", + "cw_files_search_more": "Показаны первые {n}. Есть ещё: попробуйте имя подлиннее.", + "cw_files_search_no_index": "Индекса пока нет. Первый поиск создаст его: он читает каждую папку и может занять несколько минут.", + "cw_files_search_none": "Ничего не найдено по «{q}».", + "cw_files_search_partial": "Он был остановлен раньше времени, поэтому некоторых файлов нет.", + "cw_files_search_ready": "Индекс: {n} файлов.", + "cw_files_search_rebuild": "Создать заново", + "cw_files_search_results": "Результаты поиска", + "cw_files_search_scope": "Где искать", + "cw_files_search_searching": "Поиск…", + "cw_files_search_stopping": "Остановка индекса…", + "cw_files_search_unsupported": "PS5Upload на этой консоли слишком старый для поиска. Обновите его, чтобы искать.", + "cw_files_search_waiting": "Найдёт «{q}», когда индекс будет готов.", "cw_files_select_all": "Выбрать все", "cw_files_select_invert": "Инвертировать", "cw_files_select_none": "Снять выбор", @@ -251,6 +274,20 @@ "cw_files_unpin": "Открепить", "cw_files_unpin_done": "{name} откреплена.", "cw_files_up": "Вверх", + "cw_files_usage": "Занятое место", + "cw_files_usage_again": "Измерить заново", + "cw_files_usage_busy": "Сейчас измеряется размер другой папки. Повторите, когда это закончится.", + "cw_files_usage_cancelled": "Отменено: учтено {size}, остальное не измерено.", + "cw_files_usage_capped": "Остановлено на {items} элементах: учтено {size}, в папке есть ещё.", + "cw_files_usage_done": "{size} в {items} элементах.", + "cw_files_usage_files_here": "Файлы в этой папке", + "cw_files_usage_inside": "Заглянуть в {name}", + "cw_files_usage_open": "Открыть в «Файлах»", + "cw_files_usage_open_row": "Открыть {name} в «Файлах»", + "cw_files_usage_running": "Измерение… {at} из {n} папок, {items} элементов.", + "cw_files_usage_skipped": "не измерено", + "cw_files_usage_timeout": "Остановлено через 3 минуты: учтено {size}, остальное не измерено.", + "cw_files_usage_unreadable": "не читается", "cw_files_verify": "Проверить (контрольная сумма)", "cw_files_verify_blake3": "BLAKE3", "cw_files_verify_cancelled": "Отменено.", diff --git a/client/src/console/locales/th.json b/client/src/console/locales/th.json index e4069b4f..16f383dc 100644 --- a/client/src/console/locales/th.json +++ b/client/src/console/locales/th.json @@ -188,6 +188,29 @@ "cw_files_replace_failed": "ลบรายการที่จะแทนที่ไม่ได้ จึงไม่ได้วาง", "cw_files_retry": "ลองอีกครั้ง", "cw_files_rows_more": "แสดงแถวเพิ่ม (เหลือ {n})", + "cw_files_search": "ค้นหา", + "cw_files_search_all": "ทุกไดรฟ์", + "cw_files_search_bad": "ค้นหาเครื่องหมายคำพูดและแบ็กสแลชไม่ได้", + "cw_files_search_building": "กำลังสร้างดัชนี… ตอนนี้ {n} ไฟล์", + "cw_files_search_cancel": "ยกเลิก", + "cw_files_search_capped": "ดัชนีเต็ม (200,000 ไฟล์) จึงขาดบางไฟล์", + "cw_files_search_field": "ชื่อหรือรูปแบบ", + "cw_files_search_found": "พบ {n} รายการ", + "cw_files_search_go": "ค้นหา", + "cw_files_search_here": "โฟลเดอร์นี้", + "cw_files_search_hint": "ค้นหาไฟล์ตามชื่อ คำจะตรงกับส่วนใดก็ได้ของชื่อ ใช้ * และ ? เป็นรูปแบบ (*.pkg)", + "cw_files_search_more": "แสดง {n} รายการแรก ยังมีอีก: ลองใช้ชื่อที่ยาวขึ้น", + "cw_files_search_no_index": "ยังไม่มีดัชนี การค้นหาครั้งแรกจะสร้างดัชนี โดยอ่านทุกโฟลเดอร์และอาจใช้เวลาสองสามนาที", + "cw_files_search_none": "ไม่มีสิ่งที่ตรงกับ “{q}”", + "cw_files_search_partial": "ดัชนีถูกหยุดก่อนเสร็จ จึงขาดบางไฟล์", + "cw_files_search_ready": "ดัชนี: {n} ไฟล์", + "cw_files_search_rebuild": "สร้างใหม่", + "cw_files_search_results": "ผลการค้นหา", + "cw_files_search_scope": "ค้นหาที่ใด", + "cw_files_search_searching": "กำลังค้นหา…", + "cw_files_search_stopping": "กำลังหยุดดัชนี…", + "cw_files_search_unsupported": "PS5Upload บนเครื่องนี้เก่าเกินกว่าจะค้นหาได้ อัปเดตเพื่อใช้การค้นหา", + "cw_files_search_waiting": "จะค้นหา “{q}” เมื่อดัชนีพร้อม", "cw_files_select_all": "เลือกทั้งหมด", "cw_files_select_invert": "กลับการเลือก", "cw_files_select_none": "ไม่เลือกเลย", @@ -251,6 +274,20 @@ "cw_files_unpin": "เลิกปักหมุด", "cw_files_unpin_done": "เลิกปักหมุด {name} แล้ว", "cw_files_up": "ขึ้น", + "cw_files_usage": "การใช้ดิสก์", + "cw_files_usage_again": "วัดอีกครั้ง", + "cw_files_usage_busy": "กำลังวัดขนาดโฟลเดอร์อื่นอยู่ ลองอีกครั้งเมื่อเสร็จ", + "cw_files_usage_cancelled": "ยกเลิกแล้ว: นับได้ {size} ส่วนที่เหลือไม่ได้วัด", + "cw_files_usage_capped": "หยุดที่ {items} รายการ: นับได้ {size} โฟลเดอร์ยังมีอีก", + "cw_files_usage_done": "{size} ใน {items} รายการ", + "cw_files_usage_files_here": "ไฟล์ในโฟลเดอร์นี้", + "cw_files_usage_inside": "ดูข้างใน {name}", + "cw_files_usage_open": "เปิดในไฟล์", + "cw_files_usage_open_row": "เปิด {name} ในไฟล์", + "cw_files_usage_running": "กำลังวัด… {at} จาก {n} โฟลเดอร์, {items} รายการ", + "cw_files_usage_skipped": "ไม่ได้วัด", + "cw_files_usage_timeout": "หยุดหลัง 3 นาที: นับได้ {size} ส่วนที่เหลือไม่ได้วัด", + "cw_files_usage_unreadable": "อ่านไม่ได้", "cw_files_verify": "ตรวจสอบ (เช็กซัม)", "cw_files_verify_blake3": "BLAKE3", "cw_files_verify_cancelled": "ยกเลิกแล้ว", diff --git a/client/src/console/locales/tr.json b/client/src/console/locales/tr.json index eee745e3..344c94a9 100644 --- a/client/src/console/locales/tr.json +++ b/client/src/console/locales/tr.json @@ -188,6 +188,29 @@ "cw_files_replace_failed": "Değiştirilecek öğe silinemedi, bu yüzden yapıştırılmadı.", "cw_files_retry": "Tekrar dene", "cw_files_rows_more": "Daha fazla satır göster ({n} kaldı)", + "cw_files_search": "Ara", + "cw_files_search_all": "Tüm sürücüler", + "cw_files_search_bad": "Tırnak işaretleri ve ters eğik çizgiler aranamaz.", + "cw_files_search_building": "Dizin oluşturuluyor… şimdiye dek {n} dosya.", + "cw_files_search_cancel": "İptal", + "cw_files_search_capped": "Dolu (200.000 dosya), bu yüzden bazı dosyalar eksik.", + "cw_files_search_field": "Ad veya desen", + "cw_files_search_found": "{n} bulundu.", + "cw_files_search_go": "Ara", + "cw_files_search_here": "Bu klasör", + "cw_files_search_hint": "Dosyaları adına göre bulur. Bir sözcük adın herhangi bir yerinde eşleşir; desen için * ve ? kullanın (*.pkg).", + "cw_files_search_more": "İlk {n} gösteriliyor. Daha fazlası var: daha uzun bir ad deneyin.", + "cw_files_search_no_index": "Henüz dizin yok. İlk arama bir dizin oluşturur: her klasörü okur ve birkaç dakika sürebilir.", + "cw_files_search_none": "“{q}” ile eşleşen bir şey yok.", + "cw_files_search_partial": "Erken durduruldu, bu yüzden bazı dosyalar eksik.", + "cw_files_search_ready": "Dizin: {n} dosya.", + "cw_files_search_rebuild": "Yeniden oluştur", + "cw_files_search_results": "Arama sonuçları", + "cw_files_search_scope": "Nerede aranacak", + "cw_files_search_searching": "Aranıyor…", + "cw_files_search_stopping": "Dizin durduruluyor…", + "cw_files_search_unsupported": "Bu konsoldaki PS5Upload arama için çok eski. Aramayı kullanmak için güncelleyin.", + "cw_files_search_waiting": "Dizin hazır olunca “{q}” aranacak.", "cw_files_select_all": "Tümünü seç", "cw_files_select_invert": "Tersine çevir", "cw_files_select_none": "Seçimi kaldır", @@ -251,6 +274,20 @@ "cw_files_unpin": "Sabitlemeyi kaldır", "cw_files_unpin_done": "{name} sabitlemesi kaldırıldı.", "cw_files_up": "Yukarı", + "cw_files_usage": "Disk kullanımı", + "cw_files_usage_again": "Yeniden ölç", + "cw_files_usage_busy": "Başka bir klasörün boyutu ölçülüyor. Bitince yeniden deneyin.", + "cw_files_usage_cancelled": "İptal edildi: {size} sayıldı, kalanı ölçülmedi.", + "cw_files_usage_capped": "{items} öğede durdu: {size} sayıldı, klasörde daha fazlası var.", + "cw_files_usage_done": "{items} öğede {size}.", + "cw_files_usage_files_here": "Bu klasördeki dosyalar", + "cw_files_usage_inside": "{name} içine bak", + "cw_files_usage_open": "Dosyalar’da aç", + "cw_files_usage_open_row": "{name} öğesini Dosyalar’da aç", + "cw_files_usage_running": "Ölçülüyor… {n} klasörden {at}, {items} öğe.", + "cw_files_usage_skipped": "ölçülmedi", + "cw_files_usage_timeout": "3 dakika sonra durdu: {size} sayıldı, kalanı ölçülmedi.", + "cw_files_usage_unreadable": "okunamıyor", "cw_files_verify": "Doğrula (sağlama toplamı)", "cw_files_verify_blake3": "BLAKE3", "cw_files_verify_cancelled": "İptal edildi.", diff --git a/client/src/console/locales/vi.json b/client/src/console/locales/vi.json index f2a24dbb..506d0d8f 100644 --- a/client/src/console/locales/vi.json +++ b/client/src/console/locales/vi.json @@ -188,6 +188,29 @@ "cw_files_replace_failed": "Không xóa được mục cần thay thế nên chưa dán.", "cw_files_retry": "Thử lại", "cw_files_rows_more": "Hiện thêm dòng (còn {n})", + "cw_files_search": "Tìm kiếm", + "cw_files_search_all": "Tất cả ổ đĩa", + "cw_files_search_bad": "Không thể tìm dấu ngoặc kép và dấu gạch chéo ngược.", + "cw_files_search_building": "Đang tạo chỉ mục… đến giờ {n} tệp.", + "cw_files_search_cancel": "Hủy", + "cw_files_search_capped": "Chỉ mục đã đầy (200.000 tệp) nên thiếu một số tệp.", + "cw_files_search_field": "Tên hoặc mẫu", + "cw_files_search_found": "Tìm thấy {n}.", + "cw_files_search_go": "Tìm", + "cw_files_search_here": "Thư mục này", + "cw_files_search_hint": "Tìm tệp theo tên. Một từ khớp ở bất kỳ đâu trong tên; dùng * và ? cho mẫu (*.pkg).", + "cw_files_search_more": "Đang hiện {n} kết quả đầu. Còn nữa: hãy thử tên dài hơn.", + "cw_files_search_no_index": "Chưa có chỉ mục. Lần tìm đầu tiên sẽ tạo: nó đọc mọi thư mục và có thể mất vài phút.", + "cw_files_search_none": "Không có gì khớp với “{q}”.", + "cw_files_search_partial": "Chỉ mục bị dừng sớm nên thiếu một số tệp.", + "cw_files_search_ready": "Chỉ mục: {n} tệp.", + "cw_files_search_rebuild": "Tạo lại", + "cw_files_search_results": "Kết quả tìm kiếm", + "cw_files_search_scope": "Tìm ở đâu", + "cw_files_search_searching": "Đang tìm…", + "cw_files_search_stopping": "Đang dừng chỉ mục…", + "cw_files_search_unsupported": "PS5Upload trên máy này quá cũ để tìm kiếm. Hãy cập nhật để dùng tìm kiếm.", + "cw_files_search_waiting": "Sẽ tìm “{q}” khi chỉ mục sẵn sàng.", "cw_files_select_all": "Chọn tất cả", "cw_files_select_invert": "Đảo chọn", "cw_files_select_none": "Bỏ chọn", @@ -251,6 +274,20 @@ "cw_files_unpin": "Bỏ ghim", "cw_files_unpin_done": "Đã bỏ ghim {name}.", "cw_files_up": "Lên", + "cw_files_usage": "Dung lượng đã dùng", + "cw_files_usage_again": "Đo lại", + "cw_files_usage_busy": "Đang đo kích thước một thư mục khác. Hãy thử lại khi xong.", + "cw_files_usage_cancelled": "Đã hủy: đã đếm {size}, phần còn lại chưa đo.", + "cw_files_usage_capped": "Dừng ở {items} mục: đã đếm {size}, thư mục còn nhiều hơn.", + "cw_files_usage_done": "{size} trong {items} mục.", + "cw_files_usage_files_here": "Tệp trong thư mục này", + "cw_files_usage_inside": "Xem bên trong {name}", + "cw_files_usage_open": "Mở trong Tệp", + "cw_files_usage_open_row": "Mở {name} trong Tệp", + "cw_files_usage_running": "Đang đo… {at}/{n} thư mục, {items} mục.", + "cw_files_usage_skipped": "chưa đo", + "cw_files_usage_timeout": "Dừng sau 3 phút: đã đếm {size}, phần còn lại chưa đo.", + "cw_files_usage_unreadable": "không đọc được", "cw_files_verify": "Kiểm tra (checksum)", "cw_files_verify_blake3": "BLAKE3", "cw_files_verify_cancelled": "Đã hủy.", diff --git a/client/src/console/locales/zh-CN.json b/client/src/console/locales/zh-CN.json index 8d479d47..304dfa05 100644 --- a/client/src/console/locales/zh-CN.json +++ b/client/src/console/locales/zh-CN.json @@ -188,6 +188,29 @@ "cw_files_replace_failed": "无法删除要替换的项目,因此未粘贴。", "cw_files_retry": "重试", "cw_files_rows_more": "显示更多行(还剩 {n})", + "cw_files_search": "搜索", + "cw_files_search_all": "所有驱动器", + "cw_files_search_bad": "无法搜索引号和反斜杠。", + "cw_files_search_building": "正在建立索引… 目前 {n} 个文件。", + "cw_files_search_cancel": "取消", + "cw_files_search_capped": "索引已满(200,000 个文件),因此缺少部分文件。", + "cw_files_search_field": "名称或模式", + "cw_files_search_found": "找到 {n} 个。", + "cw_files_search_go": "搜索", + "cw_files_search_here": "此文件夹", + "cw_files_search_hint": "按名称查找文件。单词可匹配名称中的任意位置;模式可使用 * 和 ?(*.pkg)。", + "cw_files_search_more": "显示前 {n} 个。还有更多:请尝试更长的名称。", + "cw_files_search_no_index": "还没有索引。首次搜索会建立索引:需要读取每个文件夹,可能需要几分钟。", + "cw_files_search_none": "没有与“{q}”匹配的内容。", + "cw_files_search_partial": "索引提前停止,因此缺少部分文件。", + "cw_files_search_ready": "索引:{n} 个文件。", + "cw_files_search_rebuild": "重新建立", + "cw_files_search_results": "搜索结果", + "cw_files_search_scope": "搜索范围", + "cw_files_search_searching": "正在搜索…", + "cw_files_search_stopping": "正在停止索引…", + "cw_files_search_unsupported": "此主机上的 PS5Upload 版本过旧,无法搜索。请更新后使用搜索。", + "cw_files_search_waiting": "索引就绪后将搜索“{q}”。", "cw_files_select_all": "全选", "cw_files_select_invert": "反选", "cw_files_select_none": "取消全选", @@ -251,6 +274,20 @@ "cw_files_unpin": "取消固定", "cw_files_unpin_done": "已取消固定 {name}。", "cw_files_up": "上一级", + "cw_files_usage": "磁盘用量", + "cw_files_usage_again": "重新测量", + "cw_files_usage_busy": "正在测量另一个文件夹的大小。请在其结束后重试。", + "cw_files_usage_cancelled": "已取消:已计 {size},其余未测量。", + "cw_files_usage_capped": "在 {items} 项处停止:已计 {size},文件夹中还有更多。", + "cw_files_usage_done": "{items} 项共 {size}。", + "cw_files_usage_files_here": "此文件夹中的文件", + "cw_files_usage_inside": "查看 {name} 内部", + "cw_files_usage_open": "在“文件”中打开", + "cw_files_usage_open_row": "在“文件”中打开 {name}", + "cw_files_usage_running": "正在测量… {n} 个文件夹中的第 {at} 个,{items} 项。", + "cw_files_usage_skipped": "未测量", + "cw_files_usage_timeout": "3 分钟后停止:已计 {size},其余未测量。", + "cw_files_usage_unreadable": "无法读取", "cw_files_verify": "校验(校验和)", "cw_files_verify_blake3": "BLAKE3", "cw_files_verify_cancelled": "已取消。", diff --git a/client/src/console/locales/zh-TW.json b/client/src/console/locales/zh-TW.json index a66f7757..638f7323 100644 --- a/client/src/console/locales/zh-TW.json +++ b/client/src/console/locales/zh-TW.json @@ -188,6 +188,29 @@ "cw_files_replace_failed": "無法刪除要取代的項目,因此未貼上。", "cw_files_retry": "重試", "cw_files_rows_more": "顯示更多列(還剩 {n})", + "cw_files_search": "搜尋", + "cw_files_search_all": "所有磁碟機", + "cw_files_search_bad": "無法搜尋引號和反斜線。", + "cw_files_search_building": "正在建立索引… 目前 {n} 個檔案。", + "cw_files_search_cancel": "取消", + "cw_files_search_capped": "索引已滿(200,000 個檔案),因此缺少部分檔案。", + "cw_files_search_field": "名稱或樣式", + "cw_files_search_found": "找到 {n} 個。", + "cw_files_search_go": "搜尋", + "cw_files_search_here": "此資料夾", + "cw_files_search_hint": "依名稱尋找檔案。單字可符合名稱中的任何位置;樣式可使用 * 和 ?(*.pkg)。", + "cw_files_search_more": "顯示前 {n} 個。還有更多:請嘗試較長的名稱。", + "cw_files_search_no_index": "尚無索引。第一次搜尋會建立索引:需要讀取每個資料夾,可能需要幾分鐘。", + "cw_files_search_none": "沒有符合「{q}」的項目。", + "cw_files_search_partial": "索引提早停止,因此缺少部分檔案。", + "cw_files_search_ready": "索引:{n} 個檔案。", + "cw_files_search_rebuild": "重新建立", + "cw_files_search_results": "搜尋結果", + "cw_files_search_scope": "搜尋範圍", + "cw_files_search_searching": "正在搜尋…", + "cw_files_search_stopping": "正在停止索引…", + "cw_files_search_unsupported": "此主機上的 PS5Upload 版本太舊,無法搜尋。請更新後使用搜尋。", + "cw_files_search_waiting": "索引就緒後將搜尋「{q}」。", "cw_files_select_all": "全選", "cw_files_select_invert": "反選", "cw_files_select_none": "取消全選", @@ -251,6 +274,20 @@ "cw_files_unpin": "取消釘選", "cw_files_unpin_done": "已取消釘選 {name}。", "cw_files_up": "上一層", + "cw_files_usage": "磁碟用量", + "cw_files_usage_again": "重新測量", + "cw_files_usage_busy": "正在測量另一個資料夾的大小。請在結束後重試。", + "cw_files_usage_cancelled": "已取消:已計 {size},其餘未測量。", + "cw_files_usage_capped": "在 {items} 項處停止:已計 {size},資料夾中還有更多。", + "cw_files_usage_done": "{items} 項共 {size}。", + "cw_files_usage_files_here": "此資料夾中的檔案", + "cw_files_usage_inside": "查看 {name} 內部", + "cw_files_usage_open": "在「檔案」中開啟", + "cw_files_usage_open_row": "在「檔案」中開啟 {name}", + "cw_files_usage_running": "正在測量… {n} 個資料夾中的第 {at} 個,{items} 項。", + "cw_files_usage_skipped": "未測量", + "cw_files_usage_timeout": "3 分鐘後停止:已計 {size},其餘未測量。", + "cw_files_usage_unreadable": "無法讀取", "cw_files_verify": "驗證(檢查碼)", "cw_files_verify_blake3": "BLAKE3", "cw_files_verify_cancelled": "已取消。", diff --git a/client/src/i18n/locales/ar.ts b/client/src/i18n/locales/ar.ts index 4599aca9..34906d37 100644 --- a/client/src/i18n/locales/ar.ts +++ b/client/src/i18n/locales/ar.ts @@ -4450,6 +4450,43 @@ c2c_test_deep_bad: "لم يصل الإرسال التجريبي بحجم 4 KiB: "cw_files_game_ps4": "مجلد لعبة PS4", "cw_files_game_version": "الإصدار {v}", "cw_files_game_fw": "يتطلب برنامج النظام {v}", +"cw_files_search": "بحث", +"cw_files_search_field": "الاسم أو النمط", +"cw_files_search_go": "بحث", +"cw_files_search_scope": "مكان البحث", +"cw_files_search_all": "كل الأقراص", +"cw_files_search_here": "هذا المجلد", +"cw_files_search_hint": "يبحث عن الملفات بالاسم. تطابق الكلمة أي جزء من الاسم؛ استخدم * و ? للنمط (*.pkg).", +"cw_files_search_bad": "لا يمكن البحث عن علامات الاقتباس والشرطات المائلة العكسية.", +"cw_files_search_unsupported": "إصدار PS5Upload على هذا الجهاز قديم جدًا للبحث. حدّثه لاستخدام البحث.", +"cw_files_search_no_index": "لا يوجد فهرس بعد. أول بحث ينشئ فهرسًا: يقرأ كل مجلد وقد يستغرق بضع دقائق.", +"cw_files_search_building": "جارٍ إنشاء الفهرس… {n} ملف حتى الآن.", +"cw_files_search_stopping": "جارٍ إيقاف الفهرس…", +"cw_files_search_ready": "الفهرس: {n} ملف.", +"cw_files_search_partial": "أُوقف مبكرًا، لذا تنقصه بعض الملفات.", +"cw_files_search_capped": "امتلأ (200,000 ملف)، لذا تنقصه بعض الملفات.", +"cw_files_search_rebuild": "إنشاء من جديد", +"cw_files_search_cancel": "إلغاء", +"cw_files_search_waiting": "سيبحث عن «{q}» عندما يجهز الفهرس.", +"cw_files_search_searching": "جارٍ البحث…", +"cw_files_search_none": "لا شيء يطابق «{q}».", +"cw_files_search_found": "عُثر على {n}.", +"cw_files_search_more": "تُعرض أول {n} نتيجة. هناك المزيد: جرّب اسمًا أطول.", +"cw_files_search_results": "نتائج البحث", +"cw_files_usage": "استخدام القرص", +"cw_files_usage_open": "فتح في الملفات", +"cw_files_usage_open_row": "فتح {name} في الملفات", +"cw_files_usage_inside": "عرض محتوى {name}", +"cw_files_usage_files_here": "الملفات في هذا المجلد", +"cw_files_usage_running": "جارٍ القياس… {at} من {n} مجلد، {items} عنصر.", +"cw_files_usage_done": "{size} في {items} عنصر.", +"cw_files_usage_capped": "توقف عند {items} عنصر: حُسب {size}، والمجلد يحوي المزيد.", +"cw_files_usage_timeout": "توقف بعد 3 دقائق: حُسب {size}، ولم يُقس الباقي.", +"cw_files_usage_cancelled": "أُلغي: حُسب {size}، ولم يُقس الباقي.", +"cw_files_usage_again": "القياس مجددًا", +"cw_files_usage_busy": "يجري قياس حجم مجلد آخر. أعد المحاولة عند انتهائه.", +"cw_files_usage_skipped": "لم يُقس", +"cw_files_usage_unreadable": "غير قابل للقراءة", }; export default ar; diff --git a/client/src/i18n/locales/bn.ts b/client/src/i18n/locales/bn.ts index c3b9226c..81b4856d 100644 --- a/client/src/i18n/locales/bn.ts +++ b/client/src/i18n/locales/bn.ts @@ -4450,6 +4450,43 @@ c2c_test_deep_bad: "4 KiB-এর পরীক্ষামূলক পাঠা "cw_files_game_ps4": "PS4 গেম ফোল্ডার", "cw_files_game_version": "সংস্করণ {v}", "cw_files_game_fw": "সিস্টেম সফটওয়্যার {v} দরকার", +"cw_files_search": "খুঁজুন", +"cw_files_search_field": "নাম বা প্যাটার্ন", +"cw_files_search_go": "খুঁজুন", +"cw_files_search_scope": "কোথায় খুঁজবেন", +"cw_files_search_all": "সব ড্রাইভ", +"cw_files_search_here": "এই ফোল্ডার", +"cw_files_search_hint": "নাম ধরে ফাইল খোঁজে। একটি শব্দ নামের যেকোনো জায়গায় মিলে যায়; প্যাটার্নের জন্য * ও ? ব্যবহার করুন (*.pkg)।", +"cw_files_search_bad": "উদ্ধৃতিচিহ্ন ও ব্যাকস্ল্যাশ খোঁজা যায় না।", +"cw_files_search_unsupported": "এই কনসোলের PS5Upload খোঁজার জন্য খুব পুরনো। খোঁজ ব্যবহার করতে এটি আপডেট করুন।", +"cw_files_search_no_index": "এখনও কোনো ইনডেক্স নেই। প্রথম খোঁজ একটি তৈরি করে: এটি প্রতিটি ফোল্ডার পড়ে এবং কয়েক মিনিট লাগতে পারে।", +"cw_files_search_building": "ইনডেক্স তৈরি হচ্ছে… এখন পর্যন্ত {n}টি ফাইল।", +"cw_files_search_stopping": "ইনডেক্স থামানো হচ্ছে…", +"cw_files_search_ready": "ইনডেক্স: {n}টি ফাইল।", +"cw_files_search_partial": "এটি আগেই থামানো হয়েছিল, তাই কিছু ফাইল নেই।", +"cw_files_search_capped": "এটি পূর্ণ (200,000টি ফাইল), তাই কিছু ফাইল নেই।", +"cw_files_search_rebuild": "আবার তৈরি করুন", +"cw_files_search_cancel": "বাতিল", +"cw_files_search_waiting": "ইনডেক্স তৈরি হলে “{q}” খুঁজবে।", +"cw_files_search_searching": "খোঁজা হচ্ছে…", +"cw_files_search_none": "“{q}”-এর সাথে কিছু মেলে না।", +"cw_files_search_found": "{n}টি পাওয়া গেছে।", +"cw_files_search_more": "প্রথম {n}টি দেখানো হচ্ছে। আরও আছে: লম্বা নাম দিয়ে চেষ্টা করুন।", +"cw_files_search_results": "খোঁজের ফলাফল", +"cw_files_usage": "ডিস্ক ব্যবহার", +"cw_files_usage_open": "ফাইল-এ খুলুন", +"cw_files_usage_open_row": "{name} ফাইল-এ খুলুন", +"cw_files_usage_inside": "{name}-এর ভেতরে দেখুন", +"cw_files_usage_files_here": "এই ফোল্ডারের ফাইল", +"cw_files_usage_running": "মাপা হচ্ছে… {n}টি ফোল্ডারের মধ্যে {at}, {items}টি আইটেম।", +"cw_files_usage_done": "{items}টি আইটেমে {size}।", +"cw_files_usage_capped": "{items}টি আইটেমে থেমেছে: {size} গোনা হয়েছে, ফোল্ডারে আরও আছে।", +"cw_files_usage_timeout": "৩ মিনিট পর থেমেছে: {size} গোনা হয়েছে, বাকিটা মাপা হয়নি।", +"cw_files_usage_cancelled": "বাতিল হয়েছে: {size} গোনা হয়েছে, বাকিটা মাপা হয়নি।", +"cw_files_usage_again": "আবার মাপুন", +"cw_files_usage_busy": "অন্য একটি ফোল্ডারের আকার মাপা হচ্ছে। সেটি শেষ হলে আবার চেষ্টা করুন।", +"cw_files_usage_skipped": "মাপা হয়নি", +"cw_files_usage_unreadable": "পড়া যায় না", }; export default bn; diff --git a/client/src/i18n/locales/de.ts b/client/src/i18n/locales/de.ts index cb7e4d5f..518fba94 100644 --- a/client/src/i18n/locales/de.ts +++ b/client/src/i18n/locales/de.ts @@ -4458,6 +4458,43 @@ c2c_test_deep_bad: "Die 4-KiB-Testsendung ist nicht angekommen: {detail}", "cw_files_game_ps4": "PS4-Spielordner", "cw_files_game_version": "Version {v}", "cw_files_game_fw": "benötigt Systemsoftware {v}", +"cw_files_search": "Suchen", +"cw_files_search_field": "Name oder Muster", +"cw_files_search_go": "Suchen", +"cw_files_search_scope": "Wo suchen", +"cw_files_search_all": "Alle Laufwerke", +"cw_files_search_here": "Dieser Ordner", +"cw_files_search_hint": "Findet Dateien nach Namen. Ein Wort passt überall im Namen; mit * und ? als Muster (*.pkg).", +"cw_files_search_bad": "Nach Anführungszeichen und Backslashes kann nicht gesucht werden.", +"cw_files_search_unsupported": "PS5Upload auf dieser Konsole ist zu alt für die Suche. Aktualisiere es, um zu suchen.", +"cw_files_search_no_index": "Noch kein Index. Die erste Suche erstellt einen: Sie liest jeden Ordner und kann einige Minuten dauern.", +"cw_files_search_building": "Index wird erstellt… bisher {n} Dateien.", +"cw_files_search_stopping": "Index wird gestoppt…", +"cw_files_search_ready": "Index: {n} Dateien.", +"cw_files_search_partial": "Er wurde vorzeitig gestoppt, daher fehlen einige Dateien.", +"cw_files_search_capped": "Er ist voll (200.000 Dateien), daher fehlen einige Dateien.", +"cw_files_search_rebuild": "Neu erstellen", +"cw_files_search_cancel": "Abbrechen", +"cw_files_search_waiting": "Sucht nach „{q}“, sobald der Index fertig ist.", +"cw_files_search_searching": "Suche…", +"cw_files_search_none": "Nichts passt zu „{q}“.", +"cw_files_search_found": "{n} gefunden.", +"cw_files_search_more": "Die ersten {n} werden angezeigt. Es gibt mehr: Versuche einen längeren Namen.", +"cw_files_search_results": "Suchergebnisse", +"cw_files_usage": "Speicherbelegung", +"cw_files_usage_open": "In Dateien öffnen", +"cw_files_usage_open_row": "{name} in Dateien öffnen", +"cw_files_usage_inside": "In {name} hineinsehen", +"cw_files_usage_files_here": "Dateien in diesem Ordner", +"cw_files_usage_running": "Messe… {at} von {n} Ordnern, {items} Elemente.", +"cw_files_usage_done": "{size} in {items} Elementen.", +"cw_files_usage_capped": "Bei {items} Elementen gestoppt: {size} gezählt, der Ordner enthält mehr.", +"cw_files_usage_timeout": "Nach 3 Minuten gestoppt: {size} gezählt, der Rest wurde nicht gemessen.", +"cw_files_usage_cancelled": "Abgebrochen: {size} gezählt, der Rest wurde nicht gemessen.", +"cw_files_usage_again": "Erneut messen", +"cw_files_usage_busy": "Gerade wird eine andere Ordnergröße gemessen. Versuche es danach erneut.", +"cw_files_usage_skipped": "nicht gemessen", +"cw_files_usage_unreadable": "nicht lesbar", }; export default de; diff --git a/client/src/i18n/locales/en.ts b/client/src/i18n/locales/en.ts index 1619506a..3fb00394 100644 --- a/client/src/i18n/locales/en.ts +++ b/client/src/i18n/locales/en.ts @@ -4773,6 +4773,43 @@ c2c_test_deep_bad: "The 4 KiB test send did not arrive: {detail}", "cw_files_game_ps4": "PS4 game folder", "cw_files_game_version": "version {v}", "cw_files_game_fw": "needs system software {v}", +"cw_files_search": "Search", +"cw_files_search_field": "Name or pattern", +"cw_files_search_go": "Search", +"cw_files_search_scope": "Where to search", +"cw_files_search_all": "All drives", +"cw_files_search_here": "This folder", +"cw_files_search_hint": "Finds files by name. A word matches anywhere in the name; use * and ? for a pattern (*.pkg).", +"cw_files_search_bad": "Quotes and backslashes cannot be searched for.", +"cw_files_search_unsupported": "PS5Upload on this console is too old to search. Update it to use search.", +"cw_files_search_no_index": "No index yet. The first search builds one: it reads every folder and can take a few minutes.", +"cw_files_search_building": "Building the index… {n} files so far.", +"cw_files_search_stopping": "Stopping the index…", +"cw_files_search_ready": "Index: {n} files.", +"cw_files_search_partial": "It was stopped early, so some files are missing.", +"cw_files_search_capped": "It is full (200,000 files), so some files are missing.", +"cw_files_search_rebuild": "Build again", +"cw_files_search_cancel": "Cancel", +"cw_files_search_waiting": "Searches for “{q}” when the index is ready.", +"cw_files_search_searching": "Searching…", +"cw_files_search_none": "Nothing matches “{q}”.", +"cw_files_search_found": "{n} found.", +"cw_files_search_more": "Showing the first {n}. There are more: try a longer name.", +"cw_files_search_results": "Search results", +"cw_files_usage": "Disk usage", +"cw_files_usage_open": "Open in Files", +"cw_files_usage_open_row": "Open {name} in Files", +"cw_files_usage_inside": "Look inside {name}", +"cw_files_usage_files_here": "Files in this folder", +"cw_files_usage_running": "Measuring… {at} of {n} folders, {items} items.", +"cw_files_usage_done": "{size} in {items} items.", +"cw_files_usage_capped": "Stopped at {items} items: {size} counted, the folder holds more.", +"cw_files_usage_timeout": "Stopped after 3 minutes: {size} counted, the rest was not measured.", +"cw_files_usage_cancelled": "Cancelled: {size} counted, the rest was not measured.", +"cw_files_usage_again": "Measure again", +"cw_files_usage_busy": "Another folder size is being measured. Try again when it ends.", +"cw_files_usage_skipped": "not measured", +"cw_files_usage_unreadable": "unreadable", }; export default en; diff --git a/client/src/i18n/locales/es.ts b/client/src/i18n/locales/es.ts index 1614ca8a..e4d1bb44 100644 --- a/client/src/i18n/locales/es.ts +++ b/client/src/i18n/locales/es.ts @@ -4400,6 +4400,43 @@ c2c_test_deep_bad: "El envío de prueba de 4 KiB no llegó: {detail}", "cw_files_game_ps4": "Carpeta de juego de PS4", "cw_files_game_version": "versión {v}", "cw_files_game_fw": "requiere el software del sistema {v}", +"cw_files_search": "Buscar", +"cw_files_search_field": "Nombre o patrón", +"cw_files_search_go": "Buscar", +"cw_files_search_scope": "Dónde buscar", +"cw_files_search_all": "Todas las unidades", +"cw_files_search_here": "Esta carpeta", +"cw_files_search_hint": "Busca archivos por nombre. Una palabra coincide en cualquier parte del nombre; usa * y ? para un patrón (*.pkg).", +"cw_files_search_bad": "No se pueden buscar comillas ni barras invertidas.", +"cw_files_search_unsupported": "PS5Upload en esta consola es demasiado antiguo para buscar. Actualízalo para usar la búsqueda.", +"cw_files_search_no_index": "Aún no hay índice. La primera búsqueda crea uno: lee todas las carpetas y puede tardar unos minutos.", +"cw_files_search_building": "Creando el índice… {n} archivos hasta ahora.", +"cw_files_search_stopping": "Deteniendo el índice…", +"cw_files_search_ready": "Índice: {n} archivos.", +"cw_files_search_partial": "Se detuvo antes de tiempo, así que faltan algunos archivos.", +"cw_files_search_capped": "Está lleno (200.000 archivos), así que faltan algunos archivos.", +"cw_files_search_rebuild": "Crear de nuevo", +"cw_files_search_cancel": "Cancelar", +"cw_files_search_waiting": "Buscará «{q}» cuando el índice esté listo.", +"cw_files_search_searching": "Buscando…", +"cw_files_search_none": "Nada coincide con «{q}».", +"cw_files_search_found": "{n} encontrados.", +"cw_files_search_more": "Se muestran los primeros {n}. Hay más: prueba con un nombre más largo.", +"cw_files_search_results": "Resultados de la búsqueda", +"cw_files_usage": "Uso del disco", +"cw_files_usage_open": "Abrir en Archivos", +"cw_files_usage_open_row": "Abrir {name} en Archivos", +"cw_files_usage_inside": "Ver dentro de {name}", +"cw_files_usage_files_here": "Archivos de esta carpeta", +"cw_files_usage_running": "Midiendo… {at} de {n} carpetas, {items} elementos.", +"cw_files_usage_done": "{size} en {items} elementos.", +"cw_files_usage_capped": "Detenido en {items} elementos: {size} contados, la carpeta contiene más.", +"cw_files_usage_timeout": "Detenido tras 3 minutos: {size} contados, el resto no se midió.", +"cw_files_usage_cancelled": "Cancelado: {size} contados, el resto no se midió.", +"cw_files_usage_again": "Medir de nuevo", +"cw_files_usage_busy": "Se está midiendo el tamaño de otra carpeta. Vuelve a intentarlo cuando termine.", +"cw_files_usage_skipped": "sin medir", +"cw_files_usage_unreadable": "ilegible", }; export default es; diff --git a/client/src/i18n/locales/fa.ts b/client/src/i18n/locales/fa.ts index a223c6c3..2b46bba5 100644 --- a/client/src/i18n/locales/fa.ts +++ b/client/src/i18n/locales/fa.ts @@ -4751,6 +4751,43 @@ c2c_test_deep_bad: "ارسال آزمایشی ۴ KiB نرسید: {detail}", "cw_files_game_ps4": "پوشهٔ بازی PS4", "cw_files_game_version": "نسخهٔ {v}", "cw_files_game_fw": "به نرم‌افزار سیستم {v} نیاز دارد", +"cw_files_search": "جستجو", +"cw_files_search_field": "نام یا الگو", +"cw_files_search_go": "جستجو", +"cw_files_search_scope": "محل جستجو", +"cw_files_search_all": "همهٔ درایوها", +"cw_files_search_here": "همین پوشه", +"cw_files_search_hint": "فایل‌ها را بر اساس نام پیدا می‌کند. یک واژه با هر بخشی از نام جور می‌شود؛ برای الگو از * و ? استفاده کنید (*.pkg).", +"cw_files_search_bad": "نمی‌توان نقل‌قول و بک‌اسلش را جستجو کرد.", +"cw_files_search_unsupported": "PS5Upload روی این کنسول برای جستجو قدیمی است. برای جستجو آن را به‌روز کنید.", +"cw_files_search_no_index": "هنوز نمایه‌ای نیست. نخستین جستجو آن را می‌سازد: همهٔ پوشه‌ها را می‌خواند و ممکن است چند دقیقه طول بکشد.", +"cw_files_search_building": "در حال ساخت نمایه… تاکنون {n} فایل.", +"cw_files_search_stopping": "در حال توقف نمایه…", +"cw_files_search_ready": "نمایه: {n} فایل.", +"cw_files_search_partial": "زودتر متوقف شد، پس برخی فایل‌ها در آن نیستند.", +"cw_files_search_capped": "پر شده است (۲۰۰٬۰۰۰ فایل)، پس برخی فایل‌ها در آن نیستند.", +"cw_files_search_rebuild": "ساخت دوباره", +"cw_files_search_cancel": "لغو", +"cw_files_search_waiting": "وقتی نمایه آماده شد «{q}» را جستجو می‌کند.", +"cw_files_search_searching": "در حال جستجو…", +"cw_files_search_none": "چیزی با «{q}» جور نیست.", +"cw_files_search_found": "{n} مورد پیدا شد.", +"cw_files_search_more": "{n} مورد نخست نمایش داده می‌شود. موارد بیشتری هست: نام بلندتری امتحان کنید.", +"cw_files_search_results": "نتایج جستجو", +"cw_files_usage": "فضای اشغال‌شده", +"cw_files_usage_open": "باز کردن در فایل‌ها", +"cw_files_usage_open_row": "باز کردن {name} در فایل‌ها", +"cw_files_usage_inside": "دیدن درون {name}", +"cw_files_usage_files_here": "فایل‌های این پوشه", +"cw_files_usage_running": "در حال اندازه‌گیری… {at} از {n} پوشه، {items} مورد.", +"cw_files_usage_done": "{size} در {items} مورد.", +"cw_files_usage_capped": "در {items} مورد متوقف شد: {size} شمرده شد، پوشه بیشتر دارد.", +"cw_files_usage_timeout": "پس از ۳ دقیقه متوقف شد: {size} شمرده شد، بقیه اندازه‌گیری نشد.", +"cw_files_usage_cancelled": "لغو شد: {size} شمرده شد، بقیه اندازه‌گیری نشد.", +"cw_files_usage_again": "اندازه‌گیری دوباره", +"cw_files_usage_busy": "اندازهٔ پوشهٔ دیگری در حال اندازه‌گیری است. پس از پایان آن دوباره امتحان کنید.", +"cw_files_usage_skipped": "اندازه‌گیری نشده", +"cw_files_usage_unreadable": "خواندنی نیست", }; export default fa; diff --git a/client/src/i18n/locales/fr.ts b/client/src/i18n/locales/fr.ts index b410a98c..4fc39868 100644 --- a/client/src/i18n/locales/fr.ts +++ b/client/src/i18n/locales/fr.ts @@ -4468,6 +4468,43 @@ c2c_test_deep_bad: "L’envoi test de 4 Kio n’est pas arrivé : {detail}", "cw_files_game_ps4": "Dossier de jeu PS4", "cw_files_game_version": "version {v}", "cw_files_game_fw": "nécessite le logiciel système {v}", +"cw_files_search": "Rechercher", +"cw_files_search_field": "Nom ou motif", +"cw_files_search_go": "Rechercher", +"cw_files_search_scope": "Où chercher", +"cw_files_search_all": "Tous les disques", +"cw_files_search_here": "Ce dossier", +"cw_files_search_hint": "Trouve des fichiers par leur nom. Un mot correspond n’importe où dans le nom ; utilisez * et ? pour un motif (*.pkg).", +"cw_files_search_bad": "Impossible de rechercher des guillemets ou des barres obliques inverses.", +"cw_files_search_unsupported": "PS5Upload sur cette console est trop ancien pour rechercher. Mettez-le à jour pour utiliser la recherche.", +"cw_files_search_no_index": "Pas encore d’index. La première recherche en crée un : elle lit chaque dossier et peut prendre quelques minutes.", +"cw_files_search_building": "Création de l’index… {n} fichiers jusqu’ici.", +"cw_files_search_stopping": "Arrêt de l’index…", +"cw_files_search_ready": "Index : {n} fichiers.", +"cw_files_search_partial": "Il a été arrêté avant la fin ; il manque donc des fichiers.", +"cw_files_search_capped": "Il est plein (200 000 fichiers) ; il manque donc des fichiers.", +"cw_files_search_rebuild": "Recréer", +"cw_files_search_cancel": "Annuler", +"cw_files_search_waiting": "Recherchera « {q} » quand l’index sera prêt.", +"cw_files_search_searching": "Recherche…", +"cw_files_search_none": "Rien ne correspond à « {q} ».", +"cw_files_search_found": "{n} trouvés.", +"cw_files_search_more": "Les {n} premiers sont affichés. Il y en a d’autres : essayez un nom plus long.", +"cw_files_search_results": "Résultats de la recherche", +"cw_files_usage": "Utilisation du disque", +"cw_files_usage_open": "Ouvrir dans Fichiers", +"cw_files_usage_open_row": "Ouvrir {name} dans Fichiers", +"cw_files_usage_inside": "Voir le contenu de {name}", +"cw_files_usage_files_here": "Fichiers de ce dossier", +"cw_files_usage_running": "Mesure… {at} sur {n} dossiers, {items} éléments.", +"cw_files_usage_done": "{size} en {items} éléments.", +"cw_files_usage_capped": "Arrêté à {items} éléments : {size} comptés, le dossier en contient davantage.", +"cw_files_usage_timeout": "Arrêté après 3 minutes : {size} comptés, le reste n’a pas été mesuré.", +"cw_files_usage_cancelled": "Annulé : {size} comptés, le reste n’a pas été mesuré.", +"cw_files_usage_again": "Mesurer à nouveau", +"cw_files_usage_busy": "La taille d’un autre dossier est en cours de mesure. Réessayez quand elle sera terminée.", +"cw_files_usage_skipped": "non mesuré", +"cw_files_usage_unreadable": "illisible", }; export default fr; diff --git a/client/src/i18n/locales/hi.ts b/client/src/i18n/locales/hi.ts index a4ada565..65d64e85 100644 --- a/client/src/i18n/locales/hi.ts +++ b/client/src/i18n/locales/hi.ts @@ -4466,6 +4466,43 @@ c2c_test_deep_bad: "4 KiB का परीक्षण नहीं पहुँ "cw_files_game_ps4": "PS4 गेम फ़ोल्डर", "cw_files_game_version": "संस्करण {v}", "cw_files_game_fw": "सिस्टम सॉफ़्टवेयर {v} चाहिए", +"cw_files_search": "खोजें", +"cw_files_search_field": "नाम या पैटर्न", +"cw_files_search_go": "खोजें", +"cw_files_search_scope": "कहाँ खोजें", +"cw_files_search_all": "सभी ड्राइव", +"cw_files_search_here": "यह फ़ोल्डर", +"cw_files_search_hint": "नाम से फ़ाइलें ढूँढता है। कोई शब्द नाम में कहीं भी मेल खाता है; पैटर्न के लिए * और ? का उपयोग करें (*.pkg)।", +"cw_files_search_bad": "उद्धरण चिह्न और बैकस्लैश नहीं खोजे जा सकते।", +"cw_files_search_unsupported": "इस कंसोल पर PS5Upload खोज के लिए बहुत पुराना है। खोज इस्तेमाल करने के लिए इसे अपडेट करें।", +"cw_files_search_no_index": "अभी कोई इंडेक्स नहीं है। पहली खोज इसे बनाती है: यह हर फ़ोल्डर पढ़ती है और कुछ मिनट लग सकते हैं।", +"cw_files_search_building": "इंडेक्स बन रहा है… अब तक {n} फ़ाइलें।", +"cw_files_search_stopping": "इंडेक्स रोका जा रहा है…", +"cw_files_search_ready": "इंडेक्स: {n} फ़ाइलें।", +"cw_files_search_partial": "इसे पहले ही रोक दिया गया, इसलिए कुछ फ़ाइलें नहीं हैं।", +"cw_files_search_capped": "यह भर गया है (200,000 फ़ाइलें), इसलिए कुछ फ़ाइलें नहीं हैं।", +"cw_files_search_rebuild": "फिर से बनाएँ", +"cw_files_search_cancel": "रद्द करें", +"cw_files_search_waiting": "इंडेक्स तैयार होने पर “{q}” खोजेगा।", +"cw_files_search_searching": "खोज रहा है…", +"cw_files_search_none": "“{q}” से कुछ मेल नहीं खाता।", +"cw_files_search_found": "{n} मिले।", +"cw_files_search_more": "पहले {n} दिखाए जा रहे हैं। और भी हैं: लंबा नाम आज़माएँ।", +"cw_files_search_results": "खोज परिणाम", +"cw_files_usage": "डिस्क उपयोग", +"cw_files_usage_open": "फ़ाइलें सेक्शन में खोलें", +"cw_files_usage_open_row": "{name} को फ़ाइलें सेक्शन में खोलें", +"cw_files_usage_inside": "{name} के अंदर देखें", +"cw_files_usage_files_here": "इस फ़ोल्डर की फ़ाइलें", +"cw_files_usage_running": "माप रहा है… {n} में से {at} फ़ोल्डर, {items} आइटम।", +"cw_files_usage_done": "{items} आइटम में {size}।", +"cw_files_usage_capped": "{items} आइटम पर रुका: {size} गिना गया, फ़ोल्डर में और है।", +"cw_files_usage_timeout": "3 मिनट बाद रुका: {size} गिना गया, बाकी नहीं मापा गया।", +"cw_files_usage_cancelled": "रद्द किया गया: {size} गिना गया, बाकी नहीं मापा गया।", +"cw_files_usage_again": "फिर से मापें", +"cw_files_usage_busy": "किसी दूसरे फ़ोल्डर का आकार मापा जा रहा है। उसके खत्म होने पर फिर कोशिश करें।", +"cw_files_usage_skipped": "नहीं मापा गया", +"cw_files_usage_unreadable": "पढ़ा नहीं जा सकता", }; export default hi; diff --git a/client/src/i18n/locales/hu.ts b/client/src/i18n/locales/hu.ts index 156a9969..571e06f2 100644 --- a/client/src/i18n/locales/hu.ts +++ b/client/src/i18n/locales/hu.ts @@ -4765,6 +4765,43 @@ c2c_test_deep_bad: "A 4 KiB-os tesztküldés nem érkezett meg: {detail}", "cw_files_game_ps4": "PS4-játékmappa", "cw_files_game_version": "verzió: {v}", "cw_files_game_fw": "rendszerszoftver szükséges: {v}", +"cw_files_search": "Keresés", +"cw_files_search_field": "Név vagy minta", +"cw_files_search_go": "Keresés", +"cw_files_search_scope": "Hol keressen", +"cw_files_search_all": "Minden meghajtó", +"cw_files_search_here": "Ez a mappa", +"cw_files_search_hint": "Fájlokat keres név alapján. Egy szó a név bármely részén egyezhet; mintához használd a * és ? jelet (*.pkg).", +"cw_files_search_bad": "Idézőjelre és fordított perjelre nem lehet keresni.", +"cw_files_search_unsupported": "A konzolon lévő PS5Upload túl régi a kereséshez. Frissítsd, hogy kereshess.", +"cw_files_search_no_index": "Még nincs index. Az első keresés létrehozza: minden mappát beolvas, és néhány percig tarthat.", +"cw_files_search_building": "Index készítése… eddig {n} fájl.", +"cw_files_search_stopping": "Index leállítása…", +"cw_files_search_ready": "Index: {n} fájl.", +"cw_files_search_partial": "Idő előtt leállt, ezért néhány fájl hiányzik.", +"cw_files_search_capped": "Megtelt (200 000 fájl), ezért néhány fájl hiányzik.", +"cw_files_search_rebuild": "Újraépítés", +"cw_files_search_cancel": "Mégse", +"cw_files_search_waiting": "Rákeres erre: „{q}”, amint kész az index.", +"cw_files_search_searching": "Keresés…", +"cw_files_search_none": "Semmi sem egyezik ezzel: „{q}”.", +"cw_files_search_found": "{n} találat.", +"cw_files_search_more": "Az első {n} látszik. Van több is: próbálj hosszabb nevet.", +"cw_files_search_results": "Keresési eredmények", +"cw_files_usage": "Lemezhasználat", +"cw_files_usage_open": "Megnyitás a Fájlokban", +"cw_files_usage_open_row": "{name} megnyitása a Fájlokban", +"cw_files_usage_inside": "Belenézés: {name}", +"cw_files_usage_files_here": "A mappa fájljai", +"cw_files_usage_running": "Mérés… {at}/{n} mappa, {items} elem.", +"cw_files_usage_done": "{size}, {items} elemben.", +"cw_files_usage_capped": "{items} elemnél leállt: {size} megszámolva, a mappában több van.", +"cw_files_usage_timeout": "3 perc után leállt: {size} megszámolva, a többit nem mértük.", +"cw_files_usage_cancelled": "Megszakítva: {size} megszámolva, a többit nem mértük.", +"cw_files_usage_again": "Mérés újra", +"cw_files_usage_busy": "Most egy másik mappa mérete mérődik. Próbáld újra, ha végzett.", +"cw_files_usage_skipped": "nincs mérve", +"cw_files_usage_unreadable": "nem olvasható", }; export default hu; diff --git a/client/src/i18n/locales/id.ts b/client/src/i18n/locales/id.ts index 167fe6ac..6e6db423 100644 --- a/client/src/i18n/locales/id.ts +++ b/client/src/i18n/locales/id.ts @@ -4459,6 +4459,43 @@ c2c_test_deep_bad: "Kiriman uji 4 KiB tidak tiba: {detail}", "cw_files_game_ps4": "Folder game PS4", "cw_files_game_version": "versi {v}", "cw_files_game_fw": "perlu perangkat lunak sistem {v}", +"cw_files_search": "Cari", +"cw_files_search_field": "Nama atau pola", +"cw_files_search_go": "Cari", +"cw_files_search_scope": "Tempat mencari", +"cw_files_search_all": "Semua drive", +"cw_files_search_here": "Folder ini", +"cw_files_search_hint": "Mencari file menurut nama. Sebuah kata cocok di bagian mana pun dari nama; gunakan * dan ? untuk pola (*.pkg).", +"cw_files_search_bad": "Tanda kutip dan garis miring terbalik tidak dapat dicari.", +"cw_files_search_unsupported": "PS5Upload di konsol ini terlalu lama untuk mencari. Perbarui untuk memakai pencarian.", +"cw_files_search_no_index": "Belum ada indeks. Pencarian pertama membuatnya: membaca setiap folder dan bisa memakan beberapa menit.", +"cw_files_search_building": "Membuat indeks… {n} file sejauh ini.", +"cw_files_search_stopping": "Menghentikan indeks…", +"cw_files_search_ready": "Indeks: {n} file.", +"cw_files_search_partial": "Indeks dihentikan lebih awal, jadi beberapa file tidak ada.", +"cw_files_search_capped": "Indeks penuh (200.000 file), jadi beberapa file tidak ada.", +"cw_files_search_rebuild": "Buat ulang", +"cw_files_search_cancel": "Batal", +"cw_files_search_waiting": "Akan mencari “{q}” saat indeks siap.", +"cw_files_search_searching": "Mencari…", +"cw_files_search_none": "Tidak ada yang cocok dengan “{q}”.", +"cw_files_search_found": "{n} ditemukan.", +"cw_files_search_more": "Menampilkan {n} pertama. Masih ada lagi: coba nama yang lebih panjang.", +"cw_files_search_results": "Hasil pencarian", +"cw_files_usage": "Penggunaan disk", +"cw_files_usage_open": "Buka di File", +"cw_files_usage_open_row": "Buka {name} di File", +"cw_files_usage_inside": "Lihat isi {name}", +"cw_files_usage_files_here": "File di folder ini", +"cw_files_usage_running": "Mengukur… {at} dari {n} folder, {items} item.", +"cw_files_usage_done": "{size} dalam {items} item.", +"cw_files_usage_capped": "Berhenti di {items} item: {size} terhitung, folder berisi lebih banyak.", +"cw_files_usage_timeout": "Berhenti setelah 3 menit: {size} terhitung, sisanya tidak diukur.", +"cw_files_usage_cancelled": "Dibatalkan: {size} terhitung, sisanya tidak diukur.", +"cw_files_usage_again": "Ukur lagi", +"cw_files_usage_busy": "Ukuran folder lain sedang diukur. Coba lagi setelah selesai.", +"cw_files_usage_skipped": "tidak diukur", +"cw_files_usage_unreadable": "tidak terbaca", }; export default id; diff --git a/client/src/i18n/locales/it.ts b/client/src/i18n/locales/it.ts index daef9949..6fb45a74 100644 --- a/client/src/i18n/locales/it.ts +++ b/client/src/i18n/locales/it.ts @@ -4400,6 +4400,43 @@ c2c_test_deep_bad: "L’invio di prova di 4 KiB non è arrivato: {detail}", "cw_files_game_ps4": "Cartella di gioco PS4", "cw_files_game_version": "versione {v}", "cw_files_game_fw": "richiede il software di sistema {v}", +"cw_files_search": "Cerca", +"cw_files_search_field": "Nome o modello", +"cw_files_search_go": "Cerca", +"cw_files_search_scope": "Dove cercare", +"cw_files_search_all": "Tutte le unità", +"cw_files_search_here": "Questa cartella", +"cw_files_search_hint": "Trova i file per nome. Una parola corrisponde in qualsiasi punto del nome; usa * e ? per un modello (*.pkg).", +"cw_files_search_bad": "Non si possono cercare virgolette o barre rovesciate.", +"cw_files_search_unsupported": "PS5Upload su questa console è troppo vecchio per cercare. Aggiornalo per usare la ricerca.", +"cw_files_search_no_index": "Nessun indice ancora. La prima ricerca ne crea uno: legge ogni cartella e può richiedere alcuni minuti.", +"cw_files_search_building": "Creazione dell’indice… {n} file finora.", +"cw_files_search_stopping": "Arresto dell’indice…", +"cw_files_search_ready": "Indice: {n} file.", +"cw_files_search_partial": "È stato fermato prima della fine, quindi mancano alcuni file.", +"cw_files_search_capped": "È pieno (200.000 file), quindi mancano alcuni file.", +"cw_files_search_rebuild": "Ricrea", +"cw_files_search_cancel": "Annulla", +"cw_files_search_waiting": "Cercherà «{q}» quando l’indice sarà pronto.", +"cw_files_search_searching": "Ricerca…", +"cw_files_search_none": "Nessun risultato per «{q}».", +"cw_files_search_found": "{n} trovati.", +"cw_files_search_more": "Vengono mostrati i primi {n}. Ce ne sono altri: prova un nome più lungo.", +"cw_files_search_results": "Risultati della ricerca", +"cw_files_usage": "Utilizzo del disco", +"cw_files_usage_open": "Apri in File", +"cw_files_usage_open_row": "Apri {name} in File", +"cw_files_usage_inside": "Guarda dentro {name}", +"cw_files_usage_files_here": "File in questa cartella", +"cw_files_usage_running": "Misurazione… {at} di {n} cartelle, {items} elementi.", +"cw_files_usage_done": "{size} in {items} elementi.", +"cw_files_usage_capped": "Fermato a {items} elementi: {size} contati, la cartella ne contiene di più.", +"cw_files_usage_timeout": "Fermato dopo 3 minuti: {size} contati, il resto non è stato misurato.", +"cw_files_usage_cancelled": "Annullato: {size} contati, il resto non è stato misurato.", +"cw_files_usage_again": "Misura di nuovo", +"cw_files_usage_busy": "È in corso la misura di un’altra cartella. Riprova quando è finita.", +"cw_files_usage_skipped": "non misurato", +"cw_files_usage_unreadable": "illeggibile", }; export default it; diff --git a/client/src/i18n/locales/ja.ts b/client/src/i18n/locales/ja.ts index dde7716f..9dc9a77f 100644 --- a/client/src/i18n/locales/ja.ts +++ b/client/src/i18n/locales/ja.ts @@ -4471,6 +4471,43 @@ c2c_test_deep_bad: "4 KiB のテスト送信が届きませんでした: {detail "cw_files_game_ps4": "PS4 ゲームフォルダー", "cw_files_game_version": "バージョン {v}", "cw_files_game_fw": "システムソフトウェア {v} が必要", +"cw_files_search": "検索", +"cw_files_search_field": "名前またはパターン", +"cw_files_search_go": "検索", +"cw_files_search_scope": "検索する場所", +"cw_files_search_all": "すべてのドライブ", +"cw_files_search_here": "このフォルダー", +"cw_files_search_hint": "名前でファイルを探します。単語は名前のどこにあっても一致します。パターンには * と ? を使います(*.pkg)。", +"cw_files_search_bad": "引用符とバックスラッシュは検索できません。", +"cw_files_search_unsupported": "この本体の PS5Upload は古いため検索できません。検索を使うには更新してください。", +"cw_files_search_no_index": "まだインデックスがありません。最初の検索で作成します。すべてのフォルダーを読むため数分かかることがあります。", +"cw_files_search_building": "インデックスを作成中… これまでに {n} ファイル。", +"cw_files_search_stopping": "インデックスを停止中…", +"cw_files_search_ready": "インデックス: {n} ファイル。", +"cw_files_search_partial": "途中で停止したため、一部のファイルがありません。", +"cw_files_search_capped": "上限(200,000 ファイル)に達したため、一部のファイルがありません。", +"cw_files_search_rebuild": "作り直す", +"cw_files_search_cancel": "キャンセル", +"cw_files_search_waiting": "インデックスの準備ができたら「{q}」を検索します。", +"cw_files_search_searching": "検索中…", +"cw_files_search_none": "「{q}」に一致するものはありません。", +"cw_files_search_found": "{n} 件見つかりました。", +"cw_files_search_more": "最初の {n} 件を表示しています。さらにあります。もっと長い名前で試してください。", +"cw_files_search_results": "検索結果", +"cw_files_usage": "ディスク使用量", +"cw_files_usage_open": "ファイルで開く", +"cw_files_usage_open_row": "{name} をファイルで開く", +"cw_files_usage_inside": "{name} の中を見る", +"cw_files_usage_files_here": "このフォルダー内のファイル", +"cw_files_usage_running": "計測中… {n} フォルダー中 {at}、{items} 項目。", +"cw_files_usage_done": "{items} 項目で {size}。", +"cw_files_usage_capped": "{items} 項目で停止: {size} を計上、フォルダーにはさらにあります。", +"cw_files_usage_timeout": "3 分で停止: {size} を計上、残りは計測していません。", +"cw_files_usage_cancelled": "キャンセルしました: {size} を計上、残りは計測していません。", +"cw_files_usage_again": "もう一度計測", +"cw_files_usage_busy": "別のフォルダーのサイズを計測中です。終わってから再試行してください。", +"cw_files_usage_skipped": "未計測", +"cw_files_usage_unreadable": "読み取れません", }; export default ja; diff --git a/client/src/i18n/locales/ko.ts b/client/src/i18n/locales/ko.ts index 6c2ff8f2..17d207e9 100644 --- a/client/src/i18n/locales/ko.ts +++ b/client/src/i18n/locales/ko.ts @@ -4469,6 +4469,43 @@ c2c_test_deep_bad: "4 KiB 테스트 전송이 도착하지 않았습니다: {det "cw_files_game_ps4": "PS4 게임 폴더", "cw_files_game_version": "버전 {v}", "cw_files_game_fw": "시스템 소프트웨어 {v} 필요", +"cw_files_search": "검색", +"cw_files_search_field": "이름 또는 패턴", +"cw_files_search_go": "검색", +"cw_files_search_scope": "검색 위치", +"cw_files_search_all": "모든 드라이브", +"cw_files_search_here": "이 폴더", +"cw_files_search_hint": "이름으로 파일을 찾습니다. 단어는 이름의 어느 부분과도 일치하며, 패턴에는 *와 ?를 사용합니다(*.pkg).", +"cw_files_search_bad": "따옴표와 백슬래시는 검색할 수 없습니다.", +"cw_files_search_unsupported": "이 콘솔의 PS5Upload가 오래되어 검색할 수 없습니다. 검색을 사용하려면 업데이트하세요.", +"cw_files_search_no_index": "아직 색인이 없습니다. 첫 검색에서 색인을 만듭니다. 모든 폴더를 읽으므로 몇 분 걸릴 수 있습니다.", +"cw_files_search_building": "색인 생성 중… 지금까지 {n}개 파일.", +"cw_files_search_stopping": "색인 중지 중…", +"cw_files_search_ready": "색인: {n}개 파일.", +"cw_files_search_partial": "일찍 중지되어 일부 파일이 빠져 있습니다.", +"cw_files_search_capped": "가득 차서(200,000개 파일) 일부 파일이 빠져 있습니다.", +"cw_files_search_rebuild": "다시 만들기", +"cw_files_search_cancel": "취소", +"cw_files_search_waiting": "색인이 준비되면 “{q}”을(를) 검색합니다.", +"cw_files_search_searching": "검색 중…", +"cw_files_search_none": "“{q}”과(와) 일치하는 항목이 없습니다.", +"cw_files_search_found": "{n}개 찾음.", +"cw_files_search_more": "처음 {n}개를 표시합니다. 더 있습니다. 더 긴 이름으로 시도하세요.", +"cw_files_search_results": "검색 결과", +"cw_files_usage": "디스크 사용량", +"cw_files_usage_open": "파일에서 열기", +"cw_files_usage_open_row": "{name}을(를) 파일에서 열기", +"cw_files_usage_inside": "{name} 안 보기", +"cw_files_usage_files_here": "이 폴더의 파일", +"cw_files_usage_running": "측정 중… 폴더 {n}개 중 {at}개, 항목 {items}개.", +"cw_files_usage_done": "항목 {items}개에 {size}.", +"cw_files_usage_capped": "항목 {items}개에서 중지: {size} 집계, 폴더에 더 있습니다.", +"cw_files_usage_timeout": "3분 후 중지: {size} 집계, 나머지는 측정하지 않았습니다.", +"cw_files_usage_cancelled": "취소됨: {size} 집계, 나머지는 측정하지 않았습니다.", +"cw_files_usage_again": "다시 측정", +"cw_files_usage_busy": "다른 폴더 크기를 측정하는 중입니다. 끝난 뒤 다시 시도하세요.", +"cw_files_usage_skipped": "측정 안 됨", +"cw_files_usage_unreadable": "읽을 수 없음", }; export default ko; diff --git a/client/src/i18n/locales/pl.ts b/client/src/i18n/locales/pl.ts index 6a251faa..c94c0f91 100644 --- a/client/src/i18n/locales/pl.ts +++ b/client/src/i18n/locales/pl.ts @@ -4705,6 +4705,43 @@ c2c_test_deep_bad: "Testowa wysyłka 4 KiB nie dotarła: {detail}", "cw_files_game_ps4": "Folder gry PS4", "cw_files_game_version": "wersja {v}", "cw_files_game_fw": "wymaga oprogramowania systemowego {v}", +"cw_files_search": "Szukaj", +"cw_files_search_field": "Nazwa lub wzorzec", +"cw_files_search_go": "Szukaj", +"cw_files_search_scope": "Gdzie szukać", +"cw_files_search_all": "Wszystkie dyski", +"cw_files_search_here": "Ten folder", +"cw_files_search_hint": "Znajduje pliki po nazwie. Słowo pasuje w dowolnym miejscu nazwy; użyj * i ? jako wzorca (*.pkg).", +"cw_files_search_bad": "Nie można szukać cudzysłowów ani ukośników wstecznych.", +"cw_files_search_unsupported": "PS5Upload na tej konsoli jest za stary, by szukać. Zaktualizuj go, aby korzystać z wyszukiwania.", +"cw_files_search_no_index": "Nie ma jeszcze indeksu. Pierwsze wyszukiwanie go zbuduje: odczyta każdy folder i może potrwać kilka minut.", +"cw_files_search_building": "Budowanie indeksu… dotąd {n} plików.", +"cw_files_search_stopping": "Zatrzymywanie indeksu…", +"cw_files_search_ready": "Indeks: {n} plików.", +"cw_files_search_partial": "Zatrzymano go przed końcem, więc brakuje niektórych plików.", +"cw_files_search_capped": "Jest pełny (200 000 plików), więc brakuje niektórych plików.", +"cw_files_search_rebuild": "Zbuduj ponownie", +"cw_files_search_cancel": "Anuluj", +"cw_files_search_waiting": "Wyszuka „{q}”, gdy indeks będzie gotowy.", +"cw_files_search_searching": "Wyszukiwanie…", +"cw_files_search_none": "Nic nie pasuje do „{q}”.", +"cw_files_search_found": "Znaleziono: {n}.", +"cw_files_search_more": "Pokazano pierwsze {n}. Jest ich więcej: spróbuj dłuższej nazwy.", +"cw_files_search_results": "Wyniki wyszukiwania", +"cw_files_usage": "Zajęte miejsce", +"cw_files_usage_open": "Otwórz w Plikach", +"cw_files_usage_open_row": "Otwórz {name} w Plikach", +"cw_files_usage_inside": "Zajrzyj do {name}", +"cw_files_usage_files_here": "Pliki w tym folderze", +"cw_files_usage_running": "Mierzenie… {at} z {n} folderów, {items} elementów.", +"cw_files_usage_done": "{size} w {items} elementach.", +"cw_files_usage_capped": "Zatrzymano przy {items} elementach: policzono {size}, folder zawiera więcej.", +"cw_files_usage_timeout": "Zatrzymano po 3 minutach: policzono {size}, reszty nie zmierzono.", +"cw_files_usage_cancelled": "Anulowano: policzono {size}, reszty nie zmierzono.", +"cw_files_usage_again": "Zmierz ponownie", +"cw_files_usage_busy": "Trwa mierzenie rozmiaru innego folderu. Spróbuj ponownie, gdy się skończy.", +"cw_files_usage_skipped": "nie zmierzono", +"cw_files_usage_unreadable": "nieczytelny", }; export default pl; diff --git a/client/src/i18n/locales/pt-BR.ts b/client/src/i18n/locales/pt-BR.ts index 6e404e42..8f7fe023 100644 --- a/client/src/i18n/locales/pt-BR.ts +++ b/client/src/i18n/locales/pt-BR.ts @@ -4467,6 +4467,43 @@ c2c_test_deep_bad: "O envio de teste de 4 KiB não chegou: {detail}", "cw_files_game_ps4": "Pasta de jogo de PS4", "cw_files_game_version": "versão {v}", "cw_files_game_fw": "requer o software do sistema {v}", +"cw_files_search": "Pesquisar", +"cw_files_search_field": "Nome ou padrão", +"cw_files_search_go": "Pesquisar", +"cw_files_search_scope": "Onde pesquisar", +"cw_files_search_all": "Todas as unidades", +"cw_files_search_here": "Esta pasta", +"cw_files_search_hint": "Encontra arquivos pelo nome. Uma palavra corresponde em qualquer parte do nome; use * e ? para um padrão (*.pkg).", +"cw_files_search_bad": "Não é possível pesquisar aspas nem barras invertidas.", +"cw_files_search_unsupported": "O PS5Upload deste console é antigo demais para pesquisar. Atualize-o para usar a pesquisa.", +"cw_files_search_no_index": "Ainda não há índice. A primeira pesquisa cria um: ela lê todas as pastas e pode levar alguns minutos.", +"cw_files_search_building": "Criando o índice… {n} arquivos até agora.", +"cw_files_search_stopping": "Parando o índice…", +"cw_files_search_ready": "Índice: {n} arquivos.", +"cw_files_search_partial": "Ele foi interrompido antes do fim, então faltam alguns arquivos.", +"cw_files_search_capped": "Ele está cheio (200.000 arquivos), então faltam alguns arquivos.", +"cw_files_search_rebuild": "Criar de novo", +"cw_files_search_cancel": "Cancelar", +"cw_files_search_waiting": "Vai pesquisar “{q}” quando o índice estiver pronto.", +"cw_files_search_searching": "Pesquisando…", +"cw_files_search_none": "Nada corresponde a “{q}”.", +"cw_files_search_found": "{n} encontrados.", +"cw_files_search_more": "Mostrando os primeiros {n}. Há mais: tente um nome mais longo.", +"cw_files_search_results": "Resultados da pesquisa", +"cw_files_usage": "Uso do disco", +"cw_files_usage_open": "Abrir em Arquivos", +"cw_files_usage_open_row": "Abrir {name} em Arquivos", +"cw_files_usage_inside": "Ver dentro de {name}", +"cw_files_usage_files_here": "Arquivos nesta pasta", +"cw_files_usage_running": "Medindo… {at} de {n} pastas, {items} itens.", +"cw_files_usage_done": "{size} em {items} itens.", +"cw_files_usage_capped": "Parou em {items} itens: {size} contados, a pasta tem mais.", +"cw_files_usage_timeout": "Parou após 3 minutos: {size} contados, o resto não foi medido.", +"cw_files_usage_cancelled": "Cancelado: {size} contados, o resto não foi medido.", +"cw_files_usage_again": "Medir de novo", +"cw_files_usage_busy": "O tamanho de outra pasta está sendo medido. Tente de novo quando terminar.", +"cw_files_usage_skipped": "não medido", +"cw_files_usage_unreadable": "ilegível", }; export default pt_BR; diff --git a/client/src/i18n/locales/ru.ts b/client/src/i18n/locales/ru.ts index 6560e94f..f5ee81ef 100644 --- a/client/src/i18n/locales/ru.ts +++ b/client/src/i18n/locales/ru.ts @@ -4400,6 +4400,43 @@ c2c_test_deep_bad: "Тестовая отправка 4 КиБ не дошла: "cw_files_game_ps4": "Папка игры PS4", "cw_files_game_version": "версия {v}", "cw_files_game_fw": "требуется системное ПО {v}", +"cw_files_search": "Поиск", +"cw_files_search_field": "Имя или шаблон", +"cw_files_search_go": "Найти", +"cw_files_search_scope": "Где искать", +"cw_files_search_all": "Все диски", +"cw_files_search_here": "Эта папка", +"cw_files_search_hint": "Ищет файлы по имени. Слово совпадает в любом месте имени; для шаблона используйте * и ? (*.pkg).", +"cw_files_search_bad": "Кавычки и обратную косую черту искать нельзя.", +"cw_files_search_unsupported": "PS5Upload на этой консоли слишком старый для поиска. Обновите его, чтобы искать.", +"cw_files_search_no_index": "Индекса пока нет. Первый поиск создаст его: он читает каждую папку и может занять несколько минут.", +"cw_files_search_building": "Создание индекса… пока {n} файлов.", +"cw_files_search_stopping": "Остановка индекса…", +"cw_files_search_ready": "Индекс: {n} файлов.", +"cw_files_search_partial": "Он был остановлен раньше времени, поэтому некоторых файлов нет.", +"cw_files_search_capped": "Он заполнен (200 000 файлов), поэтому некоторых файлов нет.", +"cw_files_search_rebuild": "Создать заново", +"cw_files_search_cancel": "Отмена", +"cw_files_search_waiting": "Найдёт «{q}», когда индекс будет готов.", +"cw_files_search_searching": "Поиск…", +"cw_files_search_none": "Ничего не найдено по «{q}».", +"cw_files_search_found": "Найдено: {n}.", +"cw_files_search_more": "Показаны первые {n}. Есть ещё: попробуйте имя подлиннее.", +"cw_files_search_results": "Результаты поиска", +"cw_files_usage": "Занятое место", +"cw_files_usage_open": "Открыть в «Файлах»", +"cw_files_usage_open_row": "Открыть {name} в «Файлах»", +"cw_files_usage_inside": "Заглянуть в {name}", +"cw_files_usage_files_here": "Файлы в этой папке", +"cw_files_usage_running": "Измерение… {at} из {n} папок, {items} элементов.", +"cw_files_usage_done": "{size} в {items} элементах.", +"cw_files_usage_capped": "Остановлено на {items} элементах: учтено {size}, в папке есть ещё.", +"cw_files_usage_timeout": "Остановлено через 3 минуты: учтено {size}, остальное не измерено.", +"cw_files_usage_cancelled": "Отменено: учтено {size}, остальное не измерено.", +"cw_files_usage_again": "Измерить заново", +"cw_files_usage_busy": "Сейчас измеряется размер другой папки. Повторите, когда это закончится.", +"cw_files_usage_skipped": "не измерено", +"cw_files_usage_unreadable": "не читается", }; export default ru; diff --git a/client/src/i18n/locales/th.ts b/client/src/i18n/locales/th.ts index 4c9af363..9ca9059d 100644 --- a/client/src/i18n/locales/th.ts +++ b/client/src/i18n/locales/th.ts @@ -4459,6 +4459,43 @@ c2c_test_deep_bad: "การส่งทดสอบ 4 KiB ไปไม่ถ "cw_files_game_ps4": "โฟลเดอร์เกม PS4", "cw_files_game_version": "เวอร์ชัน {v}", "cw_files_game_fw": "ต้องใช้ซอฟต์แวร์ระบบ {v}", +"cw_files_search": "ค้นหา", +"cw_files_search_field": "ชื่อหรือรูปแบบ", +"cw_files_search_go": "ค้นหา", +"cw_files_search_scope": "ค้นหาที่ใด", +"cw_files_search_all": "ทุกไดรฟ์", +"cw_files_search_here": "โฟลเดอร์นี้", +"cw_files_search_hint": "ค้นหาไฟล์ตามชื่อ คำจะตรงกับส่วนใดก็ได้ของชื่อ ใช้ * และ ? เป็นรูปแบบ (*.pkg)", +"cw_files_search_bad": "ค้นหาเครื่องหมายคำพูดและแบ็กสแลชไม่ได้", +"cw_files_search_unsupported": "PS5Upload บนเครื่องนี้เก่าเกินกว่าจะค้นหาได้ อัปเดตเพื่อใช้การค้นหา", +"cw_files_search_no_index": "ยังไม่มีดัชนี การค้นหาครั้งแรกจะสร้างดัชนี โดยอ่านทุกโฟลเดอร์และอาจใช้เวลาสองสามนาที", +"cw_files_search_building": "กำลังสร้างดัชนี… ตอนนี้ {n} ไฟล์", +"cw_files_search_stopping": "กำลังหยุดดัชนี…", +"cw_files_search_ready": "ดัชนี: {n} ไฟล์", +"cw_files_search_partial": "ดัชนีถูกหยุดก่อนเสร็จ จึงขาดบางไฟล์", +"cw_files_search_capped": "ดัชนีเต็ม (200,000 ไฟล์) จึงขาดบางไฟล์", +"cw_files_search_rebuild": "สร้างใหม่", +"cw_files_search_cancel": "ยกเลิก", +"cw_files_search_waiting": "จะค้นหา “{q}” เมื่อดัชนีพร้อม", +"cw_files_search_searching": "กำลังค้นหา…", +"cw_files_search_none": "ไม่มีสิ่งที่ตรงกับ “{q}”", +"cw_files_search_found": "พบ {n} รายการ", +"cw_files_search_more": "แสดง {n} รายการแรก ยังมีอีก: ลองใช้ชื่อที่ยาวขึ้น", +"cw_files_search_results": "ผลการค้นหา", +"cw_files_usage": "การใช้ดิสก์", +"cw_files_usage_open": "เปิดในไฟล์", +"cw_files_usage_open_row": "เปิด {name} ในไฟล์", +"cw_files_usage_inside": "ดูข้างใน {name}", +"cw_files_usage_files_here": "ไฟล์ในโฟลเดอร์นี้", +"cw_files_usage_running": "กำลังวัด… {at} จาก {n} โฟลเดอร์, {items} รายการ", +"cw_files_usage_done": "{size} ใน {items} รายการ", +"cw_files_usage_capped": "หยุดที่ {items} รายการ: นับได้ {size} โฟลเดอร์ยังมีอีก", +"cw_files_usage_timeout": "หยุดหลัง 3 นาที: นับได้ {size} ส่วนที่เหลือไม่ได้วัด", +"cw_files_usage_cancelled": "ยกเลิกแล้ว: นับได้ {size} ส่วนที่เหลือไม่ได้วัด", +"cw_files_usage_again": "วัดอีกครั้ง", +"cw_files_usage_busy": "กำลังวัดขนาดโฟลเดอร์อื่นอยู่ ลองอีกครั้งเมื่อเสร็จ", +"cw_files_usage_skipped": "ไม่ได้วัด", +"cw_files_usage_unreadable": "อ่านไม่ได้", }; export default th; diff --git a/client/src/i18n/locales/tr.ts b/client/src/i18n/locales/tr.ts index 9aa284f8..1ee5600b 100644 --- a/client/src/i18n/locales/tr.ts +++ b/client/src/i18n/locales/tr.ts @@ -4457,6 +4457,43 @@ c2c_test_deep_bad: "4 KiB'lik test gönderimi ulaşmadı: {detail}", "cw_files_game_ps4": "PS4 oyun klasörü", "cw_files_game_version": "sürüm {v}", "cw_files_game_fw": "sistem yazılımı {v} gerekir", +"cw_files_search": "Ara", +"cw_files_search_field": "Ad veya desen", +"cw_files_search_go": "Ara", +"cw_files_search_scope": "Nerede aranacak", +"cw_files_search_all": "Tüm sürücüler", +"cw_files_search_here": "Bu klasör", +"cw_files_search_hint": "Dosyaları adına göre bulur. Bir sözcük adın herhangi bir yerinde eşleşir; desen için * ve ? kullanın (*.pkg).", +"cw_files_search_bad": "Tırnak işaretleri ve ters eğik çizgiler aranamaz.", +"cw_files_search_unsupported": "Bu konsoldaki PS5Upload arama için çok eski. Aramayı kullanmak için güncelleyin.", +"cw_files_search_no_index": "Henüz dizin yok. İlk arama bir dizin oluşturur: her klasörü okur ve birkaç dakika sürebilir.", +"cw_files_search_building": "Dizin oluşturuluyor… şimdiye dek {n} dosya.", +"cw_files_search_stopping": "Dizin durduruluyor…", +"cw_files_search_ready": "Dizin: {n} dosya.", +"cw_files_search_partial": "Erken durduruldu, bu yüzden bazı dosyalar eksik.", +"cw_files_search_capped": "Dolu (200.000 dosya), bu yüzden bazı dosyalar eksik.", +"cw_files_search_rebuild": "Yeniden oluştur", +"cw_files_search_cancel": "İptal", +"cw_files_search_waiting": "Dizin hazır olunca “{q}” aranacak.", +"cw_files_search_searching": "Aranıyor…", +"cw_files_search_none": "“{q}” ile eşleşen bir şey yok.", +"cw_files_search_found": "{n} bulundu.", +"cw_files_search_more": "İlk {n} gösteriliyor. Daha fazlası var: daha uzun bir ad deneyin.", +"cw_files_search_results": "Arama sonuçları", +"cw_files_usage": "Disk kullanımı", +"cw_files_usage_open": "Dosyalar’da aç", +"cw_files_usage_open_row": "{name} öğesini Dosyalar’da aç", +"cw_files_usage_inside": "{name} içine bak", +"cw_files_usage_files_here": "Bu klasördeki dosyalar", +"cw_files_usage_running": "Ölçülüyor… {n} klasörden {at}, {items} öğe.", +"cw_files_usage_done": "{items} öğede {size}.", +"cw_files_usage_capped": "{items} öğede durdu: {size} sayıldı, klasörde daha fazlası var.", +"cw_files_usage_timeout": "3 dakika sonra durdu: {size} sayıldı, kalanı ölçülmedi.", +"cw_files_usage_cancelled": "İptal edildi: {size} sayıldı, kalanı ölçülmedi.", +"cw_files_usage_again": "Yeniden ölç", +"cw_files_usage_busy": "Başka bir klasörün boyutu ölçülüyor. Bitince yeniden deneyin.", +"cw_files_usage_skipped": "ölçülmedi", +"cw_files_usage_unreadable": "okunamıyor", }; export default tr; diff --git a/client/src/i18n/locales/vi.ts b/client/src/i18n/locales/vi.ts index 6ebfdfdb..8115cc01 100644 --- a/client/src/i18n/locales/vi.ts +++ b/client/src/i18n/locales/vi.ts @@ -4452,6 +4452,43 @@ c2c_test_deep_bad: "Lượt gửi thử 4 KiB không tới: {detail}", "cw_files_game_ps4": "Thư mục game PS4", "cw_files_game_version": "phiên bản {v}", "cw_files_game_fw": "cần phần mềm hệ thống {v}", +"cw_files_search": "Tìm kiếm", +"cw_files_search_field": "Tên hoặc mẫu", +"cw_files_search_go": "Tìm", +"cw_files_search_scope": "Tìm ở đâu", +"cw_files_search_all": "Tất cả ổ đĩa", +"cw_files_search_here": "Thư mục này", +"cw_files_search_hint": "Tìm tệp theo tên. Một từ khớp ở bất kỳ đâu trong tên; dùng * và ? cho mẫu (*.pkg).", +"cw_files_search_bad": "Không thể tìm dấu ngoặc kép và dấu gạch chéo ngược.", +"cw_files_search_unsupported": "PS5Upload trên máy này quá cũ để tìm kiếm. Hãy cập nhật để dùng tìm kiếm.", +"cw_files_search_no_index": "Chưa có chỉ mục. Lần tìm đầu tiên sẽ tạo: nó đọc mọi thư mục và có thể mất vài phút.", +"cw_files_search_building": "Đang tạo chỉ mục… đến giờ {n} tệp.", +"cw_files_search_stopping": "Đang dừng chỉ mục…", +"cw_files_search_ready": "Chỉ mục: {n} tệp.", +"cw_files_search_partial": "Chỉ mục bị dừng sớm nên thiếu một số tệp.", +"cw_files_search_capped": "Chỉ mục đã đầy (200.000 tệp) nên thiếu một số tệp.", +"cw_files_search_rebuild": "Tạo lại", +"cw_files_search_cancel": "Hủy", +"cw_files_search_waiting": "Sẽ tìm “{q}” khi chỉ mục sẵn sàng.", +"cw_files_search_searching": "Đang tìm…", +"cw_files_search_none": "Không có gì khớp với “{q}”.", +"cw_files_search_found": "Tìm thấy {n}.", +"cw_files_search_more": "Đang hiện {n} kết quả đầu. Còn nữa: hãy thử tên dài hơn.", +"cw_files_search_results": "Kết quả tìm kiếm", +"cw_files_usage": "Dung lượng đã dùng", +"cw_files_usage_open": "Mở trong Tệp", +"cw_files_usage_open_row": "Mở {name} trong Tệp", +"cw_files_usage_inside": "Xem bên trong {name}", +"cw_files_usage_files_here": "Tệp trong thư mục này", +"cw_files_usage_running": "Đang đo… {at}/{n} thư mục, {items} mục.", +"cw_files_usage_done": "{size} trong {items} mục.", +"cw_files_usage_capped": "Dừng ở {items} mục: đã đếm {size}, thư mục còn nhiều hơn.", +"cw_files_usage_timeout": "Dừng sau 3 phút: đã đếm {size}, phần còn lại chưa đo.", +"cw_files_usage_cancelled": "Đã hủy: đã đếm {size}, phần còn lại chưa đo.", +"cw_files_usage_again": "Đo lại", +"cw_files_usage_busy": "Đang đo kích thước một thư mục khác. Hãy thử lại khi xong.", +"cw_files_usage_skipped": "chưa đo", +"cw_files_usage_unreadable": "không đọc được", }; export default vi; diff --git a/client/src/i18n/locales/zh-CN.ts b/client/src/i18n/locales/zh-CN.ts index 2543eeb1..f96bcecd 100644 --- a/client/src/i18n/locales/zh-CN.ts +++ b/client/src/i18n/locales/zh-CN.ts @@ -4406,6 +4406,43 @@ c2c_test_deep_bad: "4 KiB 测试发送未到达:{detail}", "cw_files_game_ps4": "PS4 游戏文件夹", "cw_files_game_version": "版本 {v}", "cw_files_game_fw": "需要系统软件 {v}", +"cw_files_search": "搜索", +"cw_files_search_field": "名称或模式", +"cw_files_search_go": "搜索", +"cw_files_search_scope": "搜索范围", +"cw_files_search_all": "所有驱动器", +"cw_files_search_here": "此文件夹", +"cw_files_search_hint": "按名称查找文件。单词可匹配名称中的任意位置;模式可使用 * 和 ?(*.pkg)。", +"cw_files_search_bad": "无法搜索引号和反斜杠。", +"cw_files_search_unsupported": "此主机上的 PS5Upload 版本过旧,无法搜索。请更新后使用搜索。", +"cw_files_search_no_index": "还没有索引。首次搜索会建立索引:需要读取每个文件夹,可能需要几分钟。", +"cw_files_search_building": "正在建立索引… 目前 {n} 个文件。", +"cw_files_search_stopping": "正在停止索引…", +"cw_files_search_ready": "索引:{n} 个文件。", +"cw_files_search_partial": "索引提前停止,因此缺少部分文件。", +"cw_files_search_capped": "索引已满(200,000 个文件),因此缺少部分文件。", +"cw_files_search_rebuild": "重新建立", +"cw_files_search_cancel": "取消", +"cw_files_search_waiting": "索引就绪后将搜索“{q}”。", +"cw_files_search_searching": "正在搜索…", +"cw_files_search_none": "没有与“{q}”匹配的内容。", +"cw_files_search_found": "找到 {n} 个。", +"cw_files_search_more": "显示前 {n} 个。还有更多:请尝试更长的名称。", +"cw_files_search_results": "搜索结果", +"cw_files_usage": "磁盘用量", +"cw_files_usage_open": "在“文件”中打开", +"cw_files_usage_open_row": "在“文件”中打开 {name}", +"cw_files_usage_inside": "查看 {name} 内部", +"cw_files_usage_files_here": "此文件夹中的文件", +"cw_files_usage_running": "正在测量… {n} 个文件夹中的第 {at} 个,{items} 项。", +"cw_files_usage_done": "{items} 项共 {size}。", +"cw_files_usage_capped": "在 {items} 项处停止:已计 {size},文件夹中还有更多。", +"cw_files_usage_timeout": "3 分钟后停止:已计 {size},其余未测量。", +"cw_files_usage_cancelled": "已取消:已计 {size},其余未测量。", +"cw_files_usage_again": "重新测量", +"cw_files_usage_busy": "正在测量另一个文件夹的大小。请在其结束后重试。", +"cw_files_usage_skipped": "未测量", +"cw_files_usage_unreadable": "无法读取", }; export default zh_CN; diff --git a/client/src/i18n/locales/zh-TW.ts b/client/src/i18n/locales/zh-TW.ts index 32fca549..459f06ea 100644 --- a/client/src/i18n/locales/zh-TW.ts +++ b/client/src/i18n/locales/zh-TW.ts @@ -4400,6 +4400,43 @@ c2c_test_deep_bad: "4 KiB 測試傳送未抵達:{detail}", "cw_files_game_ps4": "PS4 遊戲資料夾", "cw_files_game_version": "版本 {v}", "cw_files_game_fw": "需要系統軟體 {v}", +"cw_files_search": "搜尋", +"cw_files_search_field": "名稱或樣式", +"cw_files_search_go": "搜尋", +"cw_files_search_scope": "搜尋範圍", +"cw_files_search_all": "所有磁碟機", +"cw_files_search_here": "此資料夾", +"cw_files_search_hint": "依名稱尋找檔案。單字可符合名稱中的任何位置;樣式可使用 * 和 ?(*.pkg)。", +"cw_files_search_bad": "無法搜尋引號和反斜線。", +"cw_files_search_unsupported": "此主機上的 PS5Upload 版本太舊,無法搜尋。請更新後使用搜尋。", +"cw_files_search_no_index": "尚無索引。第一次搜尋會建立索引:需要讀取每個資料夾,可能需要幾分鐘。", +"cw_files_search_building": "正在建立索引… 目前 {n} 個檔案。", +"cw_files_search_stopping": "正在停止索引…", +"cw_files_search_ready": "索引:{n} 個檔案。", +"cw_files_search_partial": "索引提早停止,因此缺少部分檔案。", +"cw_files_search_capped": "索引已滿(200,000 個檔案),因此缺少部分檔案。", +"cw_files_search_rebuild": "重新建立", +"cw_files_search_cancel": "取消", +"cw_files_search_waiting": "索引就緒後將搜尋「{q}」。", +"cw_files_search_searching": "正在搜尋…", +"cw_files_search_none": "沒有符合「{q}」的項目。", +"cw_files_search_found": "找到 {n} 個。", +"cw_files_search_more": "顯示前 {n} 個。還有更多:請嘗試較長的名稱。", +"cw_files_search_results": "搜尋結果", +"cw_files_usage": "磁碟用量", +"cw_files_usage_open": "在「檔案」中開啟", +"cw_files_usage_open_row": "在「檔案」中開啟 {name}", +"cw_files_usage_inside": "查看 {name} 內部", +"cw_files_usage_files_here": "此資料夾中的檔案", +"cw_files_usage_running": "正在測量… {n} 個資料夾中的第 {at} 個,{items} 項。", +"cw_files_usage_done": "{items} 項共 {size}。", +"cw_files_usage_capped": "在 {items} 項處停止:已計 {size},資料夾中還有更多。", +"cw_files_usage_timeout": "3 分鐘後停止:已計 {size},其餘未測量。", +"cw_files_usage_cancelled": "已取消:已計 {size},其餘未測量。", +"cw_files_usage_again": "重新測量", +"cw_files_usage_busy": "正在測量另一個資料夾的大小。請在結束後重試。", +"cw_files_usage_skipped": "未測量", +"cw_files_usage_unreadable": "無法讀取", }; export default zh_TW; diff --git a/payload/include/webui_codec.h b/payload/include/webui_codec.h index 6f1ff5ee..753ff43a 100644 --- a/payload/include/webui_codec.h +++ b/payload/include/webui_codec.h @@ -145,4 +145,38 @@ const char *webui_cheats_toggle_error(const char *cause, size_t n); * unmount, so a running game is never asked about: it would lose its image (ruling R20). */ const char *webui_unmount_refusal(const char *p, int game_running); +/* ---- search index (INDEX_START / INDEX_STATUS / INDEX_SEARCH / INDEX_CANCEL, runtime.c) ---- + * The handlers read their bodies with a quote-to-quote scan (no JSON unescaping), so every string the + * page sends is checked to hold no '"', no '\\' and no control character before it is put in one. */ + +/* The roots INDEX_START takes: at most 8, each under 256 bytes (the handler's own limits). */ +#define WEBUI_INDEX_ROOTS_MAX 8 +#define WEBUI_INDEX_ROOT_CAP 256 + +/* 1 when `p` can be an index root: starts with '/', 1-255 bytes, no '"', '\\' or control character. */ +int webui_index_root_ok(const char *p); +/* 1 when `q` can be a name pattern: 1-255 bytes, no '"', '\\' or control character. */ +int webui_index_query_ok(const char *q); + +/* INDEX_START's body {"roots":["/a","/b"]} for `n` roots (each webui_index_root_ok, 1 <= n <= 8). + * Returns the length, or -1 (a bad root, a bad count, or it does not fit; out is then ""). */ +int webui_index_start_body(char *out, size_t cap, const char *const *roots, int n); + +/* An INDEX_STATUS reply ({"phase":"idle"|"building"|"ready","files":N,"truncated":bool,"started_at":N, + * "completed_at":N}) as + * {"phase":"..","files":N,"truncated":bool,"cancelled":bool,"roots":["..",..]|null} + * `roots` (NULL: null) are the roots the page started this index with, `cancelled` that the page stopped + * it (the helper itself does not say). 0, or -1 (no usable phase, or it does not fit). */ +int webui_index_status_to_json(const char *text, size_t n, const char *const *roots, int nroots, int cancelled, + char *out, size_t cap); + +/* An INDEX_SEARCH reply ({"results":[{"path":"..","size":N},..](,"truncated":true)}) as + * {"results":[{"path":"..","size":N},..],"truncated":bool} + * keeping only paths under `under` (NULL: all; "/" all) that `hide` (may be NULL) does not hide, at + * most `max` of them. "truncated" is set when the helper said so, when it answered `asked` results + * (its limit: there may be more), when a result was dropped for `max`, or when `out` is full. Returns + * the number written, or -1 (no results array, or not even the empty list fits). */ +int webui_index_results_to_json(const char *text, size_t n, const char *under, size_t max, size_t asked, + int (*hide)(const char *path, void *ctx), void *ctx, char *out, size_t cap); + #endif diff --git a/payload/src/webui_api.c b/payload/src/webui_api.c index 75e71016..503de5ad 100644 --- a/payload/src/webui_api.c +++ b/payload/src/webui_api.c @@ -1081,6 +1081,198 @@ static void r_cheats_toggle(const char *b, size_t n, uint8_t *out, int fd) { } } +/* ---- search: the helper's file index (INDEX_START / STATUS / SEARCH / CANCEL, runtime.c) ---- + * + * One index for the whole helper (its own thread walks the roots: at most 200,000 files and 32 MiB of + * paths, ten folders deep, dot names skipped; INDEX_CANCEL stops it between folders). The helper does + * not say which roots an index covers or that it was stopped: the page's last start is remembered here + * (by the started_at INDEX_STATUS gives it) so /api/index/status can tell the page whether the index + * it finds is the one it asked for. The AVA1 trust store's files are never answered. */ + +#define API_INDEX_RESULTS_MAX 500u /* results one search answers */ +#define API_INDEX_ASK_ALL 500u /* INDEX_SEARCH limit for a search of every root */ +#define API_INDEX_ASK_UNDER 5000u /* for one folder: the helper's own maximum, filtered here */ +#define API_INDEX_JSON (256u * 1024u) /* 500 results with long, escaped paths fit easily */ +#define API_INDEX_STATUS_JSON (WEBUI_INDEX_ROOTS_MAX * (6u * WEBUI_INDEX_ROOT_CAP + 3u) + 160u) /* 8 roots, escaped */ + +static pthread_mutex_t g_idx_mu = PTHREAD_MUTEX_INITIALIZER; +static char g_idx_roots[WEBUI_INDEX_ROOTS_MAX][WEBUI_INDEX_ROOT_CAP]; +static int g_idx_nroots = 0; +static uint64_t g_idx_started_at = 0; /* 0: no index the page started */ +static int g_idx_cancelled = 0; + +static int hide_protected(const char *path, void *ctx) { + (void)ctx; + return path_in_protected(path); +} + +/* INDEX_STATUS's reply text, and its started_at (0 when absent). */ +static int index_status_text(uint8_t *out, const uint8_t **t, size_t *tl, uint64_t *started_at) { + size_t ol; + int st = call_text(AVA1_METHOD_INDEX_STATUS, NULL, 0, out, &ol, t, tl); + if (st != AVA1_STATUS_OK) return st; + if (webui_json_u64((const char *)*t, *tl, "started_at", started_at) != 1) *started_at = 0; + return AVA1_STATUS_OK; +} + +/* {roots:["/data",..]} (1-8 roots): a new index, replacing the one there. 409 busy while one is built. */ +static void r_index_start(const char *b, size_t n, uint8_t *out, int fd) { + char roots[WEBUI_INDEX_ROOTS_MAX][WEBUI_INDEX_ROOT_CAP], body[WEBUI_INDEX_ROOTS_MAX * (WEBUI_INDEX_ROOT_CAP + 3) + 16]; + char err[64]; + const char *ptrs[WEBUI_INDEX_ROOTS_MAX]; + const char *arr; + const uint8_t *t; + size_t alen, pos = 0, ol, tl; + uint64_t started_at = 0; + int nroots = 0, rc, len, st, started = 0; + if (webui_json_array(b, n, "roots", &arr, &alen) != 1) { + send_bad(fd, "bad_roots"); + return; + } + for (;;) { + char root[WEBUI_INDEX_ROOT_CAP + 1]; + rc = webui_json_array_str(arr, alen, &pos, root, sizeof root); + if (rc == 0) break; + if (rc < 0 || nroots >= WEBUI_INDEX_ROOTS_MAX || !webui_index_root_ok(root)) { + send_bad(fd, "bad_roots"); + return; + } + if (path_in_protected(root)) { + send_error(fd, 403, "path_not_allowed", 16); + return; + } + memcpy(roots[nroots], root, strlen(root) + 1); + ptrs[nroots] = roots[nroots]; + nroots++; + } + len = nroots ? webui_index_start_body(body, sizeof body, ptrs, nroots) : -1; + if (len <= 0) { + send_bad(fd, "bad_roots"); + return; + } + st = call_text(AVA1_METHOD_INDEX_START, body, (size_t)len, out, &ol, &t, &tl); + if (st != AVA1_STATUS_OK) { + send_status_error(fd, st, out, ol); + return; + } + if (webui_json_bool((const char *)t, tl, "started", &started) != 1 || !started) { + if (webui_json_str((const char *)t, tl, "err", err, sizeof err) != 1 || !err[0]) snprintf(err, sizeof err, "index_failed"); + if (strcmp(err, "already_building") == 0) send_error(fd, 409, "busy", 4); + else send_error(fd, 500, err, strlen(err)); + return; + } + /* the index just started is this one: remember what it covers */ + if (index_status_text(out, &t, &tl, &started_at) != AVA1_STATUS_OK) started_at = 0; + pthread_mutex_lock(&g_idx_mu); + memcpy(g_idx_roots, roots, sizeof roots); + g_idx_nroots = nroots; + g_idx_started_at = started_at; + g_idx_cancelled = 0; + pthread_mutex_unlock(&g_idx_mu); + send_json(fd, 200, "{\"started\":true}", 16); +} + +/* {} -> {"phase","files","truncated","cancelled","roots"|null}: roots null when the index there is not + * one the page started (never built, or another client's). */ +static void r_index_status(const char *b, size_t n, uint8_t *out, int fd) { + const char *ptrs[WEBUI_INDEX_ROOTS_MAX]; + char roots[WEBUI_INDEX_ROOTS_MAX][WEBUI_INDEX_ROOT_CAP]; + char *json; + const uint8_t *t; + size_t tl; + uint64_t started_at = 0; + int st, i, nroots = 0, cancelled = 0, ours = 0; + (void)b; + (void)n; + st = index_status_text(out, &t, &tl, &started_at); + if (st != AVA1_STATUS_OK) { + send_status_error(fd, st, NULL, 0); + return; + } + pthread_mutex_lock(&g_idx_mu); + if (g_idx_started_at != 0 && g_idx_started_at == started_at) { + ours = 1; + nroots = g_idx_nroots; + cancelled = g_idx_cancelled; + memcpy(roots, g_idx_roots, sizeof roots); + } + pthread_mutex_unlock(&g_idx_mu); + for (i = 0; i < nroots; i++) ptrs[i] = roots[i]; + json = malloc(API_INDEX_STATUS_JSON); + if (!json) { + send_error(fd, 500, "out_of_memory", 13); + return; + } + if (webui_index_status_to_json((const char *)t, tl, ours ? ptrs : NULL, nroots, cancelled, json, API_INDEX_STATUS_JSON) != 0) + send_error(fd, 500, "bad_index_reply", 15); + else + send_json(fd, 200, json, strlen(json)); + free(json); +} + +/* {query, under?}: files whose NAME matches `query` (the helper's glob: * and ?, ASCII case-insensitive, + * the whole name), under the folder `under` when given. -> {"results":[{"path","size"}],"truncated"}. */ +static void r_index_search(const char *b, size_t n, uint8_t *out, int fd) { + char query[WEBUI_INDEX_ROOT_CAP + 1], under[API_PATH], body[WEBUI_INDEX_ROOT_CAP + 64], *json; + const uint8_t *t; + size_t ol, tl; + unsigned ask; + int rc, st, k; + if (webui_json_str(b, n, "query", query, sizeof query) != 1 || !webui_index_query_ok(query)) { + send_bad(fd, "bad_query"); + return; + } + rc = webui_json_str(b, n, "under", under, sizeof under); + if (rc < 0 || (rc == 1 && under[0] != '/')) { + send_bad(fd, "bad_under"); + return; + } + if (rc == 0) under[0] = '\0'; + ask = under[0] ? API_INDEX_ASK_UNDER : API_INDEX_ASK_ALL; + k = snprintf(body, sizeof body, "{\"query\":\"%s\",\"limit\":%u}", query, ask); /* checked: no quote, no backslash */ + if (k <= 0 || (size_t)k >= sizeof body) { + send_bad(fd, "bad_query"); + return; + } + st = call_text(AVA1_METHOD_INDEX_SEARCH, body, (size_t)k, out, &ol, &t, &tl); + if (st != AVA1_STATUS_OK) { + send_status_error(fd, st, out, ol); + return; + } + json = malloc(API_INDEX_JSON); + if (!json) { + send_error(fd, 500, "out_of_memory", 13); + return; + } + if (webui_index_results_to_json((const char *)t, tl, under[0] ? under : NULL, API_INDEX_RESULTS_MAX, ask, + hide_protected, NULL, json, API_INDEX_JSON) < 0) + send_error(fd, 500, "bad_index_reply", 15); + else + send_json(fd, 200, json, strlen(json)); + free(json); +} + +/* {} -> {}: the build stops before its next folder (what it found stays searchable, marked cancelled). */ +static void r_index_cancel(const char *b, size_t n, uint8_t *out, int fd) { + const uint8_t *t; + size_t ol, tl; + uint64_t started_at = 0; + int st; + (void)b; + (void)n; + st = call_text(AVA1_METHOD_INDEX_CANCEL, NULL, 0, out, &ol, &t, &tl); + if (st != AVA1_STATUS_OK) { + send_status_error(fd, st, out, ol); + return; + } + if (index_status_text(out, &t, &tl, &started_at) == AVA1_STATUS_OK) { + pthread_mutex_lock(&g_idx_mu); + if (g_idx_started_at != 0 && g_idx_started_at == started_at) g_idx_cancelled = 1; + pthread_mutex_unlock(&g_idx_mu); + } + send_json(fd, 200, "{}", 2); +} + /* ---- input log ---- */ /* One append at a time (the file is a ring rewritten in place), and a rate gate: a page that @@ -1166,6 +1358,10 @@ static const struct { { "/api/cheats/status", r_cheats_status }, { "/api/cheats/list", r_cheats_list }, { "/api/cheats/toggle", r_cheats_toggle }, + { "/api/index/start", r_index_start }, + { "/api/index/status", r_index_status }, + { "/api/index/search", r_index_search }, + { "/api/index/cancel", r_index_cancel }, }; /* The page's own headers: only same-origin fetches, inline script/style (the page is one file), diff --git a/payload/src/webui_codec.c b/payload/src/webui_codec.c index e24155cd..f10e38f1 100644 --- a/payload/src/webui_codec.c +++ b/payload/src/webui_codec.c @@ -868,3 +868,168 @@ const char *webui_unmount_refusal(const char *p, int game_running) { return "system_volume"; return game_running ? "game_running" : NULL; } + +/* ---- search index ---- */ + +/* 1-255 bytes with no '"', '\\' or control character (the index handlers scan quote to quote). */ +static int index_text_ok(const char *s) { + size_t i, n; + if (!s) return 0; + n = strlen(s); + if (n == 0 || n >= WEBUI_INDEX_ROOT_CAP) return 0; + for (i = 0; i < n; i++) { + unsigned char c = (unsigned char)s[i]; + if (c < 0x20 || c == 0x7f || c == '"' || c == '\\') return 0; + } + return 1; +} + +int webui_index_root_ok(const char *p) { return index_text_ok(p) && p[0] == '/'; } + +int webui_index_query_ok(const char *q) { return index_text_ok(q); } + +int webui_index_start_body(char *out, size_t cap, const char *const *roots, int n) { + size_t at = 0; + int i; + if (!out || !cap) return -1; + out[0] = '\0'; + if (!roots || n < 1 || n > WEBUI_INDEX_ROOTS_MAX) return -1; + for (i = 0; i < n; i++) + if (!webui_index_root_ok(roots[i])) return -1; + if (cap < 12) return -1; + memcpy(out, "{\"roots\":[", 10); + at = 10; + for (i = 0; i < n; i++) { + size_t l = strlen(roots[i]); + /* `,""` then `]}` and the NUL */ + if (at + l + 3 + 3 > cap) { + out[0] = '\0'; + return -1; + } + if (i) out[at++] = ','; + out[at++] = '"'; + memcpy(out + at, roots[i], l); + at += l; + out[at++] = '"'; + } + out[at++] = ']'; + out[at++] = '}'; + out[at] = '\0'; + return (int)at; +} + +int webui_index_status_to_json(const char *text, size_t n, const char *const *roots, int nroots, int cancelled, + char *out, size_t cap) { + char phase[16]; + uint64_t files = 0; + int truncated = 0, i; + jw_t w; + char num[48]; + if (cap) out[0] = '\0'; + if (!text || webui_json_str(text, n, "phase", phase, sizeof phase) != 1) return -1; + if (strcmp(phase, "idle") != 0 && strcmp(phase, "building") != 0 && strcmp(phase, "ready") != 0) return -1; + if (webui_json_u64(text, n, "files", &files) != 1) files = 0; + if (webui_json_bool(text, n, "truncated", &truncated) != 1) truncated = 0; + memset(&w, 0, sizeof w); + w.out = out; + w.cap = cap; + put(&w, "{\"phase\":\"", 10); + put(&w, phase, strlen(phase)); + snprintf(num, sizeof num, "\",\"files\":%llu", (unsigned long long)files); + put(&w, num, strlen(num)); + put(&w, truncated ? ",\"truncated\":true" : ",\"truncated\":false", truncated ? 17 : 18); + put(&w, cancelled ? ",\"cancelled\":true" : ",\"cancelled\":false", cancelled ? 17 : 18); + if (!roots) { + put(&w, ",\"roots\":null}", 14); + } else { + put(&w, ",\"roots\":[", 10); + for (i = 0; i < nroots; i++) { + if (i) put(&w, ",", 1); + put(&w, "\"", 1); + esc(&w, roots[i], strlen(roots[i])); + put(&w, "\"", 1); + } + put(&w, "]}", 2); + } + if (w.full) { + if (cap) out[0] = '\0'; + return -1; + } + out[w.wrote] = '\0'; + return 0; +} + +/* `path` is `under` or below it ("/" and NULL: everything). */ +static int path_under(const char *path, const char *under) { + size_t l; + if (!under || !under[0] || strcmp(under, "/") == 0) return 1; + l = strlen(under); + while (l > 1 && under[l - 1] == '/') l--; + return strncmp(path, under, l) == 0 && (path[l] == '\0' || path[l] == '/'); +} + +#define INDEX_RESULTS_TAIL 24 /* `],"truncated":false}` + NUL */ + +int webui_index_results_to_json(const char *text, size_t n, const char *under, size_t max, size_t asked, + int (*hide)(const char *path, void *ctx), void *ctx, char *out, size_t cap) { + const char *e = text + n, *p, *q; + char path[1024 + 8]; + size_t pos, seen = 0; + int written = 0, truncated = 0, said = 0; + if (cap) out[0] = '\0'; + if (!text) return -1; + p = top_value(text, n, "results"); + if (!p || *p != '[') return -1; + if (cap < 12 + INDEX_RESULTS_TAIL) return -1; + if (webui_json_bool(text, n, "truncated", &said) == 1 && said) truncated = 1; + memcpy(out, "{\"results\":[", 12); + pos = 12; + q = p + 1; + for (;;) { + const char *el, *end; + size_t l; + uint64_t size; + q = ws(q, e); + if (q >= e) { + truncated = 1; + break; + } + if (*q == ']') break; + el = q; + end = skip_val(q, e); + if (!end) { + truncated = 1; + break; + } + l = (size_t)(end - el); + seen++; + if (*el == '{' && webui_json_str(el, l, "path", path, sizeof path) == 1 && path[0] == '/' && + webui_json_u64(el, l, "size", &size) == 1 && path_under(path, under) && !(hide && hide(path, ctx))) { + jw_t w; + char tail[40]; + int tl; + if ((size_t)written >= max) { + truncated = 1; + break; + } + memset(&w, 0, sizeof w); + w.out = out + pos; + w.cap = cap - pos - INDEX_RESULTS_TAIL + 1; + put(&w, written ? ",{\"path\":\"" : "{\"path\":\"", written ? 10 : 9); + esc(&w, path, strlen(path)); + tl = snprintf(tail, sizeof tail, "\",\"size\":%llu}", (unsigned long long)size); + put(&w, tail, (size_t)tl); + if (w.full) { + truncated = 1; + break; + } + pos += w.wrote; + written++; + } + q = ws(end, e); + if (q < e && *q == ',') q++; + } + if (asked && seen >= asked) truncated = 1; + snprintf(out + pos, cap - pos, "],\"truncated\":%s}", truncated ? "true" : "false"); + return written; +} diff --git a/payload/tests/webui_codec_selftest.c b/payload/tests/webui_codec_selftest.c index b5c863ae..d27a7a74 100644 --- a/payload/tests/webui_codec_selftest.c +++ b/payload/tests/webui_codec_selftest.c @@ -769,6 +769,97 @@ static void cheats_toggle_errors(void) { CHECK(strcmp(webui_cheats_toggle_error("busy", 4), "toggle_failed") == 0); } +static int hide_secret(const char *path, void *ctx) { + (void)ctx; + return strncmp(path, "/data/secret", 12) == 0; +} + +static void index_codec(void) { + static char out[4096]; + const char *roots[] = { "/data", "/mnt/ext0", "/mnt/usb0" }; + const char *bad[] = { "/data", "/a\"b" }; + char long_root[300]; + const char *j; + /* roots and queries: nothing the handlers' quote-to-quote scan would misread */ + CHECK(webui_index_root_ok("/data")); + CHECK(webui_index_root_ok("/mnt/usb0/Spiele \xc3\xa4")); + CHECK(!webui_index_root_ok("data")); + CHECK(!webui_index_root_ok("")); + CHECK(!webui_index_root_ok("/a\"b")); + CHECK(!webui_index_root_ok("/a\\b")); + CHECK(!webui_index_root_ok("/a\nb")); + memset(long_root, 'a', sizeof long_root); + long_root[0] = '/'; + long_root[256] = '\0'; + CHECK(!webui_index_root_ok(long_root)); /* 256 bytes: the handler keeps < 256 */ + long_root[255] = '\0'; + CHECK(webui_index_root_ok(long_root)); + CHECK(webui_index_query_ok("*.pkg")); + CHECK(webui_index_query_ok("eboot")); + CHECK(!webui_index_query_ok("")); + CHECK(!webui_index_query_ok("a\"b")); + CHECK(!webui_index_query_ok("a\\b")); + CHECK(!webui_index_query_ok("a\tb")); + /* INDEX_START's body */ + CHECK(webui_index_start_body(out, sizeof out, roots, 3) > 0); + CHECK(strcmp(out, "{\"roots\":[\"/data\",\"/mnt/ext0\",\"/mnt/usb0\"]}") == 0); + CHECK(json_valid(out)); + CHECK(webui_index_start_body(out, sizeof out, bad, 2) == -1 && out[0] == '\0'); + CHECK(webui_index_start_body(out, sizeof out, roots, 0) == -1); + CHECK(webui_index_start_body(out, sizeof out, roots, 9) == -1); + CHECK(webui_index_start_body(out, 20, roots, 3) == -1 && out[0] == '\0'); + /* status: the helper's reply plus what the page knows */ + j = "{\"phase\":\"building\",\"files\":1234,\"truncated\":false,\"started_at\":1700000000,\"completed_at\":0}"; + CHECK(webui_index_status_to_json(j, strlen(j), roots, 2, 0, out, sizeof out) == 0); + CHECK(strcmp(out, "{\"phase\":\"building\",\"files\":1234,\"truncated\":false,\"cancelled\":false," + "\"roots\":[\"/data\",\"/mnt/ext0\"]}") == 0); + j = "{\"phase\":\"ready\",\"files\":200000,\"truncated\":true,\"started_at\":1,\"completed_at\":2}"; + CHECK(webui_index_status_to_json(j, strlen(j), NULL, 0, 1, out, sizeof out) == 0); + CHECK(strcmp(out, "{\"phase\":\"ready\",\"files\":200000,\"truncated\":true,\"cancelled\":true,\"roots\":null}") == 0); + j = "{\"phase\":\"weird\",\"files\":1}"; + CHECK(webui_index_status_to_json(j, strlen(j), NULL, 0, 0, out, sizeof out) == -1); + CHECK(webui_index_status_to_json("nope", 4, NULL, 0, 0, out, sizeof out) == -1); + j = "{\"phase\":\"idle\",\"files\":0,\"truncated\":false,\"started_at\":0,\"completed_at\":0}"; + CHECK(webui_index_status_to_json(j, strlen(j), roots, 3, 0, out, 30) == -1 && out[0] == '\0'); + /* search results: escaped paths, scope, hidden paths, the helper's limit */ + j = "{\"results\":[{\"path\":\"/data/games/A \\\"x\\\".pkg\",\"size\":10}," + "{\"path\":\"/data/secret/key.pkg\",\"size\":2}," + "{\"path\":\"/data/gamesX/b.pkg\",\"size\":3}," + "{\"path\":\"/mnt/usb0/c.pkg\",\"size\":4}]}"; + CHECK(webui_index_results_to_json(j, strlen(j), NULL, 100, 200, hide_secret, NULL, out, sizeof out) == 3); + CHECK(json_valid(out)); + CHECK(strcmp(out, "{\"results\":[{\"path\":\"/data/games/A \\\"x\\\".pkg\",\"size\":10}," + "{\"path\":\"/data/gamesX/b.pkg\",\"size\":3},{\"path\":\"/mnt/usb0/c.pkg\",\"size\":4}]," + "\"truncated\":false}") == 0); + /* under a folder: "/data/games" is not "/data/gamesX" */ + CHECK(webui_index_results_to_json(j, strlen(j), "/data/games/", 100, 200, hide_secret, NULL, out, sizeof out) == 1); + CHECK(strstr(out, "A \\\"x\\\".pkg") && !strstr(out, "gamesX")); + CHECK(webui_index_results_to_json(j, strlen(j), "/", 100, 200, NULL, NULL, out, sizeof out) == 4); + /* the page's own maximum, and the helper's limit reached: both say there may be more */ + CHECK(webui_index_results_to_json(j, strlen(j), NULL, 2, 200, NULL, NULL, out, sizeof out) == 2); + CHECK(strstr(out, "\"truncated\":true}") != NULL); + CHECK(webui_index_results_to_json(j, strlen(j), NULL, 100, 4, NULL, NULL, out, sizeof out) == 4); + CHECK(strstr(out, "\"truncated\":true}") != NULL); + /* the helper's own truncated flag */ + j = "{\"results\":[{\"path\":\"/data/a\",\"size\":1}],\"truncated\":true}"; + CHECK(webui_index_results_to_json(j, strlen(j), NULL, 100, 200, NULL, NULL, out, sizeof out) == 1); + CHECK(strstr(out, "\"truncated\":true}") != NULL); + /* a cut reply keeps the whole entries */ + j = "{\"results\":[{\"path\":\"/data/a\",\"size\":1},{\"path\":\"/data/b"; + CHECK(webui_index_results_to_json(j, strlen(j), NULL, 100, 200, NULL, NULL, out, sizeof out) == 1); + CHECK(json_valid(out) && strstr(out, "\"truncated\":true}")); + /* an entry without a usable path or size is left out; an empty list is fine */ + j = "{\"results\":[{\"path\":\"rel\",\"size\":1},{\"path\":\"/x\"},{\"size\":3}]}"; + CHECK(webui_index_results_to_json(j, strlen(j), NULL, 100, 200, NULL, NULL, out, sizeof out) == 0); + CHECK(strcmp(out, "{\"results\":[],\"truncated\":false}") == 0); + CHECK(webui_index_results_to_json("{\"err\":\"oom\"}", 13, NULL, 100, 200, NULL, NULL, out, sizeof out) == -1); + /* a small output keeps what fits and says so */ + j = "{\"results\":[{\"path\":\"/data/aaaaaaaaaaaaaaaaaaaa\",\"size\":1},{\"path\":\"/data/bbbbbbbbbbbbbbbbbbbb\",\"size\":2}]}"; + CHECK(webui_index_results_to_json(j, strlen(j), NULL, 100, 200, NULL, NULL, out, 90) == 1); + CHECK(json_valid(out) && strstr(out, "aaaa") && !strstr(out, "bbbb") && strstr(out, "\"truncated\":true}")); + CHECK(webui_index_results_to_json(j, strlen(j), NULL, 100, 200, NULL, NULL, out, 20) == -1); +} + int main(void) { unicode_path_round_trips(); array_span(); @@ -792,6 +883,7 @@ int main(void) { cheats_status(); cheats_mods(); cheats_toggle_errors(); + index_codec(); if (failures) { fprintf(stderr, "webui_codec_selftest: %d failure(s)\n", failures); return 1;