mirror of
https://github.com/phantomptr/ps5upload.git
synced 2026-10-19 02:20:02 -05:00
console files: search and disk usage
Helper: /api/index/start|status|search|cancel over INDEX_START / STATUS / SEARCH / CANCEL (handlers unchanged; the engine never calls them). Their bodies are scanned quote to quote, so every root and pattern is checked for no '"', '\' or control character and < 256 bytes (codec, selftested). Roots: 1-8, absolute, never the trust store (403). The page's last start (roots, started_at, cancelled) is remembered so status says whether the index there is the page's and what it covers. Search answers at most 500 results, filters by `under` (asking the helper's 5,000 for a folder) and never returns a trust-store path; truncated when the helper's limit or the page's is reached. JSON buffers on the heap. Page: Search (toolbar) — name or * / ? pattern (a word matches anywhere), all drives or this folder; the first search builds the index for that scope with a running count and Cancel, then searches; a covering index is reused; results (name, folder, size) open in Files at the item. An older helper (404) is told. Disk usage (toolbar) — the drive's subfolders as bars, biggest first, measured one after another with folderSize's walker under one budget (500k entries, 3 min, 2,000 subfolders), cancellable, one at a time page-wide (refuses while a folder-size walk runs); "›" looks inside (○ comes back up), ✕ opens in Files. Strings cw_files_search_* / cw_files_usage_* in 21 locales. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RxCGb8Asp1A1AQYiv1kbgS
This commit is contained in:
1 parent
3a1e8019b6
commit
ec2c8f3a6b
55 files changed
+3794
No files matched your search
@@ -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 (
|
||||
<SearchDialog
|
||||
folder={path}
|
||||
volumes={p.volumes ?? []}
|
||||
onReveal={(dir, name) => p.onReveal?.(dir, name)}
|
||||
onClose={p.onDismiss}
|
||||
/>
|
||||
);
|
||||
case "usage":
|
||||
return <UsageDialog folder={path} volumes={p.volumes ?? []} onOpen={(dir) => p.onReveal?.(dir, null)} onClose={p.onDismiss} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -785,6 +805,12 @@ export function FilesView(p: FilesViewProps) {
|
||||
onClick={() => p.onAsk({ kind: "mkdir" })}
|
||||
/>
|
||||
<ToolButton icon={SquareCheck} label={t("cw_select")} action="select" onClick={p.onToggleSelecting} />
|
||||
{p.onReveal ? (
|
||||
<>
|
||||
<ToolButton icon={Search} label={t("cw_files_search")} action="search" onClick={() => p.onAsk({ kind: "search" })} />
|
||||
<ToolButton icon={ChartBar} label={t("cw_files_usage")} action="usage" onClick={() => p.onAsk({ kind: "usage" })} />
|
||||
</>
|
||||
) : null}
|
||||
{p.onView ? (
|
||||
<>
|
||||
<ToolButton
|
||||
@@ -1493,6 +1519,15 @@ export function Files({ ctx }: { ctx: SectionContext }) {
|
||||
[go],
|
||||
);
|
||||
|
||||
/** A search result or a disk usage row: open its folder with it focused. */
|
||||
const onReveal = useCallback(
|
||||
(dir: string, name: string | null) => {
|
||||
setAsk(null);
|
||||
go(dir, name ?? "");
|
||||
},
|
||||
[go],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={root}
|
||||
@@ -1563,6 +1598,7 @@ export function Files({ ctx }: { ctx: SectionContext }) {
|
||||
onUnpack={onUnpack}
|
||||
onMkdir={onMkdir}
|
||||
onPicked={onPicked}
|
||||
onReveal={onReveal}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<div className="cw-viewer-head">
|
||||
<h2 className="cw-file-name">{t("cw_files_search")}</h2>
|
||||
<button type="button" className="cw-focusable cw-button" data-action="search-close" onClick={onClose}>
|
||||
<span>{t("cw_files_close")}</span>
|
||||
</button>
|
||||
</div>
|
||||
<form
|
||||
className="cw-tv-bar cw-search-bar"
|
||||
role="search"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!bad) onSubmit();
|
||||
}}
|
||||
>
|
||||
<label className="cw-tv-find">
|
||||
<Search aria-hidden />
|
||||
<input
|
||||
className="cw-focusable cw-input cw-search-field"
|
||||
type="text"
|
||||
value={field}
|
||||
placeholder={t("cw_files_search_field")}
|
||||
aria-label={t("cw_files_search_field")}
|
||||
data-nav-default=""
|
||||
data-action="search-field"
|
||||
autoComplete="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck={false}
|
||||
disabled={state.unsupported}
|
||||
onChange={(e) => onField(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
className="cw-focusable cw-button is-primary"
|
||||
data-action="search-go"
|
||||
disabled={state.unsupported || !field.trim() || bad || state.searching}
|
||||
>
|
||||
<span>{t("cw_files_search_go")}</span>
|
||||
</button>
|
||||
<span className="cw-search-scope" role="group" aria-label={t("cw_files_search_scope")}>
|
||||
<button
|
||||
type="button"
|
||||
className="cw-focusable cw-button"
|
||||
data-scope="all"
|
||||
aria-pressed={scope === "all"}
|
||||
onClick={() => onScope("all")}
|
||||
>
|
||||
<span>{t("cw_files_search_all")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="cw-focusable cw-button"
|
||||
data-scope="folder"
|
||||
aria-pressed={scope === "folder"}
|
||||
onClick={() => onScope("folder")}
|
||||
>
|
||||
<span>{t("cw_files_search_here")}</span>
|
||||
</button>
|
||||
</span>
|
||||
</form>
|
||||
<p className="cw-dim cw-search-hint">
|
||||
{scope === "folder" ? <span className="cw-mono">{folder} · </span> : null}
|
||||
{t("cw_files_search_hint")}
|
||||
</p>
|
||||
{bad ? (
|
||||
<p className="cw-field-error" role="alert">
|
||||
{t("cw_files_search_bad")}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="cw-msg cw-search-index" role="status" data-index-phase={state.index?.phase ?? ""}>
|
||||
<span>{idx}</span>
|
||||
{building && !state.stopping ? (
|
||||
<button type="button" className="cw-focusable cw-button cw-msg-action" data-action="index-cancel" onClick={onCancel}>
|
||||
<Square aria-hidden />
|
||||
<span>{t("cw_files_search_cancel")}</span>
|
||||
</button>
|
||||
) : null}
|
||||
{!building && state.index?.phase === "ready" && state.index.roots ? (
|
||||
<button type="button" className="cw-focusable cw-button cw-msg-action" data-action="index-rebuild" onClick={onRebuild}>
|
||||
<RotateCw aria-hidden />
|
||||
<span>{t("cw_files_search_rebuild")}</span>
|
||||
</button>
|
||||
) : null}
|
||||
{building ? (
|
||||
<div className="cw-bar" aria-hidden>
|
||||
<div className="cw-bar-fill is-unknown" style={{ width: "100%" }} />
|
||||
</div>
|
||||
) : null}
|
||||
{state.pending && building ? <span className="cw-dim cw-search-pending">{t("cw_files_search_waiting", { q: state.pending })}</span> : null}
|
||||
</div>
|
||||
{err ? (
|
||||
<p className="cw-field-error" role="alert">
|
||||
{err}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="cw-search-body">
|
||||
{res ? (
|
||||
<p className="cw-dim" role="status" data-results-line="">
|
||||
{res}
|
||||
</p>
|
||||
) : null}
|
||||
{state.done && state.done.results.hits.length ? (
|
||||
<ul className="cw-files" data-nav-list aria-label={t("cw_files_search_results")}>
|
||||
{state.done.results.hits.map((h) => (
|
||||
<li key={h.path} className="cw-file is-plain" data-nav-item>
|
||||
<button type="button" className="cw-focusable cw-file-main" data-hit={h.path} onClick={() => onReveal(h)}>
|
||||
<File className="cw-file-icon" aria-hidden />
|
||||
<span className="cw-file-text">
|
||||
<span className="cw-file-name">{h.name}</span>
|
||||
<span className="cw-dim cw-mono cw-file-sub">{h.folder}</span>
|
||||
</span>
|
||||
<span className="cw-file-meta cw-dim">{formatBytes(h.size)}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<SearchState>(INITIAL_SEARCH);
|
||||
const [field, setField] = useState("");
|
||||
const [scope, setScope] = useState<Scope>("all");
|
||||
const flow = useRef<SearchFlow | null>(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<typeof setTimeout>) },
|
||||
setState,
|
||||
);
|
||||
flow.current = f;
|
||||
void f.open();
|
||||
return () => f.close();
|
||||
}, [flowFactory]);
|
||||
return (
|
||||
<Modal label={t("cw_files_search")} className="cw-viewer cw-search" onBack={onClose}>
|
||||
<SearchView
|
||||
state={state}
|
||||
field={field}
|
||||
scope={scope}
|
||||
folder={folder}
|
||||
onField={setField}
|
||||
onScope={setScope}
|
||||
onSubmit={() => 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}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<IndexStatus> = {}) {
|
||||
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<IndexStatus>) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<IndexStatus>;
|
||||
start(roots: string[]): Promise<void>;
|
||||
cancel(): Promise<void>;
|
||||
search(pattern: string, under: string | null): Promise<Results>;
|
||||
}
|
||||
|
||||
export const searchApi: SearchApi = {
|
||||
async status() {
|
||||
const s = toStatus(await call<unknown>("/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<unknown>("/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<void>;
|
||||
/**
|
||||
* 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<void>;
|
||||
/** Build the index again for `roots` (results may be stale). */
|
||||
rebuild(roots: string[]): Promise<void>;
|
||||
/** Stop the build: what it found so far stays searchable. */
|
||||
cancel(): Promise<void>;
|
||||
/** 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<SearchState>) => {
|
||||
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<void> => {
|
||||
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<boolean> => {
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<typeof import("../api")>();
|
||||
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<Record<string, unknown>>[] {
|
||||
const out: ReactElement<Record<string, unknown>>[] = [];
|
||||
const walk = (n: ReactNode) => {
|
||||
if (Array.isArray(n)) return n.forEach(walk);
|
||||
if (!isValidElement(n)) return;
|
||||
const el = n as ReactElement<Record<string, unknown>>;
|
||||
out.push(el);
|
||||
walk(el.props.children as ReactNode);
|
||||
};
|
||||
walk(node);
|
||||
return out;
|
||||
}
|
||||
function find<T>(node: ReactNode, type: T): ReactElement<Record<string, unknown>> {
|
||||
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> = {}): 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>): SearchState => ({ ...INITIAL_SEARCH, ...over });
|
||||
const view = (s: SearchState, field = "eboot") =>
|
||||
renderToStaticMarkup(
|
||||
<SearchView
|
||||
state={s}
|
||||
field={field}
|
||||
scope="all"
|
||||
folder="/data/g"
|
||||
onField={vi.fn()}
|
||||
onScope={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
onRebuild={vi.fn()}
|
||||
onReveal={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<UsageView
|
||||
root="/data"
|
||||
canUp={false}
|
||||
snap={snap}
|
||||
refused={false}
|
||||
onUp={vi.fn()}
|
||||
onDrill={vi.fn()}
|
||||
onOpen={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
onAgain={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<>
|
||||
<div className="cw-viewer-head">
|
||||
<h2 className="cw-file-name">{t("cw_files_usage")}</h2>
|
||||
<button type="button" className="cw-focusable cw-button" data-action="usage-close" onClick={onClose}>
|
||||
<span>{t("cw_files_close")}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="cw-tv-bar">
|
||||
<button type="button" className="cw-focusable cw-button" data-action="usage-up" disabled={!canUp} onClick={onUp}>
|
||||
<ArrowUp aria-hidden />
|
||||
<span>{t("cw_files_up")}</span>
|
||||
</button>
|
||||
<span className="cw-mono cw-usage-path" data-usage-root={root}>
|
||||
{root}
|
||||
</span>
|
||||
<button type="button" className="cw-focusable cw-button" data-action="usage-open-root" onClick={() => onOpen(root)}>
|
||||
<FolderOpen aria-hidden />
|
||||
<span>{t("cw_files_usage_open")}</span>
|
||||
</button>
|
||||
</div>
|
||||
{line ? (
|
||||
<div className={`cw-msg${line.tone !== "plain" ? ` is-${line.tone}` : ""}`} role="status" data-usage-end={snap?.end ?? "running"}>
|
||||
{line.text}
|
||||
{running ? (
|
||||
<button type="button" className="cw-focusable cw-button cw-msg-action" data-action="usage-cancel" onClick={onCancel}>
|
||||
<Square aria-hidden />
|
||||
<span>{t("cw_files_search_cancel")}</span>
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="cw-focusable cw-button cw-msg-action" data-action="usage-again" onClick={onAgain}>
|
||||
<RotateCw aria-hidden />
|
||||
<span>{t("cw_files_usage_again")}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="cw-usage-body">
|
||||
{snap && snap.end != null && !rows.length ? <p className="cw-dim">{t("cw_files_empty")}</p> : null}
|
||||
<ul className="cw-files" data-nav-list aria-label={root}>
|
||||
{rows.map((r) => {
|
||||
const Icon = r.files ? FilesIcon : Folder;
|
||||
const pct = barPercent(r.bytes, max);
|
||||
return (
|
||||
<li key={r.path + (r.files ? "/" : "")} className="cw-file cw-usage-row" data-nav-item data-usage-row={r.files ? "" : r.name}>
|
||||
<button
|
||||
type="button"
|
||||
className="cw-focusable cw-file-main"
|
||||
data-usage-open={r.path}
|
||||
aria-label={t("cw_files_usage_open_row", { name: r.files ? t("cw_files_usage_files_here") : r.name })}
|
||||
onClick={() => onOpen(r.path)}
|
||||
>
|
||||
<Icon className="cw-file-icon" aria-hidden />
|
||||
<span className="cw-file-text">
|
||||
<span className="cw-file-name">{r.files ? t("cw_files_usage_files_here") : r.name}</span>
|
||||
</span>
|
||||
<span className="cw-usage-bar" aria-hidden>
|
||||
<span className="cw-bar">
|
||||
<span className={`cw-bar-fill${r.state === "walking" ? " is-unknown" : ""}`} style={{ display: "block", width: `${pct}%` }} />
|
||||
</span>
|
||||
</span>
|
||||
<span className="cw-file-meta cw-dim cw-usage-size">{rowSize(r)}</span>
|
||||
</button>
|
||||
{!r.files ? (
|
||||
<button
|
||||
type="button"
|
||||
className="cw-focusable cw-button"
|
||||
data-usage-drill={r.path}
|
||||
aria-label={t("cw_files_usage_inside", { name: r.name })}
|
||||
data-tip={t("cw_files_usage_inside", { name: r.name })}
|
||||
onClick={() => onDrill(r.path)}
|
||||
>
|
||||
<ChevronRight aria-hidden />
|
||||
</button>
|
||||
) : (
|
||||
<span className="cw-usage-spacer" aria-hidden />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<UsageSnapshot | null>(null);
|
||||
const [refused, setRefused] = useState(false);
|
||||
const [tick, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
let run: ReturnType<typeof startUsage> = 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<HTMLElement>(`[${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 (
|
||||
<Modal label={t("cw_files_usage")} className="cw-viewer cw-usage" onBack={canUp ? onUp : onClose}>
|
||||
<UsageView
|
||||
root={root}
|
||||
canUp={canUp}
|
||||
snap={snap}
|
||||
refused={refused}
|
||||
onUp={onUp}
|
||||
onDrill={onDrill}
|
||||
onOpen={onOpen}
|
||||
onCancel={() => void stopUsage()}
|
||||
onAgain={() => setTick((n) => n + 1)}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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<string, Entry[]>, opts: { page?: number; fail?: Record<string, unknown>; 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<ListPage> => {
|
||||
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<string, Entry[]> = {
|
||||
"/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<string, Entry[]> = { "/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<void>((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<void>((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;
|
||||
});
|
||||
});
|
||||
@@ -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<ListPage>("/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<UsageSnapshot> {
|
||||
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<UsageEnd, null>, 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<UsageSnapshot> } | 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<UsageSnapshot>; 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<void> {
|
||||
const r = running;
|
||||
if (!r) return;
|
||||
r.cancel();
|
||||
await r.done.catch(() => undefined);
|
||||
}
|
||||
@@ -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": "أُلغي.",
|
||||
|
||||
@@ -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": "বাতিল করা হয়েছে।",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "لغو شد.",
|
||||
|
||||
@@ -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é.",
|
||||
|
||||
@@ -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": "रद्द किया।",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "キャンセルしました。",
|
||||
|
||||
@@ -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": "취소했습니다.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "Отменено.",
|
||||
|
||||
@@ -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": "ยกเลิกแล้ว",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "已取消。",
|
||||
|
||||
@@ -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": "已取消。",
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
@@ -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),
|
||||
|
||||
@@ -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]);
|
||||
/* `,"<root>"` 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user