mirror of
https://github.com/phantomptr/ps5upload.git
synced 2026-10-19 02:20:02 -05:00
console files: search and viewer hardening
Index name match is linear (iterative, one backtrack point; selftest bounds the pathological pattern); star runs collapsed. INDEX_SEARCH filters an optional "under" before its limit; This folder says when matches elsewhere filled the reply. Text viewer draws a window around a find target with memoized blocks and one scan per query. Hex reads only 64 KiB; end-of-list buttons keep focus (hex First/Last added); D-pad left/right on the picture changes image (nav hands arrows to [data-nav-arrows]); SFO key scan bounded; folder size and disk usage share one walk; Search waits for the drives; Disk usage starts at the folder shown. 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
ec2c8f3a6b
commit
bf7157a5ff
70 files changed
+719
-171
No files matched your search
@@ -915,6 +915,11 @@ test-payload: payload
|
||||
$(PAYLOAD_DIR)/ava1/gen/ava1_gen.c $(PAYLOAD_DIR)/ava1/ava1_wire.c
|
||||
@/tmp/ps5upload-webui-codec-selftest
|
||||
@echo "✓ Unicode, quoted and very large folders survive the web UI's JSON both ways"
|
||||
@echo "Running file index name-match self-test (host build)..."
|
||||
@cc -O2 -Wall -Wextra -Werror -I$(PAYLOAD_DIR)/include \
|
||||
-o /tmp/ps5upload-index-glob-selftest $(PAYLOAD_DIR)/tests/index_glob_selftest.c
|
||||
@/tmp/ps5upload-index-glob-selftest
|
||||
@echo "✓ index name patterns match in linear time, whatever the pattern"
|
||||
@echo "Running file manager prefs self-test (host build)..."
|
||||
@cc -O2 -Wall -Wextra -Werror -pthread -I$(PAYLOAD_DIR)/include -I$(PAYLOAD_DIR)/ava1 -I$(PAYLOAD_DIR)/ava1/gen \
|
||||
-o /tmp/ps5upload-files-prefs-selftest \
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Binary, ChevronLeft, ChevronRight, FileText } from "lucide-react";
|
||||
import { callBlob } from "../api";
|
||||
import { t } from "../strings";
|
||||
import { NAV_ARROW_EVENT } from "../nav";
|
||||
import { Modal } from "../ui/Modal";
|
||||
import { HexView } from "./viewer/HexView";
|
||||
import { HEX_MAX } from "./viewer/hex";
|
||||
@@ -26,10 +27,11 @@ export async function readUpTo(path: string, max: number, signal?: AbortSignal):
|
||||
let at = 0;
|
||||
while (at < max) {
|
||||
if (signal?.aborted) throw new Error("aborted");
|
||||
const b = await callBlob("/api/fs/read", { path, offset: at, len: VIEW_MAX }, signal);
|
||||
const want = Math.min(VIEW_MAX, max - at);
|
||||
const b = await callBlob("/api/fs/read", { path, offset: at, len: want }, signal);
|
||||
parts.push(b);
|
||||
at += b.size;
|
||||
if (b.size < VIEW_MAX) break;
|
||||
if (b.size < want) break;
|
||||
}
|
||||
return parts.length === 1 ? parts[0] : new Blob(parts);
|
||||
}
|
||||
@@ -124,12 +126,22 @@ export function Viewer({
|
||||
const prevImage = nb?.prev ?? null;
|
||||
const nextImage = nb?.next ?? null;
|
||||
|
||||
/** The picture's blob: URL shown now: revoked when the next one replaces it, and on close. */
|
||||
const shownUrl = useRef<string | null>(null);
|
||||
const showUrl = useCallback((url: string | null) => {
|
||||
const old = shownUrl.current;
|
||||
shownUrl.current = url;
|
||||
if (old && old !== url) URL.revokeObjectURL(old);
|
||||
}, []);
|
||||
useEffect(() => () => showUrl(null), [showUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
let url: string | null = null;
|
||||
const stop = new AbortController();
|
||||
setLoaded(null);
|
||||
// The next image keeps the one shown (and the focus on it) until it arrives.
|
||||
setLoaded((l) => (mode === "image" && l?.kind === "image" ? l : null));
|
||||
if (mode === "image" && imageTooLarge(size, null)) {
|
||||
showUrl(null);
|
||||
setLoaded({ kind: "too_large" }); // nothing read: it would not be shown
|
||||
return;
|
||||
}
|
||||
@@ -139,12 +151,14 @@ export function Viewer({
|
||||
if (!live) return;
|
||||
if (mode === "image") {
|
||||
if (imageTooLarge(size, blob.size)) {
|
||||
showUrl(null);
|
||||
setLoaded({ kind: "too_large" });
|
||||
return;
|
||||
}
|
||||
const ext = name.slice(name.lastIndexOf(".") + 1).toLowerCase();
|
||||
url = URL.createObjectURL(new Blob([blob], { type: ext === "png" ? "image/png" : "image/jpeg" }));
|
||||
const url = URL.createObjectURL(new Blob([blob], { type: ext === "png" ? "image/png" : "image/jpeg" }));
|
||||
setLoaded({ kind: "image", url, size: blob.size });
|
||||
showUrl(url);
|
||||
return;
|
||||
}
|
||||
const buf = await new Response(blob).arrayBuffer();
|
||||
@@ -157,30 +171,31 @@ export function Viewer({
|
||||
setLoaded({ kind: "text", model: textModel(path, name, text), size: blob.size });
|
||||
},
|
||||
() => {
|
||||
if (live) setLoaded({ kind: "error" });
|
||||
if (!live) return;
|
||||
showUrl(null);
|
||||
setLoaded({ kind: "error" });
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
live = false;
|
||||
stop.abort();
|
||||
if (url) URL.revokeObjectURL(url);
|
||||
};
|
||||
}, [path, name, mode, size]);
|
||||
}, [path, name, mode, size, showUrl]);
|
||||
|
||||
// ← / → on the picture itself (when the browser delivers them): previous / next image.
|
||||
// ← / → (D-pad or keys) on the picture itself: previous / next image. The nav layer hands
|
||||
// left/right on a [data-nav-arrows] element to it as a NAV_ARROW_EVENT instead of moving focus.
|
||||
const loadedImage = loaded?.kind === "image";
|
||||
useEffect(() => {
|
||||
if (!onOpenImage || (!prevImage && !nextImage) || typeof window === "undefined") return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (!imageRef.current || document.activeElement !== imageRef.current) return;
|
||||
const to = e.key === "ArrowLeft" ? prevImage : e.key === "ArrowRight" ? nextImage : null;
|
||||
if (!to) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onOpenImage(to);
|
||||
const img = imageRef.current;
|
||||
if (!img || !onOpenImage) return;
|
||||
const onArrow = (e: Event) => {
|
||||
const dir = (e as CustomEvent<"left" | "right">).detail;
|
||||
const to = dir === "left" ? prevImage : nextImage;
|
||||
if (to) onOpenImage(to);
|
||||
};
|
||||
window.addEventListener("keydown", onKey, true);
|
||||
return () => window.removeEventListener("keydown", onKey, true);
|
||||
}, [prevImage, nextImage, onOpenImage]);
|
||||
img.addEventListener(NAV_ARROW_EVENT, onArrow);
|
||||
return () => img.removeEventListener(NAV_ARROW_EVENT, onArrow);
|
||||
}, [prevImage, nextImage, onOpenImage, loadedImage]);
|
||||
|
||||
return (
|
||||
<Modal label={name} className="cw-viewer" onBack={onClose}>
|
||||
@@ -218,14 +233,22 @@ export function Viewer({
|
||||
data-action="image-prev"
|
||||
aria-label={t("cw_files_vw_image_prev")}
|
||||
data-tip={t("cw_files_vw_image_prev")}
|
||||
disabled={!nb.prev}
|
||||
aria-disabled={!nb.prev || undefined}
|
||||
onClick={() => nb.prev && onOpenImage(nb.prev)}
|
||||
>
|
||||
<ChevronLeft aria-hidden />
|
||||
</button>
|
||||
) : null}
|
||||
{loaded?.kind === "image" ? (
|
||||
<img ref={imageRef} className="cw-focusable" tabIndex={0} src={loaded.url} alt={name} data-image-view="" />
|
||||
<img
|
||||
ref={imageRef}
|
||||
className="cw-focusable"
|
||||
tabIndex={0}
|
||||
src={loaded.url}
|
||||
alt={name}
|
||||
data-image-view=""
|
||||
data-nav-arrows={onOpenImage ? "" : undefined}
|
||||
/>
|
||||
) : null}
|
||||
{mode === "image" && nb && onOpenImage ? (
|
||||
<button
|
||||
@@ -234,7 +257,7 @@ export function Viewer({
|
||||
data-action="image-next"
|
||||
aria-label={t("cw_files_vw_image_next")}
|
||||
data-tip={t("cw_files_vw_image_next")}
|
||||
disabled={!nb.next}
|
||||
aria-disabled={!nb.next || undefined}
|
||||
onClick={() => nb.next && onOpenImage(nb.next)}
|
||||
>
|
||||
<ChevronRight aria-hidden />
|
||||
|
||||
@@ -114,9 +114,27 @@ export async function walkFolder(root: string, opts: WalkOptions, deps: WalkDeps
|
||||
|
||||
let running: { cancel: () => void } | null = null;
|
||||
|
||||
/** A walk runs now (another Info dialog's). */
|
||||
/** Another page-wide walk (Disk usage) holds the walker: folder size waits for it. */
|
||||
let held = false;
|
||||
|
||||
/** A walk runs now (another Info dialog's, or Disk usage's). */
|
||||
export function folderSizeRunning(): boolean {
|
||||
return running != null;
|
||||
return running != null || held;
|
||||
}
|
||||
|
||||
/**
|
||||
* Take the page's one walk for another tool (Disk usage): null while a folder
|
||||
* size walk (or another holder) runs, else the function that gives it back.
|
||||
*/
|
||||
export function holdWalk(): (() => void) | null {
|
||||
if (running || held) return null;
|
||||
held = true;
|
||||
let given = false;
|
||||
return () => {
|
||||
if (given) return;
|
||||
given = true;
|
||||
held = false;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,7 +147,7 @@ export function startFolderSize(
|
||||
deps?: WalkDeps,
|
||||
limits?: { maxEntries?: number; maxMs?: number },
|
||||
): { done: Promise<SizeResult>; cancel: () => void } | null {
|
||||
if (running) return null;
|
||||
if (running || held) return null;
|
||||
let cancelled = false;
|
||||
const me = {
|
||||
cancel: () => {
|
||||
|
||||
@@ -36,6 +36,9 @@ 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;
|
||||
// Nothing here, but the helper stopped at its limit: matches elsewhere filled it (an older helper
|
||||
// filters "This folder" after its limit). Not "nothing matches".
|
||||
if (!n && s.done.results.truncated) return t("cw_files_search_none_capped");
|
||||
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 });
|
||||
}
|
||||
@@ -43,6 +46,7 @@ export function resultsText(s: SearchState): string | null {
|
||||
/** The props-only view (tests render it). */
|
||||
export function SearchView({
|
||||
state,
|
||||
ready = true,
|
||||
field,
|
||||
scope,
|
||||
folder,
|
||||
@@ -55,6 +59,8 @@ export function SearchView({
|
||||
onClose,
|
||||
}: {
|
||||
state: SearchState;
|
||||
/** The scope's roots are known (the drives have loaded): Search can run. */
|
||||
ready?: boolean;
|
||||
field: string;
|
||||
scope: Scope;
|
||||
folder: string;
|
||||
@@ -84,7 +90,7 @@ export function SearchView({
|
||||
role="search"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!bad) onSubmit();
|
||||
if (!bad && ready) onSubmit();
|
||||
}}
|
||||
>
|
||||
<label className="cw-tv-find">
|
||||
@@ -108,7 +114,7 @@ export function SearchView({
|
||||
type="submit"
|
||||
className="cw-focusable cw-button is-primary"
|
||||
data-action="search-go"
|
||||
disabled={state.unsupported || !field.trim() || bad || state.searching}
|
||||
disabled={!ready || state.unsupported || !field.trim() || bad || state.searching}
|
||||
>
|
||||
<span>{t("cw_files_search_go")}</span>
|
||||
</button>
|
||||
@@ -235,6 +241,7 @@ export function SearchDialog({
|
||||
<Modal label={t("cw_files_search")} className="cw-viewer cw-search" onBack={onClose}>
|
||||
<SearchView
|
||||
state={state}
|
||||
ready={roots.length > 0}
|
||||
field={field}
|
||||
scope={scope}
|
||||
folder={folder}
|
||||
@@ -242,7 +249,9 @@ export function SearchDialog({
|
||||
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)}
|
||||
onRebuild={() => {
|
||||
if (roots.length) void flow.current?.rebuild(roots);
|
||||
}}
|
||||
onReveal={(h) => onReveal(h.folder, h.name)}
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
||||
@@ -77,3 +77,6 @@
|
||||
padding: 0.3rem 0;
|
||||
justify-content: center;
|
||||
}
|
||||
.cw-usage .cw-button[aria-disabled="true"] {
|
||||
opacity: 0.4;
|
||||
}
|
||||
@@ -22,6 +22,7 @@ describe("search helpers", () => {
|
||||
expect(namePattern(" eboot ")).toBe("*eboot*");
|
||||
expect(namePattern("*.pkg")).toBe("*.pkg");
|
||||
expect(namePattern("CUSA0784?")).toBe("CUSA0784?");
|
||||
expect(namePattern("**a***b?**")).toBe("*a*b?*"); // runs of stars are one star
|
||||
expect(namePattern("")).toBeNull();
|
||||
expect(namePattern(" ")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -56,7 +56,8 @@ function plain(s: string): boolean {
|
||||
export function namePattern(text: string): string | null {
|
||||
const q = text.trim();
|
||||
if (!q) return null;
|
||||
const p = /[*?]/.test(q) ? q : `*${q}*`;
|
||||
// A run of stars means one star (fewer backtrack points for the helper's matcher).
|
||||
const p = (/[*?]/.test(q) ? q : `*${q}*`).replace(/\*{2,}/g, "*");
|
||||
return plain(p) ? p : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -84,10 +84,11 @@ describe("search page", () => {
|
||||
});
|
||||
|
||||
const base = (over: Partial<SearchState>): SearchState => ({ ...INITIAL_SEARCH, ...over });
|
||||
const view = (s: SearchState, field = "eboot") =>
|
||||
const view = (s: SearchState, field = "eboot", ready = true) =>
|
||||
renderToStaticMarkup(
|
||||
<SearchView
|
||||
state={s}
|
||||
ready={ready}
|
||||
field={field}
|
||||
scope="all"
|
||||
folder="/data/g"
|
||||
@@ -126,6 +127,10 @@ describe("search page", () => {
|
||||
expect(resultsText(base({ done: { pattern: "*x*", scope: "all", results: { hits: [], truncated: false } } }))).toBe(
|
||||
t("cw_files_search_none", { q: "*x*" }),
|
||||
);
|
||||
// nothing in this folder but the helper stopped at its limit: not "nothing matches"
|
||||
expect(resultsText(base({ done: { pattern: "*x*", scope: "folder", results: { hits: [], truncated: true } } }))).toBe(
|
||||
t("cw_files_search_none_capped"),
|
||||
);
|
||||
});
|
||||
|
||||
it("an older helper, a bad pattern, an error", () => {
|
||||
@@ -135,6 +140,9 @@ describe("search page", () => {
|
||||
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");
|
||||
// the drives have not loaded: nothing to search yet
|
||||
expect(view(base({}), "eboot", false)).toMatch(/data-action="search-go"[^>]*disabled/);
|
||||
expect(view(base({}), "eboot", true)).not.toMatch(/data-action="search-go"[^>]*disabled/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -182,7 +190,7 @@ describe("disk usage page", () => {
|
||||
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).toMatch(/data-action="usage-up" aria-disabled="true"/);
|
||||
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("…");
|
||||
|
||||
@@ -79,7 +79,7 @@ export function UsageView({
|
||||
</button>
|
||||
</div>
|
||||
<div className="cw-tv-bar">
|
||||
<button type="button" className="cw-focusable cw-button" data-action="usage-up" disabled={!canUp} onClick={onUp}>
|
||||
<button type="button" className="cw-focusable cw-button" data-action="usage-up" aria-disabled={!canUp || undefined} onClick={onUp}>
|
||||
<ArrowUp aria-hidden />
|
||||
<span>{t("cw_files_up")}</span>
|
||||
</button>
|
||||
@@ -157,8 +157,8 @@ export function UsageView({
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Disk usage (a Files tool): the subfolders of a folder (at first the one
|
||||
* Files shows; Up goes as far as its drive) 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.
|
||||
*/
|
||||
@@ -168,14 +168,15 @@ export function UsageDialog({
|
||||
onOpen,
|
||||
onClose,
|
||||
}: {
|
||||
/** The folder Files shows: the measurement starts at its drive. */
|
||||
/** The folder Files shows: the measurement starts there (Up reaches its drive). */
|
||||
folder: string;
|
||||
volumes: Volume[];
|
||||
onOpen: (path: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
// Up goes as far as the drive; the measurement starts at the folder Files shows.
|
||||
const top = (volumeOf(folder, volumes)?.path ?? folder).replace(/\/+$/, "") || "/";
|
||||
const [root, setRoot] = useState(top);
|
||||
const [root, setRoot] = useState(() => folder.replace(/\/+$/, "") || "/");
|
||||
const [snap, setSnap] = useState<UsageSnapshot | null>(null);
|
||||
const [refused, setRefused] = useState(false);
|
||||
const [tick, setTick] = useState(0);
|
||||
@@ -205,10 +206,10 @@ export function UsageDialog({
|
||||
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) {
|
||||
if (el && el.getAttribute("aria-disabled") !== "true") {
|
||||
focusNext.current = null;
|
||||
focusElement(el);
|
||||
} else if (snap?.end != null) focusNext.current = null;
|
||||
} else if (snap && snap.root === root && snap.end != null) focusNext.current = null; // it ended without that row
|
||||
}, [root, snap]);
|
||||
|
||||
const canUp = root !== top && root !== "/";
|
||||
|
||||
@@ -162,6 +162,9 @@ describe("disk usage", () => {
|
||||
await walk?.done;
|
||||
const again = startUsage("/data", () => undefined, slow);
|
||||
expect(again).not.toBeNull();
|
||||
// and the other way round: a folder size walk waits while Disk usage runs
|
||||
expect(startFolderSize("/data", () => undefined)).toBeNull();
|
||||
await again?.done;
|
||||
expect(usageRunning()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@
|
||||
* while another measurement or a "Calculate folder size" runs.
|
||||
*/
|
||||
import { ApiError, call } from "../../api";
|
||||
import { folderSizeRunning, walkFolder, type ListPage, type WalkDeps } from "../folderSize";
|
||||
import { holdWalk, walkFolder, type ListPage, type WalkDeps } from "../folderSize";
|
||||
import { joinPath } from "../move";
|
||||
import { LIST_PAGE } from "../useDir";
|
||||
|
||||
@@ -196,12 +196,16 @@ export function startUsage(
|
||||
deps?: WalkDeps,
|
||||
limits?: UsageLimits,
|
||||
): { done: Promise<UsageSnapshot>; cancel: () => void } | null {
|
||||
if (running || folderSizeRunning()) return null;
|
||||
if (running) return null;
|
||||
// One walk page-wide: Disk usage and "Calculate folder size" never run together.
|
||||
const release = holdWalk();
|
||||
if (!release) return null;
|
||||
let cancelled = false;
|
||||
const cancel = () => {
|
||||
cancelled = true;
|
||||
};
|
||||
const done = measureUsage(root, { cancelled: () => cancelled, onUpdate, ...limits }, deps).finally(() => {
|
||||
release();
|
||||
if (running?.done === done) running = null;
|
||||
});
|
||||
running = { cancel, done };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react";
|
||||
import { formatBytes } from "../../../lib/format";
|
||||
import { t } from "../../strings";
|
||||
import { HEX_PAGE, hexPage, hexPages } from "./hex";
|
||||
@@ -10,7 +10,8 @@ const HEX_BLOCK = 16;
|
||||
|
||||
/**
|
||||
* The first 64 KiB of a file as offset | hex | ASCII, a page (1 KiB) at a time
|
||||
* with Previous / Next page buttons. `size` is the whole file's, when known.
|
||||
* with First / Previous / Next / Last page buttons. A button at the end stays
|
||||
* focusable (marked aria-disabled, a no-op), so the focus never drops off it. `size` is the whole file's, when known.
|
||||
*/
|
||||
export function HexView({ bytes, size }: { bytes: Uint8Array; size?: number }) {
|
||||
const [page, setPage] = useState(0);
|
||||
@@ -24,11 +25,21 @@ export function HexView({ bytes, size }: { bytes: Uint8Array; size?: number }) {
|
||||
return (
|
||||
<div className="cw-hex">
|
||||
<div className="cw-tv-bar">
|
||||
<button
|
||||
type="button"
|
||||
className="cw-focusable cw-button"
|
||||
data-action="hex-first"
|
||||
aria-disabled={page <= 0 || undefined}
|
||||
onClick={() => setPage(0)}
|
||||
>
|
||||
<ChevronsLeft aria-hidden />
|
||||
<span>{t("cw_files_vw_page_first")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="cw-focusable cw-button"
|
||||
data-action="hex-prev"
|
||||
disabled={page <= 0}
|
||||
aria-disabled={page <= 0 || undefined}
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
>
|
||||
<ChevronLeft aria-hidden />
|
||||
@@ -38,12 +49,22 @@ export function HexView({ bytes, size }: { bytes: Uint8Array; size?: number }) {
|
||||
type="button"
|
||||
className="cw-focusable cw-button"
|
||||
data-action="hex-next"
|
||||
disabled={page >= pages - 1}
|
||||
aria-disabled={page >= pages - 1 || undefined}
|
||||
onClick={() => setPage((p) => Math.min(pages - 1, p + 1))}
|
||||
>
|
||||
<ChevronRight aria-hidden />
|
||||
<span>{t("cw_files_vw_page_next")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="cw-focusable cw-button"
|
||||
data-action="hex-last"
|
||||
aria-disabled={page >= pages - 1 || undefined}
|
||||
onClick={() => setPage(pages - 1)}
|
||||
>
|
||||
<ChevronsRight aria-hidden />
|
||||
<span>{t("cw_files_vw_page_last")}</span>
|
||||
</button>
|
||||
<span className="cw-dim cw-tv-count" role="status" data-hex-page="">
|
||||
{t("cw_files_vw_page", { at: page + 1, n: pages })}
|
||||
{bytes.length ? ` · 0x${from.toString(16)}–0x${Math.max(from, to).toString(16)}` : ""}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ChevronDown, ChevronUp, Search, WrapText } from "lucide-react";
|
||||
import { t } from "../../strings";
|
||||
import {
|
||||
@@ -8,9 +8,10 @@ import {
|
||||
highlight,
|
||||
ROW_WINDOW,
|
||||
stepMatch,
|
||||
windowFor,
|
||||
windowAround,
|
||||
type Match,
|
||||
type Row,
|
||||
type RowWindow,
|
||||
} from "./text";
|
||||
import "./viewer.css";
|
||||
|
||||
@@ -23,60 +24,132 @@ export function findCountText(query: string, count: number, current: number, cap
|
||||
return capped ? `${at} · ${t("cw_files_vw_find_capped")}` : at;
|
||||
}
|
||||
|
||||
type Mark = { start: number; end: number; index: number };
|
||||
|
||||
interface Found {
|
||||
query: string;
|
||||
matches: Match[];
|
||||
capped: boolean;
|
||||
}
|
||||
|
||||
const NONE: Found = { query: "", matches: [], capped: false };
|
||||
|
||||
/** One focusable block of rows. Memoized: Next / Previous re-render only the blocks whose current mark changes. */
|
||||
const Block = memo(function Block({
|
||||
rows,
|
||||
first,
|
||||
marks,
|
||||
current,
|
||||
}: {
|
||||
rows: Row[];
|
||||
/** Index of its first row. */
|
||||
first: number;
|
||||
/** Its rows' matches, by row index (stable while the query is). */
|
||||
marks: Map<number, Mark[]> | undefined;
|
||||
/** The current match's index when it is in this block, else -1. */
|
||||
current: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="cw-focusable cw-tv-block" tabIndex={0}>
|
||||
{rows.map((r, ri) => {
|
||||
const m = marks?.get(first + ri);
|
||||
return (
|
||||
<div key={ri} className="cw-tv-row">
|
||||
<span className="cw-tv-n" aria-hidden>
|
||||
{r.cont ? "" : r.line}
|
||||
</span>
|
||||
<span className="cw-tv-t">
|
||||
{m
|
||||
? highlight(r.text, m).map(([s, mi], k) =>
|
||||
mi < 0 ? (
|
||||
s
|
||||
) : (
|
||||
<mark key={k} data-match={mi} className={mi === current ? "is-current" : undefined}>
|
||||
{s}
|
||||
</mark>
|
||||
),
|
||||
)
|
||||
: r.text}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Numbered rows of text with find (the field, then Previous / Next: on-screen
|
||||
* buttons, ✕ on each) and a wrap toggle. Rows are drawn ROW_WINDOW at a time
|
||||
* ("Show more lines", or a match further down extends it), in focusable
|
||||
* blocks the D-pad steps through. Text is rendered as text nodes, never HTML.
|
||||
* buttons, ✕ on each) and a wrap toggle. A window of rows is drawn (ROW_WINDOW
|
||||
* at first; "Show more" / "Show earlier" extend it; a find jump outside it
|
||||
* draws a fresh window around the match), in memoized focusable blocks the
|
||||
* D-pad steps through. Text is rendered as text nodes, never HTML.
|
||||
*/
|
||||
export function TextView({ rows }: { rows: Row[] }) {
|
||||
const [field, setField] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [current, setCurrent] = useState(-1);
|
||||
export function TextView({ rows, initialQuery }: { rows: Row[]; /** Tests: start with this found. */ initialQuery?: string }) {
|
||||
const [state, setState] = useState(() => {
|
||||
const found = initialQuery ? { query: initialQuery, ...findMatches(rows, initialQuery) } : NONE;
|
||||
const current = found.matches.length ? 0 : -1;
|
||||
const win0: RowWindow = { start: 0, end: Math.min(rows.length, ROW_WINDOW) };
|
||||
const win = current >= 0 ? windowAround(found.matches[0].row, win0, rows.length) : win0;
|
||||
return { found, current, win };
|
||||
});
|
||||
const [field, setField] = useState(initialQuery ?? "");
|
||||
const [wrap, setWrap] = useState(true);
|
||||
const [limit, setLimit] = useState(ROW_WINDOW);
|
||||
const body = useRef<HTMLDivElement>(null);
|
||||
const found = useMemo(() => findMatches(rows, query), [rows, query]);
|
||||
const { found, current, win } = state;
|
||||
const { matches, capped } = found;
|
||||
|
||||
const byRow = useMemo(() => {
|
||||
const m = new Map<number, { start: number; end: number; index: number }[]>();
|
||||
matches.forEach((x: Match, index) => {
|
||||
const list = m.get(x.row);
|
||||
if (list) list.push({ start: x.start, end: x.end, index });
|
||||
else m.set(x.row, [{ start: x.start, end: x.end, index }]);
|
||||
/** Every block of the file (slices made once per file). */
|
||||
const blocks = useMemo(() => {
|
||||
const out: Row[][] = [];
|
||||
for (let i = 0; i < rows.length; i += BLOCK_ROWS) out.push(rows.slice(i, i + BLOCK_ROWS));
|
||||
return out;
|
||||
}, [rows]);
|
||||
|
||||
/** Matches by block, then by row (once per query). */
|
||||
const byBlock = useMemo(() => {
|
||||
const m = new Map<number, Map<number, Mark[]>>();
|
||||
matches.forEach((x, index) => {
|
||||
const b = Math.floor(x.row / BLOCK_ROWS);
|
||||
let rowsOf = m.get(b);
|
||||
if (!rowsOf) m.set(b, (rowsOf = new Map()));
|
||||
const list = rowsOf.get(x.row);
|
||||
const mark = { start: x.start, end: x.end, index };
|
||||
if (list) list.push(mark);
|
||||
else rowsOf.set(x.row, [mark]);
|
||||
});
|
||||
return m;
|
||||
}, [matches]);
|
||||
|
||||
// A new current match: draw down to it, then bring it into view (focus stays on Next / Previous).
|
||||
// The current match into view (focus stays on Next / Previous).
|
||||
useEffect(() => {
|
||||
if (current < 0 || current >= matches.length) return;
|
||||
const row = matches[current].row;
|
||||
if (row >= limit) {
|
||||
setLimit((l) => windowFor(row, l));
|
||||
return;
|
||||
}
|
||||
if (current < 0) return;
|
||||
const el = body.current?.querySelector<HTMLElement>(`[data-match="${current}"]`);
|
||||
if (el && typeof el.scrollIntoView === "function") el.scrollIntoView({ block: "center", inline: "nearest" });
|
||||
}, [current, matches, limit]);
|
||||
}, [current, win]);
|
||||
|
||||
/** Find what the field holds: a new query starts at the first match; the same one steps on. */
|
||||
/** Find what the field holds: a new query (scanned once) starts at its first match; the same one steps on. */
|
||||
const go = (dir: 1 | -1) => {
|
||||
if (field !== query) {
|
||||
const next = findMatches(rows, field);
|
||||
setQuery(field);
|
||||
setCurrent(dir === 1 ? firstMatchFrom(next.matches, 0) : next.matches.length - 1);
|
||||
return;
|
||||
}
|
||||
setCurrent((c) => stepMatch(c, matches.length, dir));
|
||||
setState((s) => {
|
||||
let f = s.found;
|
||||
let cur: number;
|
||||
if (field !== f.query) {
|
||||
f = { query: field, ...findMatches(rows, field) };
|
||||
cur = dir === 1 ? firstMatchFrom(f.matches, s.win.start) : f.matches.length - 1;
|
||||
} else {
|
||||
cur = stepMatch(s.current, f.matches.length, dir);
|
||||
}
|
||||
const w = cur >= 0 ? windowAround(f.matches[cur].row, s.win, rows.length) : s.win;
|
||||
return { found: f, current: cur, win: w };
|
||||
});
|
||||
};
|
||||
const setWin = (w: RowWindow) => setState((s) => ({ ...s, win: w }));
|
||||
|
||||
const shown = rows.slice(0, limit);
|
||||
const blocks: Row[][] = [];
|
||||
for (let i = 0; i < shown.length; i += BLOCK_ROWS) blocks.push(shown.slice(i, i + BLOCK_ROWS));
|
||||
const firstBlock = Math.floor(win.start / BLOCK_ROWS);
|
||||
const lastBlock = Math.ceil(win.end / BLOCK_ROWS);
|
||||
const curBlock = current >= 0 ? Math.floor(matches[current].row / BLOCK_ROWS) : -1;
|
||||
const gutter = String(rows.length ? rows[rows.length - 1].line : 1).length;
|
||||
const count = findCountText(query, matches.length, current, capped);
|
||||
const count = findCountText(found.query, matches.length, current, capped);
|
||||
|
||||
return (
|
||||
<div className="cw-tv">
|
||||
@@ -127,42 +200,28 @@ export function TextView({ rows }: { rows: Row[] }) {
|
||||
</form>
|
||||
<div ref={body} className={`cw-tv-body${wrap ? " is-wrap" : ""}`} style={{ ["--cw-gutter" as string]: `${gutter}ch` }}>
|
||||
{!rows.length ? <p className="cw-dim">{t("cw_files_vw_empty")}</p> : null}
|
||||
{blocks.map((b, bi) => (
|
||||
<div key={bi} className="cw-focusable cw-tv-block" tabIndex={0}>
|
||||
{b.map((r, ri) => {
|
||||
const index = bi * BLOCK_ROWS + ri;
|
||||
const marks = byRow.get(index);
|
||||
return (
|
||||
<div key={ri} className="cw-tv-row">
|
||||
<span className="cw-tv-n" aria-hidden>
|
||||
{r.cont ? "" : r.line}
|
||||
</span>
|
||||
<span className="cw-tv-t">
|
||||
{marks
|
||||
? highlight(r.text, marks).map(([s, mi], k) =>
|
||||
mi < 0 ? (
|
||||
s
|
||||
) : (
|
||||
<mark key={k} data-match={mi} className={mi === current ? "is-current" : undefined}>
|
||||
{s}
|
||||
</mark>
|
||||
),
|
||||
)
|
||||
: r.text}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
{rows.length > limit ? (
|
||||
{win.start > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="cw-focusable cw-button cw-more"
|
||||
data-action="earlier-lines"
|
||||
onClick={() => setWin({ start: Math.max(0, win.start - ROW_WINDOW), end: win.end })}
|
||||
>
|
||||
<span>{t("cw_files_vw_earlier_lines", { n: win.start.toLocaleString() })}</span>
|
||||
</button>
|
||||
) : null}
|
||||
{blocks.slice(firstBlock, lastBlock).map((b, i) => {
|
||||
const k = firstBlock + i;
|
||||
return <Block key={k} rows={b} first={k * BLOCK_ROWS} marks={byBlock.get(k)} current={k === curBlock ? current : -1} />;
|
||||
})}
|
||||
{rows.length > win.end ? (
|
||||
<button
|
||||
type="button"
|
||||
className="cw-focusable cw-button cw-more"
|
||||
data-action="more-lines"
|
||||
onClick={() => setLimit((l) => l + ROW_WINDOW)}
|
||||
onClick={() => setWin({ start: win.start, end: Math.min(rows.length, win.end + ROW_WINDOW) })}
|
||||
>
|
||||
<span>{t("cw_files_vw_more_lines", { n: (rows.length - limit).toLocaleString() })}</span>
|
||||
<span>{t("cw_files_vw_more_lines", { n: (rows.length - win.end).toLocaleString() })}</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -107,6 +107,12 @@ describe("param.sfo", () => {
|
||||
expect(keys?.TITLE_ID).toBe("CUSA07842");
|
||||
});
|
||||
|
||||
it("a key without its NUL within 64 bytes is skipped", () => {
|
||||
const long = sfo([["X".repeat(70), { s: "v" }], ["TITLE_ID", { s: "CUSA00001" }]]);
|
||||
const keys = parseSfo(long);
|
||||
expect(Object.keys(keys ?? {})).toEqual(["TITLE_ID"]);
|
||||
});
|
||||
|
||||
it("without TITLE and TITLE_ID it names no game", () => {
|
||||
expect(sfoInfo(parseSfo(sfo([["CATEGORY", { s: "gd" }]])))).toBeNull();
|
||||
});
|
||||
|
||||
@@ -68,6 +68,8 @@ export function paramJsonText(text: string): GameInfo | null {
|
||||
const PSF_MAGIC = [0x00, 0x50, 0x53, 0x46];
|
||||
/** Entries read at most (a real param.sfo has ~20-60). */
|
||||
const SFO_MAX_ENTRIES = 512;
|
||||
/** A key name's longest (Sony's are under 20 bytes). */
|
||||
const SFO_KEY_MAX = 64;
|
||||
|
||||
/**
|
||||
* A param.sfo's keys: strings (formats 0x0004 and 0x0204, UTF-8, NULs
|
||||
@@ -94,8 +96,11 @@ export function parseSfo(bytes: Uint8Array): Record<string, string | number> | n
|
||||
const k = keyTable + keyOff;
|
||||
const d = dataTable + dataOff;
|
||||
if (k >= bytes.length || d > bytes.length || len > bytes.length - d) continue;
|
||||
// A key is a short name: look at most SFO_KEY_MAX bytes for its NUL (a damaged table never scans the file).
|
||||
const kmax = Math.min(bytes.length, k + SFO_KEY_MAX);
|
||||
let ke = k;
|
||||
while (ke < bytes.length && bytes[ke] !== 0) ke++;
|
||||
while (ke < kmax && bytes[ke] !== 0) ke++;
|
||||
if (ke >= kmax) continue;
|
||||
const key = dec.decode(bytes.subarray(k, ke));
|
||||
if (!key || Object.prototype.hasOwnProperty.call(out, key)) continue;
|
||||
if (fmt === 0x0004 || fmt === 0x0204) {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
ROW_WINDOW,
|
||||
stepMatch,
|
||||
toRows,
|
||||
windowFor,
|
||||
windowAround,
|
||||
} from "./text";
|
||||
|
||||
describe("text rows", () => {
|
||||
@@ -88,10 +88,19 @@ describe("find", () => {
|
||||
expect(firstMatchFrom([], 0)).toBe(-1);
|
||||
});
|
||||
|
||||
it("draws far enough to show a match further down, never less than now", () => {
|
||||
expect(windowFor(10, ROW_WINDOW)).toBe(ROW_WINDOW);
|
||||
expect(windowFor(ROW_WINDOW, ROW_WINDOW)).toBe(2 * ROW_WINDOW);
|
||||
expect(windowFor(5000, ROW_WINDOW)).toBe(Math.ceil(5001 / ROW_WINDOW) * ROW_WINDOW);
|
||||
it("a find jump draws a bounded window around the match, never everything above it", () => {
|
||||
const w0 = { start: 0, end: ROW_WINDOW };
|
||||
expect(windowAround(10, w0, 50_000)).toBe(w0); // already drawn: unchanged
|
||||
const far = windowAround(40_000, w0, 50_000);
|
||||
expect(far.end - far.start).toBe(ROW_WINDOW);
|
||||
expect(far.start).toBeLessThanOrEqual(40_000);
|
||||
expect(far.end).toBeGreaterThan(40_000);
|
||||
expect(far.start % 30).toBe(0); // starts on a block
|
||||
const near0 = windowAround(100, { start: 30_000, end: 31_500 }, 50_000);
|
||||
expect(near0.start).toBe(0);
|
||||
const end = windowAround(49_999, w0, 50_000);
|
||||
expect(end.end).toBe(50_000);
|
||||
expect(end.start).toBeLessThanOrEqual(49_999);
|
||||
});
|
||||
|
||||
it("cuts a row around its matches", () => {
|
||||
|
||||
@@ -89,10 +89,21 @@ export function firstMatchFrom(matches: Match[], row: number): number {
|
||||
return i < 0 ? 0 : i;
|
||||
}
|
||||
|
||||
/** How many rows to draw so row `row` is among them (never fewer than now). */
|
||||
export function windowFor(row: number, current: number, step: number = ROW_WINDOW): number {
|
||||
if (row < current) return current;
|
||||
return (Math.floor(row / step) + 1) * step;
|
||||
/** The rows drawn: [start, end). */
|
||||
export interface RowWindow {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows to draw so row `row` is among them: the window as it is when it
|
||||
* already holds it, else a fresh ROW_WINDOW-sized one around it (starting on a
|
||||
* block boundary), so a find jump far down never draws everything above it.
|
||||
*/
|
||||
export function windowAround(row: number, win: RowWindow, total: number, size: number = ROW_WINDOW): RowWindow {
|
||||
if (row >= win.start && row < win.end) return win;
|
||||
const start = Math.max(0, Math.floor((row - size / 2) / BLOCK_ROWS) * BLOCK_ROWS);
|
||||
return { start, end: Math.min(total, start + size) };
|
||||
}
|
||||
|
||||
/** A row's text cut around its matches: [text, matchIndex | -1] pieces, in order. */
|
||||
|
||||
@@ -27,7 +27,8 @@
|
||||
width: 1.8rem;
|
||||
height: 1.8rem;
|
||||
}
|
||||
.cw-image-nav:disabled {
|
||||
.cw-image-nav[aria-disabled="true"],
|
||||
.cw-tv-bar .cw-button[aria-disabled="true"] {
|
||||
opacity: 0.35;
|
||||
}
|
||||
.cw-viewer-body.is-text,
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { DirState, Entry } from "./useDir";
|
||||
import { GameBanner, GameBannerView } from "./viewer/GameBanner";
|
||||
import { HexView } from "./viewer/HexView";
|
||||
import { TextView, findCountText } from "./viewer/TextView";
|
||||
import { toRows } from "./viewer/text";
|
||||
import { ROW_WINDOW, toRows } from "./viewer/text";
|
||||
import { GameSummary, textModel, Viewer } from "./Viewer";
|
||||
|
||||
function elements(node: ReactNode): ReactElement<Record<string, unknown>>[] {
|
||||
@@ -154,6 +154,19 @@ describe("viewer in Files", () => {
|
||||
expect(findCountText("x", 10000, 0, true)).toContain(t("cw_files_vw_find_capped"));
|
||||
});
|
||||
|
||||
it("a find jump in a 50,000-line file draws a bounded window around the match", () => {
|
||||
const lines = Array.from({ length: 50_000 }, (_, i) => (i === 40_000 ? "the needle is here" : `line ${i + 1}`));
|
||||
const html = renderToStaticMarkup(<TextView rows={toRows(lines.join("\n"))} initialQuery="needle" />);
|
||||
const drawn = html.split('class="cw-tv-row"').length - 1;
|
||||
expect(drawn).toBeLessThanOrEqual(ROW_WINDOW + 30);
|
||||
expect(drawn).toBeGreaterThan(0);
|
||||
expect(html).toContain('<mark data-match="0" class="is-current">needle</mark>');
|
||||
expect(html).toMatch(/cw-tv-n"[^>]*>40001</);
|
||||
expect(html).not.toMatch(/cw-tv-n"[^>]*>1</);
|
||||
expect(html).toContain('data-action="earlier-lines"');
|
||||
expect(html).toContain('data-action="more-lines"');
|
||||
});
|
||||
|
||||
it("hex view: offset | hex | ASCII, a page at a time", () => {
|
||||
const bytes = new Uint8Array(2048).map((_, i) => i & 255);
|
||||
const html = renderToStaticMarkup(<HexView bytes={bytes} size={1_000_000} />);
|
||||
@@ -161,8 +174,11 @@ describe("viewer in Files", () => {
|
||||
expect(html).toContain("00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f");
|
||||
expect(html).not.toContain("00000400"); // page 2
|
||||
expect(html).toContain(t("cw_files_vw_page", { at: 1, n: 2 }));
|
||||
expect(html).toMatch(/data-action="hex-prev"[^>]*disabled/);
|
||||
expect(html).not.toMatch(/data-action="hex-next"[^>]*disabled/);
|
||||
expect(html).toMatch(/data-action="hex-prev" aria-disabled="true"/);
|
||||
expect(html).toMatch(/data-action="hex-first" aria-disabled="true"/);
|
||||
expect(html).not.toMatch(/data-action="hex-next" aria-disabled/);
|
||||
expect(html).not.toMatch(/data-action="hex-last" aria-disabled/);
|
||||
expect(html).not.toMatch(/data-action="hex-(prev|next|first|last)"[^>]*disabled=""/); // never disabled: focus stays
|
||||
expect(html).toContain("First 64 KiB of");
|
||||
});
|
||||
|
||||
@@ -170,8 +186,9 @@ describe("viewer in Files", () => {
|
||||
const img = renderToStaticMarkup(
|
||||
<Viewer path="/d/a.png" name="a.png" kind="image" images={["a.png", "b.png"]} onOpenImage={vi.fn()} onClose={vi.fn()} />,
|
||||
);
|
||||
expect(img).toMatch(/data-action="image-prev"[^>]*disabled/);
|
||||
expect(img).not.toMatch(/data-action="image-next"[^>]*disabled/);
|
||||
expect(img).toMatch(/data-action="image-prev"[^>]*aria-disabled="true"/);
|
||||
expect(img).not.toMatch(/data-action="image-next"[^>]*aria-disabled/);
|
||||
expect(img).not.toMatch(/data-action="image-(prev|next)"[^>]*disabled=""/);
|
||||
expect(img).toContain(t("cw_files_vw_image_at", { at: 1, n: 2 }));
|
||||
const txt = renderToStaticMarkup(<Viewer path="/d/a.txt" name="a.txt" kind="text" onClose={vi.fn()} />);
|
||||
expect(txt).toContain('data-action="as-hex"');
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"cw_files_search_more": "تُعرض أول {n} نتيجة. هناك المزيد: جرّب اسمًا أطول.",
|
||||
"cw_files_search_no_index": "لا يوجد فهرس بعد. أول بحث ينشئ فهرسًا: يقرأ كل مجلد وقد يستغرق بضع دقائق.",
|
||||
"cw_files_search_none": "لا شيء يطابق «{q}».",
|
||||
"cw_files_search_none_capped": "تطابق ملفات كثيرة جدًا في أماكن أخرى، لذا لم يعد أي ملف من هذا المجلد. استخدم اسمًا أطول، أو «إنشاء من جديد» لفهرسة هذا المجلد وحده.",
|
||||
"cw_files_search_partial": "أُوقف مبكرًا، لذا تنقصه بعض الملفات.",
|
||||
"cw_files_search_ready": "الفهرس: {n} ملف.",
|
||||
"cw_files_search_rebuild": "إنشاء من جديد",
|
||||
@@ -301,6 +302,7 @@
|
||||
"cw_files_view": "عرض",
|
||||
"cw_files_vw_as_hex": "عرض بالنظام الست عشري",
|
||||
"cw_files_vw_as_text": "عرض كنص",
|
||||
"cw_files_vw_earlier_lines": "عرض {n} سطرًا سابقًا",
|
||||
"cw_files_vw_empty": "هذا الملف فارغ.",
|
||||
"cw_files_vw_find": "البحث في هذا الملف",
|
||||
"cw_files_vw_find_at": "{at} من {n}",
|
||||
@@ -315,6 +317,8 @@
|
||||
"cw_files_vw_more_lines": "عرض {n} سطرًا إضافيًا",
|
||||
"cw_files_vw_next": "التالي",
|
||||
"cw_files_vw_page": "الصفحة {at} من {n}",
|
||||
"cw_files_vw_page_first": "الصفحة الأولى",
|
||||
"cw_files_vw_page_last": "الصفحة الأخيرة",
|
||||
"cw_files_vw_page_next": "الصفحة التالية",
|
||||
"cw_files_vw_page_prev": "الصفحة السابقة",
|
||||
"cw_files_vw_prev": "السابق",
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"cw_files_search_more": "প্রথম {n}টি দেখানো হচ্ছে। আরও আছে: লম্বা নাম দিয়ে চেষ্টা করুন।",
|
||||
"cw_files_search_no_index": "এখনও কোনো ইনডেক্স নেই। প্রথম খোঁজ একটি তৈরি করে: এটি প্রতিটি ফোল্ডার পড়ে এবং কয়েক মিনিট লাগতে পারে।",
|
||||
"cw_files_search_none": "“{q}”-এর সাথে কিছু মেলে না।",
|
||||
"cw_files_search_none_capped": "অন্য জায়গায় অনেক বেশি ফাইল মিলে যায়, তাই এই ফোল্ডার থেকে কিছু ফেরেনি। লম্বা নাম দিন, অথবা শুধু এই ফোল্ডার ইনডেক্স করতে “আবার তৈরি করুন” বেছে নিন।",
|
||||
"cw_files_search_partial": "এটি আগেই থামানো হয়েছিল, তাই কিছু ফাইল নেই।",
|
||||
"cw_files_search_ready": "ইনডেক্স: {n}টি ফাইল।",
|
||||
"cw_files_search_rebuild": "আবার তৈরি করুন",
|
||||
@@ -301,6 +302,7 @@
|
||||
"cw_files_view": "দেখুন",
|
||||
"cw_files_vw_as_hex": "হেক্সে দেখুন",
|
||||
"cw_files_vw_as_text": "টেক্সট হিসেবে দেখুন",
|
||||
"cw_files_vw_earlier_lines": "আগের {n}টি লাইন দেখান",
|
||||
"cw_files_vw_empty": "এই ফাইলটি খালি।",
|
||||
"cw_files_vw_find": "এই ফাইলে খুঁজুন",
|
||||
"cw_files_vw_find_at": "{n}-এর মধ্যে {at}",
|
||||
@@ -315,6 +317,8 @@
|
||||
"cw_files_vw_more_lines": "আরও {n}টি লাইন দেখান",
|
||||
"cw_files_vw_next": "পরের",
|
||||
"cw_files_vw_page": "পৃষ্ঠা {at} / {n}",
|
||||
"cw_files_vw_page_first": "প্রথম পৃষ্ঠা",
|
||||
"cw_files_vw_page_last": "শেষ পৃষ্ঠা",
|
||||
"cw_files_vw_page_next": "পরের পৃষ্ঠা",
|
||||
"cw_files_vw_page_prev": "আগের পৃষ্ঠা",
|
||||
"cw_files_vw_prev": "আগের",
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"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_none_capped": "Zu viele Dateien an anderen Orten passen, daher kam keine aus diesem Ordner zurück. Nutze einen längeren Namen oder „Neu erstellen“, um nur diesen Ordner zu indizieren.",
|
||||
"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",
|
||||
@@ -301,6 +302,7 @@
|
||||
"cw_files_view": "Ansehen",
|
||||
"cw_files_vw_as_hex": "Als Hex anzeigen",
|
||||
"cw_files_vw_as_text": "Als Text anzeigen",
|
||||
"cw_files_vw_earlier_lines": "{n} frühere Zeilen anzeigen",
|
||||
"cw_files_vw_empty": "Diese Datei ist leer.",
|
||||
"cw_files_vw_find": "In dieser Datei suchen",
|
||||
"cw_files_vw_find_at": "{at} von {n}",
|
||||
@@ -315,6 +317,8 @@
|
||||
"cw_files_vw_more_lines": "{n} weitere Zeilen anzeigen",
|
||||
"cw_files_vw_next": "Weiter",
|
||||
"cw_files_vw_page": "Seite {at} von {n}",
|
||||
"cw_files_vw_page_first": "Erste Seite",
|
||||
"cw_files_vw_page_last": "Letzte Seite",
|
||||
"cw_files_vw_page_next": "Nächste Seite",
|
||||
"cw_files_vw_page_prev": "Vorherige Seite",
|
||||
"cw_files_vw_prev": "Zurück",
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"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_none_capped": "Too many files elsewhere match, so none in this folder came back. Use a longer name, or Build again to index this folder only.",
|
||||
"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",
|
||||
@@ -301,6 +302,7 @@
|
||||
"cw_files_view": "View",
|
||||
"cw_files_vw_as_hex": "View as hex",
|
||||
"cw_files_vw_as_text": "View as text",
|
||||
"cw_files_vw_earlier_lines": "Show {n} earlier lines",
|
||||
"cw_files_vw_empty": "This file is empty.",
|
||||
"cw_files_vw_find": "Find in this file",
|
||||
"cw_files_vw_find_at": "{at} of {n}",
|
||||
@@ -315,6 +317,8 @@
|
||||
"cw_files_vw_more_lines": "Show {n} more lines",
|
||||
"cw_files_vw_next": "Next",
|
||||
"cw_files_vw_page": "Page {at} of {n}",
|
||||
"cw_files_vw_page_first": "First page",
|
||||
"cw_files_vw_page_last": "Last page",
|
||||
"cw_files_vw_page_next": "Next page",
|
||||
"cw_files_vw_page_prev": "Previous page",
|
||||
"cw_files_vw_prev": "Previous",
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"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_none_capped": "Coinciden demasiados archivos de otros lugares, así que no llegó ninguno de esta carpeta. Usa un nombre más largo o «Crear de nuevo» para indexar solo esta carpeta.",
|
||||
"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",
|
||||
@@ -301,6 +302,7 @@
|
||||
"cw_files_view": "Ver",
|
||||
"cw_files_vw_as_hex": "Ver en hexadecimal",
|
||||
"cw_files_vw_as_text": "Ver como texto",
|
||||
"cw_files_vw_earlier_lines": "Mostrar {n} líneas anteriores",
|
||||
"cw_files_vw_empty": "Este archivo está vacío.",
|
||||
"cw_files_vw_find": "Buscar en este archivo",
|
||||
"cw_files_vw_find_at": "{at} de {n}",
|
||||
@@ -315,6 +317,8 @@
|
||||
"cw_files_vw_more_lines": "Mostrar {n} líneas más",
|
||||
"cw_files_vw_next": "Siguiente",
|
||||
"cw_files_vw_page": "Página {at} de {n}",
|
||||
"cw_files_vw_page_first": "Primera página",
|
||||
"cw_files_vw_page_last": "Última página",
|
||||
"cw_files_vw_page_next": "Página siguiente",
|
||||
"cw_files_vw_page_prev": "Página anterior",
|
||||
"cw_files_vw_prev": "Anterior",
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"cw_files_search_more": "{n} مورد نخست نمایش داده میشود. موارد بیشتری هست: نام بلندتری امتحان کنید.",
|
||||
"cw_files_search_no_index": "هنوز نمایهای نیست. نخستین جستجو آن را میسازد: همهٔ پوشهها را میخواند و ممکن است چند دقیقه طول بکشد.",
|
||||
"cw_files_search_none": "چیزی با «{q}» جور نیست.",
|
||||
"cw_files_search_none_capped": "فایلهای زیادی در جاهای دیگر جور هستند، پس هیچ نتیجهای از این پوشه برنگشت. نام بلندتری بنویسید، یا «ساخت دوباره» را بزنید تا فقط همین پوشه نمایه شود.",
|
||||
"cw_files_search_partial": "زودتر متوقف شد، پس برخی فایلها در آن نیستند.",
|
||||
"cw_files_search_ready": "نمایه: {n} فایل.",
|
||||
"cw_files_search_rebuild": "ساخت دوباره",
|
||||
@@ -301,6 +302,7 @@
|
||||
"cw_files_view": "نمایش",
|
||||
"cw_files_vw_as_hex": "نمایش هگز",
|
||||
"cw_files_vw_as_text": "نمایش متنی",
|
||||
"cw_files_vw_earlier_lines": "نمایش {n} خط پیشین",
|
||||
"cw_files_vw_empty": "این فایل خالی است.",
|
||||
"cw_files_vw_find": "جستجو در این فایل",
|
||||
"cw_files_vw_find_at": "{at} از {n}",
|
||||
@@ -315,6 +317,8 @@
|
||||
"cw_files_vw_more_lines": "نمایش {n} خط دیگر",
|
||||
"cw_files_vw_next": "بعدی",
|
||||
"cw_files_vw_page": "صفحهٔ {at} از {n}",
|
||||
"cw_files_vw_page_first": "صفحهٔ نخست",
|
||||
"cw_files_vw_page_last": "صفحهٔ آخر",
|
||||
"cw_files_vw_page_next": "صفحهٔ بعد",
|
||||
"cw_files_vw_page_prev": "صفحهٔ قبل",
|
||||
"cw_files_vw_prev": "قبلی",
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"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_none_capped": "Trop de fichiers ailleurs correspondent, aucun de ce dossier n’est revenu. Utilisez un nom plus long, ou « Recréer » pour n’indexer que ce dossier.",
|
||||
"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",
|
||||
@@ -301,6 +302,7 @@
|
||||
"cw_files_view": "Afficher",
|
||||
"cw_files_vw_as_hex": "Afficher en hexadécimal",
|
||||
"cw_files_vw_as_text": "Afficher en texte",
|
||||
"cw_files_vw_earlier_lines": "Afficher {n} lignes précédentes",
|
||||
"cw_files_vw_empty": "Ce fichier est vide.",
|
||||
"cw_files_vw_find": "Rechercher dans ce fichier",
|
||||
"cw_files_vw_find_at": "{at} sur {n}",
|
||||
@@ -315,6 +317,8 @@
|
||||
"cw_files_vw_more_lines": "Afficher {n} lignes de plus",
|
||||
"cw_files_vw_next": "Suivant",
|
||||
"cw_files_vw_page": "Page {at} sur {n}",
|
||||
"cw_files_vw_page_first": "Première page",
|
||||
"cw_files_vw_page_last": "Dernière page",
|
||||
"cw_files_vw_page_next": "Page suivante",
|
||||
"cw_files_vw_page_prev": "Page précédente",
|
||||
"cw_files_vw_prev": "Précédent",
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"cw_files_search_more": "पहले {n} दिखाए जा रहे हैं। और भी हैं: लंबा नाम आज़माएँ।",
|
||||
"cw_files_search_no_index": "अभी कोई इंडेक्स नहीं है। पहली खोज इसे बनाती है: यह हर फ़ोल्डर पढ़ती है और कुछ मिनट लग सकते हैं।",
|
||||
"cw_files_search_none": "“{q}” से कुछ मेल नहीं खाता।",
|
||||
"cw_files_search_none_capped": "दूसरी जगहों पर बहुत सारी फ़ाइलें मेल खाती हैं, इसलिए इस फ़ोल्डर से कोई परिणाम नहीं लौटा। लंबा नाम आज़माएँ, या सिर्फ़ इस फ़ोल्डर का इंडेक्स बनाने के लिए “फिर से बनाएँ” चुनें।",
|
||||
"cw_files_search_partial": "इसे पहले ही रोक दिया गया, इसलिए कुछ फ़ाइलें नहीं हैं।",
|
||||
"cw_files_search_ready": "इंडेक्स: {n} फ़ाइलें।",
|
||||
"cw_files_search_rebuild": "फिर से बनाएँ",
|
||||
@@ -301,6 +302,7 @@
|
||||
"cw_files_view": "देखें",
|
||||
"cw_files_vw_as_hex": "हेक्स में देखें",
|
||||
"cw_files_vw_as_text": "टेक्स्ट में देखें",
|
||||
"cw_files_vw_earlier_lines": "पिछली {n} पंक्तियाँ दिखाएँ",
|
||||
"cw_files_vw_empty": "यह फ़ाइल खाली है।",
|
||||
"cw_files_vw_find": "इस फ़ाइल में खोजें",
|
||||
"cw_files_vw_find_at": "{n} में से {at}",
|
||||
@@ -315,6 +317,8 @@
|
||||
"cw_files_vw_more_lines": "{n} और पंक्तियाँ दिखाएँ",
|
||||
"cw_files_vw_next": "अगला",
|
||||
"cw_files_vw_page": "पेज {at} / {n}",
|
||||
"cw_files_vw_page_first": "पहला पेज",
|
||||
"cw_files_vw_page_last": "आखिरी पेज",
|
||||
"cw_files_vw_page_next": "अगला पेज",
|
||||
"cw_files_vw_page_prev": "पिछला पेज",
|
||||
"cw_files_vw_prev": "पिछला",
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"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_none_capped": "Túl sok máshol lévő fájl egyezik, ezért ebből a mappából egy sem jött vissza. Adj meg hosszabb nevet, vagy az „Újraépítés” csak ezt a mappát indexeli.",
|
||||
"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",
|
||||
@@ -301,6 +302,7 @@
|
||||
"cw_files_view": "Megtekintés",
|
||||
"cw_files_vw_as_hex": "Megtekintés hexában",
|
||||
"cw_files_vw_as_text": "Megtekintés szövegként",
|
||||
"cw_files_vw_earlier_lines": "{n} korábbi sor megjelenítése",
|
||||
"cw_files_vw_empty": "Ez a fájl üres.",
|
||||
"cw_files_vw_find": "Keresés ebben a fájlban",
|
||||
"cw_files_vw_find_at": "{at}/{n}",
|
||||
@@ -315,6 +317,8 @@
|
||||
"cw_files_vw_more_lines": "Még {n} sor megjelenítése",
|
||||
"cw_files_vw_next": "Következő",
|
||||
"cw_files_vw_page": "{at}/{n}. oldal",
|
||||
"cw_files_vw_page_first": "Első oldal",
|
||||
"cw_files_vw_page_last": "Utolsó oldal",
|
||||
"cw_files_vw_page_next": "Következő oldal",
|
||||
"cw_files_vw_page_prev": "Előző oldal",
|
||||
"cw_files_vw_prev": "Előző",
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"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_none_capped": "Terlalu banyak file di tempat lain yang cocok, jadi tidak ada dari folder ini yang kembali. Gunakan nama yang lebih panjang, atau Buat ulang untuk mengindeks folder ini saja.",
|
||||
"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",
|
||||
@@ -301,6 +302,7 @@
|
||||
"cw_files_view": "Lihat",
|
||||
"cw_files_vw_as_hex": "Lihat sebagai hex",
|
||||
"cw_files_vw_as_text": "Lihat sebagai teks",
|
||||
"cw_files_vw_earlier_lines": "Tampilkan {n} baris sebelumnya",
|
||||
"cw_files_vw_empty": "File ini kosong.",
|
||||
"cw_files_vw_find": "Cari di file ini",
|
||||
"cw_files_vw_find_at": "{at} dari {n}",
|
||||
@@ -315,6 +317,8 @@
|
||||
"cw_files_vw_more_lines": "Tampilkan {n} baris lagi",
|
||||
"cw_files_vw_next": "Berikutnya",
|
||||
"cw_files_vw_page": "Halaman {at} dari {n}",
|
||||
"cw_files_vw_page_first": "Halaman pertama",
|
||||
"cw_files_vw_page_last": "Halaman terakhir",
|
||||
"cw_files_vw_page_next": "Halaman berikutnya",
|
||||
"cw_files_vw_page_prev": "Halaman sebelumnya",
|
||||
"cw_files_vw_prev": "Sebelumnya",
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"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_none_capped": "Troppi file altrove corrispondono, quindi nessuno di questa cartella è tornato. Usa un nome più lungo, o «Ricrea» per indicizzare solo questa cartella.",
|
||||
"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",
|
||||
@@ -301,6 +302,7 @@
|
||||
"cw_files_view": "Visualizza",
|
||||
"cw_files_vw_as_hex": "Visualizza in esadecimale",
|
||||
"cw_files_vw_as_text": "Visualizza come testo",
|
||||
"cw_files_vw_earlier_lines": "Mostra {n} righe precedenti",
|
||||
"cw_files_vw_empty": "Questo file è vuoto.",
|
||||
"cw_files_vw_find": "Cerca in questo file",
|
||||
"cw_files_vw_find_at": "{at} di {n}",
|
||||
@@ -315,6 +317,8 @@
|
||||
"cw_files_vw_more_lines": "Mostra altre {n} righe",
|
||||
"cw_files_vw_next": "Successivo",
|
||||
"cw_files_vw_page": "Pagina {at} di {n}",
|
||||
"cw_files_vw_page_first": "Prima pagina",
|
||||
"cw_files_vw_page_last": "Ultima pagina",
|
||||
"cw_files_vw_page_next": "Pagina successiva",
|
||||
"cw_files_vw_page_prev": "Pagina precedente",
|
||||
"cw_files_vw_prev": "Precedente",
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"cw_files_search_more": "最初の {n} 件を表示しています。さらにあります。もっと長い名前で試してください。",
|
||||
"cw_files_search_no_index": "まだインデックスがありません。最初の検索で作成します。すべてのフォルダーを読むため数分かかることがあります。",
|
||||
"cw_files_search_none": "「{q}」に一致するものはありません。",
|
||||
"cw_files_search_none_capped": "ほかの場所で一致するファイルが多すぎて、このフォルダーの結果が返りませんでした。もっと長い名前にするか、「作り直す」でこのフォルダーだけをインデックスしてください。",
|
||||
"cw_files_search_partial": "途中で停止したため、一部のファイルがありません。",
|
||||
"cw_files_search_ready": "インデックス: {n} ファイル。",
|
||||
"cw_files_search_rebuild": "作り直す",
|
||||
@@ -301,6 +302,7 @@
|
||||
"cw_files_view": "表示",
|
||||
"cw_files_vw_as_hex": "16 進数で表示",
|
||||
"cw_files_vw_as_text": "テキストで表示",
|
||||
"cw_files_vw_earlier_lines": "前の {n} 行を表示",
|
||||
"cw_files_vw_empty": "このファイルは空です。",
|
||||
"cw_files_vw_find": "このファイル内を検索",
|
||||
"cw_files_vw_find_at": "{at} / {n}",
|
||||
@@ -315,6 +317,8 @@
|
||||
"cw_files_vw_more_lines": "さらに {n} 行を表示",
|
||||
"cw_files_vw_next": "次へ",
|
||||
"cw_files_vw_page": "{at} / {n} ページ",
|
||||
"cw_files_vw_page_first": "最初のページ",
|
||||
"cw_files_vw_page_last": "最後のページ",
|
||||
"cw_files_vw_page_next": "次のページ",
|
||||
"cw_files_vw_page_prev": "前のページ",
|
||||
"cw_files_vw_prev": "前へ",
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"cw_files_search_more": "처음 {n}개를 표시합니다. 더 있습니다. 더 긴 이름으로 시도하세요.",
|
||||
"cw_files_search_no_index": "아직 색인이 없습니다. 첫 검색에서 색인을 만듭니다. 모든 폴더를 읽으므로 몇 분 걸릴 수 있습니다.",
|
||||
"cw_files_search_none": "“{q}”과(와) 일치하는 항목이 없습니다.",
|
||||
"cw_files_search_none_capped": "다른 위치에서 일치하는 파일이 너무 많아 이 폴더의 결과가 돌아오지 않았습니다. 더 긴 이름을 쓰거나 “다시 만들기”로 이 폴더만 색인하세요.",
|
||||
"cw_files_search_partial": "일찍 중지되어 일부 파일이 빠져 있습니다.",
|
||||
"cw_files_search_ready": "색인: {n}개 파일.",
|
||||
"cw_files_search_rebuild": "다시 만들기",
|
||||
@@ -301,6 +302,7 @@
|
||||
"cw_files_view": "보기",
|
||||
"cw_files_vw_as_hex": "16진수로 보기",
|
||||
"cw_files_vw_as_text": "텍스트로 보기",
|
||||
"cw_files_vw_earlier_lines": "이전 {n}줄 보기",
|
||||
"cw_files_vw_empty": "이 파일은 비어 있습니다.",
|
||||
"cw_files_vw_find": "이 파일에서 찾기",
|
||||
"cw_files_vw_find_at": "{at}/{n}",
|
||||
@@ -315,6 +317,8 @@
|
||||
"cw_files_vw_more_lines": "{n}줄 더 보기",
|
||||
"cw_files_vw_next": "다음",
|
||||
"cw_files_vw_page": "{at}/{n} 페이지",
|
||||
"cw_files_vw_page_first": "첫 페이지",
|
||||
"cw_files_vw_page_last": "마지막 페이지",
|
||||
"cw_files_vw_page_next": "다음 페이지",
|
||||
"cw_files_vw_page_prev": "이전 페이지",
|
||||
"cw_files_vw_prev": "이전",
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"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_none_capped": "Zbyt wiele plików w innych miejscach pasuje, więc z tego folderu nic nie wróciło. Użyj dłuższej nazwy albo „Zbuduj ponownie”, by zindeksować tylko ten folder.",
|
||||
"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",
|
||||
@@ -301,6 +302,7 @@
|
||||
"cw_files_view": "Pokaż",
|
||||
"cw_files_vw_as_hex": "Pokaż szesnastkowo",
|
||||
"cw_files_vw_as_text": "Pokaż jako tekst",
|
||||
"cw_files_vw_earlier_lines": "Pokaż {n} wcześniejszych wierszy",
|
||||
"cw_files_vw_empty": "Ten plik jest pusty.",
|
||||
"cw_files_vw_find": "Znajdź w tym pliku",
|
||||
"cw_files_vw_find_at": "{at} z {n}",
|
||||
@@ -315,6 +317,8 @@
|
||||
"cw_files_vw_more_lines": "Pokaż jeszcze {n} wierszy",
|
||||
"cw_files_vw_next": "Następne",
|
||||
"cw_files_vw_page": "Strona {at} z {n}",
|
||||
"cw_files_vw_page_first": "Pierwsza strona",
|
||||
"cw_files_vw_page_last": "Ostatnia strona",
|
||||
"cw_files_vw_page_next": "Następna strona",
|
||||
"cw_files_vw_page_prev": "Poprzednia strona",
|
||||
"cw_files_vw_prev": "Poprzednie",
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"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_none_capped": "Arquivos demais em outros lugares correspondem, então nenhum desta pasta voltou. Use um nome mais longo ou “Criar de novo” para indexar só esta pasta.",
|
||||
"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",
|
||||
@@ -301,6 +302,7 @@
|
||||
"cw_files_view": "Ver",
|
||||
"cw_files_vw_as_hex": "Ver em hexadecimal",
|
||||
"cw_files_vw_as_text": "Ver como texto",
|
||||
"cw_files_vw_earlier_lines": "Mostrar {n} linhas anteriores",
|
||||
"cw_files_vw_empty": "Este arquivo está vazio.",
|
||||
"cw_files_vw_find": "Procurar neste arquivo",
|
||||
"cw_files_vw_find_at": "{at} de {n}",
|
||||
@@ -315,6 +317,8 @@
|
||||
"cw_files_vw_more_lines": "Mostrar mais {n} linhas",
|
||||
"cw_files_vw_next": "Próximo",
|
||||
"cw_files_vw_page": "Página {at} de {n}",
|
||||
"cw_files_vw_page_first": "Primeira página",
|
||||
"cw_files_vw_page_last": "Última página",
|
||||
"cw_files_vw_page_next": "Próxima página",
|
||||
"cw_files_vw_page_prev": "Página anterior",
|
||||
"cw_files_vw_prev": "Anterior",
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"cw_files_search_more": "Показаны первые {n}. Есть ещё: попробуйте имя подлиннее.",
|
||||
"cw_files_search_no_index": "Индекса пока нет. Первый поиск создаст его: он читает каждую папку и может занять несколько минут.",
|
||||
"cw_files_search_none": "Ничего не найдено по «{q}».",
|
||||
"cw_files_search_none_capped": "Слишком много совпадений в других местах, поэтому из этой папки ничего не вернулось. Введите имя подлиннее или нажмите «Создать заново», чтобы проиндексировать только эту папку.",
|
||||
"cw_files_search_partial": "Он был остановлен раньше времени, поэтому некоторых файлов нет.",
|
||||
"cw_files_search_ready": "Индекс: {n} файлов.",
|
||||
"cw_files_search_rebuild": "Создать заново",
|
||||
@@ -301,6 +302,7 @@
|
||||
"cw_files_view": "Просмотр",
|
||||
"cw_files_vw_as_hex": "Показать в hex",
|
||||
"cw_files_vw_as_text": "Показать как текст",
|
||||
"cw_files_vw_earlier_lines": "Показать {n} предыдущих строк",
|
||||
"cw_files_vw_empty": "Этот файл пуст.",
|
||||
"cw_files_vw_find": "Найти в этом файле",
|
||||
"cw_files_vw_find_at": "{at} из {n}",
|
||||
@@ -315,6 +317,8 @@
|
||||
"cw_files_vw_more_lines": "Показать ещё {n} строк",
|
||||
"cw_files_vw_next": "Далее",
|
||||
"cw_files_vw_page": "Страница {at} из {n}",
|
||||
"cw_files_vw_page_first": "Первая страница",
|
||||
"cw_files_vw_page_last": "Последняя страница",
|
||||
"cw_files_vw_page_next": "Следующая страница",
|
||||
"cw_files_vw_page_prev": "Предыдущая страница",
|
||||
"cw_files_vw_prev": "Назад",
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"cw_files_search_more": "แสดง {n} รายการแรก ยังมีอีก: ลองใช้ชื่อที่ยาวขึ้น",
|
||||
"cw_files_search_no_index": "ยังไม่มีดัชนี การค้นหาครั้งแรกจะสร้างดัชนี โดยอ่านทุกโฟลเดอร์และอาจใช้เวลาสองสามนาที",
|
||||
"cw_files_search_none": "ไม่มีสิ่งที่ตรงกับ “{q}”",
|
||||
"cw_files_search_none_capped": "มีไฟล์ที่อื่นตรงกันมากเกินไป จึงไม่มีผลจากโฟลเดอร์นี้กลับมา ใช้ชื่อที่ยาวขึ้น หรือกด สร้างใหม่ เพื่อทำดัชนีเฉพาะโฟลเดอร์นี้",
|
||||
"cw_files_search_partial": "ดัชนีถูกหยุดก่อนเสร็จ จึงขาดบางไฟล์",
|
||||
"cw_files_search_ready": "ดัชนี: {n} ไฟล์",
|
||||
"cw_files_search_rebuild": "สร้างใหม่",
|
||||
@@ -301,6 +302,7 @@
|
||||
"cw_files_view": "ดู",
|
||||
"cw_files_vw_as_hex": "ดูเป็นเลขฐานสิบหก",
|
||||
"cw_files_vw_as_text": "ดูเป็นข้อความ",
|
||||
"cw_files_vw_earlier_lines": "แสดง {n} บรรทัดก่อนหน้า",
|
||||
"cw_files_vw_empty": "ไฟล์นี้ว่างเปล่า",
|
||||
"cw_files_vw_find": "ค้นหาในไฟล์นี้",
|
||||
"cw_files_vw_find_at": "{at} จาก {n}",
|
||||
@@ -315,6 +317,8 @@
|
||||
"cw_files_vw_more_lines": "แสดงอีก {n} บรรทัด",
|
||||
"cw_files_vw_next": "ถัดไป",
|
||||
"cw_files_vw_page": "หน้า {at} จาก {n}",
|
||||
"cw_files_vw_page_first": "หน้าแรก",
|
||||
"cw_files_vw_page_last": "หน้าสุดท้าย",
|
||||
"cw_files_vw_page_next": "หน้าถัดไป",
|
||||
"cw_files_vw_page_prev": "หน้าก่อนหน้า",
|
||||
"cw_files_vw_prev": "ก่อนหน้า",
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"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_none_capped": "Başka yerlerde çok fazla dosya eşleşiyor, bu yüzden bu klasörden hiçbiri gelmedi. Daha uzun bir ad kullanın ya da yalnızca bu klasörü dizinlemek için Yeniden oluştur’a basın.",
|
||||
"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",
|
||||
@@ -301,6 +302,7 @@
|
||||
"cw_files_view": "Görüntüle",
|
||||
"cw_files_vw_as_hex": "Hex olarak göster",
|
||||
"cw_files_vw_as_text": "Metin olarak göster",
|
||||
"cw_files_vw_earlier_lines": "Önceki {n} satırı göster",
|
||||
"cw_files_vw_empty": "Bu dosya boş.",
|
||||
"cw_files_vw_find": "Bu dosyada bul",
|
||||
"cw_files_vw_find_at": "{at}/{n}",
|
||||
@@ -315,6 +317,8 @@
|
||||
"cw_files_vw_more_lines": "{n} satır daha göster",
|
||||
"cw_files_vw_next": "Sonraki",
|
||||
"cw_files_vw_page": "Sayfa {at}/{n}",
|
||||
"cw_files_vw_page_first": "İlk sayfa",
|
||||
"cw_files_vw_page_last": "Son sayfa",
|
||||
"cw_files_vw_page_next": "Sonraki sayfa",
|
||||
"cw_files_vw_page_prev": "Önceki sayfa",
|
||||
"cw_files_vw_prev": "Önceki",
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"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_none_capped": "Quá nhiều tệp ở nơi khác khớp nên không có tệp nào trong thư mục này được trả về. Hãy dùng tên dài hơn, hoặc Tạo lại để chỉ lập chỉ mục thư mục này.",
|
||||
"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",
|
||||
@@ -301,6 +302,7 @@
|
||||
"cw_files_view": "Xem",
|
||||
"cw_files_vw_as_hex": "Xem dạng hex",
|
||||
"cw_files_vw_as_text": "Xem dạng văn bản",
|
||||
"cw_files_vw_earlier_lines": "Hiện {n} dòng trước đó",
|
||||
"cw_files_vw_empty": "Tệp này trống.",
|
||||
"cw_files_vw_find": "Tìm trong tệp này",
|
||||
"cw_files_vw_find_at": "{at}/{n}",
|
||||
@@ -315,6 +317,8 @@
|
||||
"cw_files_vw_more_lines": "Hiện thêm {n} dòng",
|
||||
"cw_files_vw_next": "Tiếp",
|
||||
"cw_files_vw_page": "Trang {at}/{n}",
|
||||
"cw_files_vw_page_first": "Trang đầu",
|
||||
"cw_files_vw_page_last": "Trang cuối",
|
||||
"cw_files_vw_page_next": "Trang sau",
|
||||
"cw_files_vw_page_prev": "Trang trước",
|
||||
"cw_files_vw_prev": "Trước",
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"cw_files_search_more": "显示前 {n} 个。还有更多:请尝试更长的名称。",
|
||||
"cw_files_search_no_index": "还没有索引。首次搜索会建立索引:需要读取每个文件夹,可能需要几分钟。",
|
||||
"cw_files_search_none": "没有与“{q}”匹配的内容。",
|
||||
"cw_files_search_none_capped": "其他位置匹配的文件太多,此文件夹中的结果没有返回。请使用更长的名称,或点“重新建立”只为此文件夹建立索引。",
|
||||
"cw_files_search_partial": "索引提前停止,因此缺少部分文件。",
|
||||
"cw_files_search_ready": "索引:{n} 个文件。",
|
||||
"cw_files_search_rebuild": "重新建立",
|
||||
@@ -301,6 +302,7 @@
|
||||
"cw_files_view": "查看",
|
||||
"cw_files_vw_as_hex": "以十六进制查看",
|
||||
"cw_files_vw_as_text": "以文本查看",
|
||||
"cw_files_vw_earlier_lines": "显示前面的 {n} 行",
|
||||
"cw_files_vw_empty": "此文件为空。",
|
||||
"cw_files_vw_find": "在此文件中查找",
|
||||
"cw_files_vw_find_at": "第 {at} 个,共 {n} 个",
|
||||
@@ -315,6 +317,8 @@
|
||||
"cw_files_vw_more_lines": "再显示 {n} 行",
|
||||
"cw_files_vw_next": "下一个",
|
||||
"cw_files_vw_page": "第 {at} 页,共 {n} 页",
|
||||
"cw_files_vw_page_first": "第一页",
|
||||
"cw_files_vw_page_last": "最后一页",
|
||||
"cw_files_vw_page_next": "下一页",
|
||||
"cw_files_vw_page_prev": "上一页",
|
||||
"cw_files_vw_prev": "上一个",
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"cw_files_search_more": "顯示前 {n} 個。還有更多:請嘗試較長的名稱。",
|
||||
"cw_files_search_no_index": "尚無索引。第一次搜尋會建立索引:需要讀取每個資料夾,可能需要幾分鐘。",
|
||||
"cw_files_search_none": "沒有符合「{q}」的項目。",
|
||||
"cw_files_search_none_capped": "其他位置符合的檔案太多,此資料夾中的結果沒有傳回。請使用較長的名稱,或按「重新建立」只為此資料夾建立索引。",
|
||||
"cw_files_search_partial": "索引提早停止,因此缺少部分檔案。",
|
||||
"cw_files_search_ready": "索引:{n} 個檔案。",
|
||||
"cw_files_search_rebuild": "重新建立",
|
||||
@@ -301,6 +302,7 @@
|
||||
"cw_files_view": "檢視",
|
||||
"cw_files_vw_as_hex": "以十六進位檢視",
|
||||
"cw_files_vw_as_text": "以文字檢視",
|
||||
"cw_files_vw_earlier_lines": "顯示前面的 {n} 行",
|
||||
"cw_files_vw_empty": "此檔案是空的。",
|
||||
"cw_files_vw_find": "在此檔案中尋找",
|
||||
"cw_files_vw_find_at": "第 {at} 個,共 {n} 個",
|
||||
@@ -315,6 +317,8 @@
|
||||
"cw_files_vw_more_lines": "再顯示 {n} 行",
|
||||
"cw_files_vw_next": "下一個",
|
||||
"cw_files_vw_page": "第 {at} 頁,共 {n} 頁",
|
||||
"cw_files_vw_page_first": "第一頁",
|
||||
"cw_files_vw_page_last": "最後一頁",
|
||||
"cw_files_vw_page_next": "下一頁",
|
||||
"cw_files_vw_page_prev": "上一頁",
|
||||
"cw_files_vw_prev": "上一個",
|
||||
|
||||
@@ -525,9 +525,26 @@ function toRail(): void {
|
||||
if (target) focusElement(target);
|
||||
}
|
||||
|
||||
/**
|
||||
* ← / → on an element marked [data-nav-arrows] (the viewer's picture) do not
|
||||
* move focus: the element gets a NAV_ARROW_EVENT (detail "left" | "right")
|
||||
* instead and decides what they mean (previous / next image).
|
||||
*/
|
||||
export const NAV_ARROW_EVENT = "cw-nav-arrow";
|
||||
|
||||
function handArrow(dir: Direction): boolean {
|
||||
if (dir !== "left" && dir !== "right") return false;
|
||||
if (typeof document === "undefined" || typeof CustomEvent === "undefined") return false;
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
if (!active || typeof active.hasAttribute !== "function" || !active.hasAttribute("data-nav-arrows")) return false;
|
||||
active.dispatchEvent(new CustomEvent(NAV_ARROW_EVENT, { detail: dir }));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Run one action: movement and confirm here, the rest through the handlers. */
|
||||
export function dispatchNav(action: NavAction): void {
|
||||
if (isDirection(action)) {
|
||||
if (handArrow(action)) return;
|
||||
move(action);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { dispatchNav, NAV_ARROW_EVENT } from "./nav";
|
||||
|
||||
/* Node has no DOM: just what dispatchNav touches for ← / →. */
|
||||
function fakeActive(marked: boolean) {
|
||||
const seen: string[] = [];
|
||||
const el = {
|
||||
hasAttribute: (a: string) => marked && a === "data-nav-arrows",
|
||||
dispatchEvent: (e: Event) => {
|
||||
seen.push(`${e.type}:${(e as CustomEvent).detail}`);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
vi.stubGlobal("document", { activeElement: el, querySelectorAll: () => [] });
|
||||
return seen;
|
||||
}
|
||||
|
||||
describe("arrows handed to the focused element", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("← / → on a [data-nav-arrows] element become its event, not a focus move", () => {
|
||||
const seen = fakeActive(true);
|
||||
dispatchNav("left");
|
||||
dispatchNav("right");
|
||||
expect(seen).toEqual([`${NAV_ARROW_EVENT}:left`, `${NAV_ARROW_EVENT}:right`]);
|
||||
});
|
||||
|
||||
it("↑ / ↓, and any arrow on an unmarked element, move focus as before", () => {
|
||||
const seen = fakeActive(true);
|
||||
dispatchNav("up");
|
||||
expect(seen).toEqual([]);
|
||||
const plain = fakeActive(false);
|
||||
dispatchNav("left");
|
||||
expect(plain).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -4487,6 +4487,10 @@ c2c_test_deep_bad: "لم يصل الإرسال التجريبي بحجم 4 KiB:
|
||||
"cw_files_usage_busy": "يجري قياس حجم مجلد آخر. أعد المحاولة عند انتهائه.",
|
||||
"cw_files_usage_skipped": "لم يُقس",
|
||||
"cw_files_usage_unreadable": "غير قابل للقراءة",
|
||||
"cw_files_vw_earlier_lines": "عرض {n} سطرًا سابقًا",
|
||||
"cw_files_vw_page_first": "الصفحة الأولى",
|
||||
"cw_files_vw_page_last": "الصفحة الأخيرة",
|
||||
"cw_files_search_none_capped": "تطابق ملفات كثيرة جدًا في أماكن أخرى، لذا لم يعد أي ملف من هذا المجلد. استخدم اسمًا أطول، أو «إنشاء من جديد» لفهرسة هذا المجلد وحده.",
|
||||
};
|
||||
|
||||
export default ar;
|
||||
@@ -4487,6 +4487,10 @@ c2c_test_deep_bad: "4 KiB-এর পরীক্ষামূলক পাঠা
|
||||
"cw_files_usage_busy": "অন্য একটি ফোল্ডারের আকার মাপা হচ্ছে। সেটি শেষ হলে আবার চেষ্টা করুন।",
|
||||
"cw_files_usage_skipped": "মাপা হয়নি",
|
||||
"cw_files_usage_unreadable": "পড়া যায় না",
|
||||
"cw_files_vw_earlier_lines": "আগের {n}টি লাইন দেখান",
|
||||
"cw_files_vw_page_first": "প্রথম পৃষ্ঠা",
|
||||
"cw_files_vw_page_last": "শেষ পৃষ্ঠা",
|
||||
"cw_files_search_none_capped": "অন্য জায়গায় অনেক বেশি ফাইল মিলে যায়, তাই এই ফোল্ডার থেকে কিছু ফেরেনি। লম্বা নাম দিন, অথবা শুধু এই ফোল্ডার ইনডেক্স করতে “আবার তৈরি করুন” বেছে নিন।",
|
||||
};
|
||||
|
||||
export default bn;
|
||||
@@ -4495,6 +4495,10 @@ c2c_test_deep_bad: "Die 4-KiB-Testsendung ist nicht angekommen: {detail}",
|
||||
"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",
|
||||
"cw_files_vw_earlier_lines": "{n} frühere Zeilen anzeigen",
|
||||
"cw_files_vw_page_first": "Erste Seite",
|
||||
"cw_files_vw_page_last": "Letzte Seite",
|
||||
"cw_files_search_none_capped": "Zu viele Dateien an anderen Orten passen, daher kam keine aus diesem Ordner zurück. Nutze einen längeren Namen oder „Neu erstellen“, um nur diesen Ordner zu indizieren.",
|
||||
};
|
||||
|
||||
export default de;
|
||||
@@ -4810,6 +4810,10 @@ c2c_test_deep_bad: "The 4 KiB test send did not arrive: {detail}",
|
||||
"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",
|
||||
"cw_files_vw_earlier_lines": "Show {n} earlier lines",
|
||||
"cw_files_vw_page_first": "First page",
|
||||
"cw_files_vw_page_last": "Last page",
|
||||
"cw_files_search_none_capped": "Too many files elsewhere match, so none in this folder came back. Use a longer name, or Build again to index this folder only.",
|
||||
};
|
||||
|
||||
export default en;
|
||||
@@ -4437,6 +4437,10 @@ c2c_test_deep_bad: "El envío de prueba de 4 KiB no llegó: {detail}",
|
||||
"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",
|
||||
"cw_files_vw_earlier_lines": "Mostrar {n} líneas anteriores",
|
||||
"cw_files_vw_page_first": "Primera página",
|
||||
"cw_files_vw_page_last": "Última página",
|
||||
"cw_files_search_none_capped": "Coinciden demasiados archivos de otros lugares, así que no llegó ninguno de esta carpeta. Usa un nombre más largo o «Crear de nuevo» para indexar solo esta carpeta.",
|
||||
};
|
||||
|
||||
export default es;
|
||||
@@ -4788,6 +4788,10 @@ c2c_test_deep_bad: "ارسال آزمایشی ۴ KiB نرسید: {detail}",
|
||||
"cw_files_usage_busy": "اندازهٔ پوشهٔ دیگری در حال اندازهگیری است. پس از پایان آن دوباره امتحان کنید.",
|
||||
"cw_files_usage_skipped": "اندازهگیری نشده",
|
||||
"cw_files_usage_unreadable": "خواندنی نیست",
|
||||
"cw_files_vw_earlier_lines": "نمایش {n} خط پیشین",
|
||||
"cw_files_vw_page_first": "صفحهٔ نخست",
|
||||
"cw_files_vw_page_last": "صفحهٔ آخر",
|
||||
"cw_files_search_none_capped": "فایلهای زیادی در جاهای دیگر جور هستند، پس هیچ نتیجهای از این پوشه برنگشت. نام بلندتری بنویسید، یا «ساخت دوباره» را بزنید تا فقط همین پوشه نمایه شود.",
|
||||
};
|
||||
|
||||
export default fa;
|
||||
@@ -4505,6 +4505,10 @@ c2c_test_deep_bad: "L’envoi test de 4 Kio n’est pas arrivé : {detail}",
|
||||
"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",
|
||||
"cw_files_vw_earlier_lines": "Afficher {n} lignes précédentes",
|
||||
"cw_files_vw_page_first": "Première page",
|
||||
"cw_files_vw_page_last": "Dernière page",
|
||||
"cw_files_search_none_capped": "Trop de fichiers ailleurs correspondent, aucun de ce dossier n’est revenu. Utilisez un nom plus long, ou « Recréer » pour n’indexer que ce dossier.",
|
||||
};
|
||||
|
||||
export default fr;
|
||||
@@ -4503,6 +4503,10 @@ c2c_test_deep_bad: "4 KiB का परीक्षण नहीं पहुँ
|
||||
"cw_files_usage_busy": "किसी दूसरे फ़ोल्डर का आकार मापा जा रहा है। उसके खत्म होने पर फिर कोशिश करें।",
|
||||
"cw_files_usage_skipped": "नहीं मापा गया",
|
||||
"cw_files_usage_unreadable": "पढ़ा नहीं जा सकता",
|
||||
"cw_files_vw_earlier_lines": "पिछली {n} पंक्तियाँ दिखाएँ",
|
||||
"cw_files_vw_page_first": "पहला पेज",
|
||||
"cw_files_vw_page_last": "आखिरी पेज",
|
||||
"cw_files_search_none_capped": "दूसरी जगहों पर बहुत सारी फ़ाइलें मेल खाती हैं, इसलिए इस फ़ोल्डर से कोई परिणाम नहीं लौटा। लंबा नाम आज़माएँ, या सिर्फ़ इस फ़ोल्डर का इंडेक्स बनाने के लिए “फिर से बनाएँ” चुनें।",
|
||||
};
|
||||
|
||||
export default hi;
|
||||
@@ -4802,6 +4802,10 @@ c2c_test_deep_bad: "A 4 KiB-os tesztküldés nem érkezett meg: {detail}",
|
||||
"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ó",
|
||||
"cw_files_vw_earlier_lines": "{n} korábbi sor megjelenítése",
|
||||
"cw_files_vw_page_first": "Első oldal",
|
||||
"cw_files_vw_page_last": "Utolsó oldal",
|
||||
"cw_files_search_none_capped": "Túl sok máshol lévő fájl egyezik, ezért ebből a mappából egy sem jött vissza. Adj meg hosszabb nevet, vagy az „Újraépítés” csak ezt a mappát indexeli.",
|
||||
};
|
||||
|
||||
export default hu;
|
||||
@@ -4496,6 +4496,10 @@ c2c_test_deep_bad: "Kiriman uji 4 KiB tidak tiba: {detail}",
|
||||
"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",
|
||||
"cw_files_vw_earlier_lines": "Tampilkan {n} baris sebelumnya",
|
||||
"cw_files_vw_page_first": "Halaman pertama",
|
||||
"cw_files_vw_page_last": "Halaman terakhir",
|
||||
"cw_files_search_none_capped": "Terlalu banyak file di tempat lain yang cocok, jadi tidak ada dari folder ini yang kembali. Gunakan nama yang lebih panjang, atau Buat ulang untuk mengindeks folder ini saja.",
|
||||
};
|
||||
|
||||
export default id;
|
||||
@@ -4437,6 +4437,10 @@ c2c_test_deep_bad: "L’invio di prova di 4 KiB non è arrivato: {detail}",
|
||||
"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",
|
||||
"cw_files_vw_earlier_lines": "Mostra {n} righe precedenti",
|
||||
"cw_files_vw_page_first": "Prima pagina",
|
||||
"cw_files_vw_page_last": "Ultima pagina",
|
||||
"cw_files_search_none_capped": "Troppi file altrove corrispondono, quindi nessuno di questa cartella è tornato. Usa un nome più lungo, o «Ricrea» per indicizzare solo questa cartella.",
|
||||
};
|
||||
|
||||
export default it;
|
||||
@@ -4508,6 +4508,10 @@ c2c_test_deep_bad: "4 KiB のテスト送信が届きませんでした: {detail
|
||||
"cw_files_usage_busy": "別のフォルダーのサイズを計測中です。終わってから再試行してください。",
|
||||
"cw_files_usage_skipped": "未計測",
|
||||
"cw_files_usage_unreadable": "読み取れません",
|
||||
"cw_files_vw_earlier_lines": "前の {n} 行を表示",
|
||||
"cw_files_vw_page_first": "最初のページ",
|
||||
"cw_files_vw_page_last": "最後のページ",
|
||||
"cw_files_search_none_capped": "ほかの場所で一致するファイルが多すぎて、このフォルダーの結果が返りませんでした。もっと長い名前にするか、「作り直す」でこのフォルダーだけをインデックスしてください。",
|
||||
};
|
||||
|
||||
export default ja;
|
||||
@@ -4506,6 +4506,10 @@ c2c_test_deep_bad: "4 KiB 테스트 전송이 도착하지 않았습니다: {det
|
||||
"cw_files_usage_busy": "다른 폴더 크기를 측정하는 중입니다. 끝난 뒤 다시 시도하세요.",
|
||||
"cw_files_usage_skipped": "측정 안 됨",
|
||||
"cw_files_usage_unreadable": "읽을 수 없음",
|
||||
"cw_files_vw_earlier_lines": "이전 {n}줄 보기",
|
||||
"cw_files_vw_page_first": "첫 페이지",
|
||||
"cw_files_vw_page_last": "마지막 페이지",
|
||||
"cw_files_search_none_capped": "다른 위치에서 일치하는 파일이 너무 많아 이 폴더의 결과가 돌아오지 않았습니다. 더 긴 이름을 쓰거나 “다시 만들기”로 이 폴더만 색인하세요.",
|
||||
};
|
||||
|
||||
export default ko;
|
||||
@@ -4742,6 +4742,10 @@ c2c_test_deep_bad: "Testowa wysyłka 4 KiB nie dotarła: {detail}",
|
||||
"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",
|
||||
"cw_files_vw_earlier_lines": "Pokaż {n} wcześniejszych wierszy",
|
||||
"cw_files_vw_page_first": "Pierwsza strona",
|
||||
"cw_files_vw_page_last": "Ostatnia strona",
|
||||
"cw_files_search_none_capped": "Zbyt wiele plików w innych miejscach pasuje, więc z tego folderu nic nie wróciło. Użyj dłuższej nazwy albo „Zbuduj ponownie”, by zindeksować tylko ten folder.",
|
||||
};
|
||||
|
||||
export default pl;
|
||||
@@ -4504,6 +4504,10 @@ c2c_test_deep_bad: "O envio de teste de 4 KiB não chegou: {detail}",
|
||||
"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",
|
||||
"cw_files_vw_earlier_lines": "Mostrar {n} linhas anteriores",
|
||||
"cw_files_vw_page_first": "Primeira página",
|
||||
"cw_files_vw_page_last": "Última página",
|
||||
"cw_files_search_none_capped": "Arquivos demais em outros lugares correspondem, então nenhum desta pasta voltou. Use um nome mais longo ou “Criar de novo” para indexar só esta pasta.",
|
||||
};
|
||||
|
||||
export default pt_BR;
|
||||
@@ -4437,6 +4437,10 @@ c2c_test_deep_bad: "Тестовая отправка 4 КиБ не дошла:
|
||||
"cw_files_usage_busy": "Сейчас измеряется размер другой папки. Повторите, когда это закончится.",
|
||||
"cw_files_usage_skipped": "не измерено",
|
||||
"cw_files_usage_unreadable": "не читается",
|
||||
"cw_files_vw_earlier_lines": "Показать {n} предыдущих строк",
|
||||
"cw_files_vw_page_first": "Первая страница",
|
||||
"cw_files_vw_page_last": "Последняя страница",
|
||||
"cw_files_search_none_capped": "Слишком много совпадений в других местах, поэтому из этой папки ничего не вернулось. Введите имя подлиннее или нажмите «Создать заново», чтобы проиндексировать только эту папку.",
|
||||
};
|
||||
|
||||
export default ru;
|
||||
@@ -4496,6 +4496,10 @@ c2c_test_deep_bad: "การส่งทดสอบ 4 KiB ไปไม่ถ
|
||||
"cw_files_usage_busy": "กำลังวัดขนาดโฟลเดอร์อื่นอยู่ ลองอีกครั้งเมื่อเสร็จ",
|
||||
"cw_files_usage_skipped": "ไม่ได้วัด",
|
||||
"cw_files_usage_unreadable": "อ่านไม่ได้",
|
||||
"cw_files_vw_earlier_lines": "แสดง {n} บรรทัดก่อนหน้า",
|
||||
"cw_files_vw_page_first": "หน้าแรก",
|
||||
"cw_files_vw_page_last": "หน้าสุดท้าย",
|
||||
"cw_files_search_none_capped": "มีไฟล์ที่อื่นตรงกันมากเกินไป จึงไม่มีผลจากโฟลเดอร์นี้กลับมา ใช้ชื่อที่ยาวขึ้น หรือกด สร้างใหม่ เพื่อทำดัชนีเฉพาะโฟลเดอร์นี้",
|
||||
};
|
||||
|
||||
export default th;
|
||||
@@ -4494,6 +4494,10 @@ c2c_test_deep_bad: "4 KiB'lik test gönderimi ulaşmadı: {detail}",
|
||||
"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",
|
||||
"cw_files_vw_earlier_lines": "Önceki {n} satırı göster",
|
||||
"cw_files_vw_page_first": "İlk sayfa",
|
||||
"cw_files_vw_page_last": "Son sayfa",
|
||||
"cw_files_search_none_capped": "Başka yerlerde çok fazla dosya eşleşiyor, bu yüzden bu klasörden hiçbiri gelmedi. Daha uzun bir ad kullanın ya da yalnızca bu klasörü dizinlemek için Yeniden oluştur’a basın.",
|
||||
};
|
||||
|
||||
export default tr;
|
||||
@@ -4489,6 +4489,10 @@ c2c_test_deep_bad: "Lượt gửi thử 4 KiB không tới: {detail}",
|
||||
"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",
|
||||
"cw_files_vw_earlier_lines": "Hiện {n} dòng trước đó",
|
||||
"cw_files_vw_page_first": "Trang đầu",
|
||||
"cw_files_vw_page_last": "Trang cuối",
|
||||
"cw_files_search_none_capped": "Quá nhiều tệp ở nơi khác khớp nên không có tệp nào trong thư mục này được trả về. Hãy dùng tên dài hơn, hoặc Tạo lại để chỉ lập chỉ mục thư mục này.",
|
||||
};
|
||||
|
||||
export default vi;
|
||||
@@ -4443,6 +4443,10 @@ c2c_test_deep_bad: "4 KiB 测试发送未到达:{detail}",
|
||||
"cw_files_usage_busy": "正在测量另一个文件夹的大小。请在其结束后重试。",
|
||||
"cw_files_usage_skipped": "未测量",
|
||||
"cw_files_usage_unreadable": "无法读取",
|
||||
"cw_files_vw_earlier_lines": "显示前面的 {n} 行",
|
||||
"cw_files_vw_page_first": "第一页",
|
||||
"cw_files_vw_page_last": "最后一页",
|
||||
"cw_files_search_none_capped": "其他位置匹配的文件太多,此文件夹中的结果没有返回。请使用更长的名称,或点“重新建立”只为此文件夹建立索引。",
|
||||
};
|
||||
|
||||
export default zh_CN;
|
||||
@@ -4437,6 +4437,10 @@ c2c_test_deep_bad: "4 KiB 測試傳送未抵達:{detail}",
|
||||
"cw_files_usage_busy": "正在測量另一個資料夾的大小。請在結束後重試。",
|
||||
"cw_files_usage_skipped": "未測量",
|
||||
"cw_files_usage_unreadable": "無法讀取",
|
||||
"cw_files_vw_earlier_lines": "顯示前面的 {n} 行",
|
||||
"cw_files_vw_page_first": "第一頁",
|
||||
"cw_files_vw_page_last": "最後一頁",
|
||||
"cw_files_search_none_capped": "其他位置符合的檔案太多,此資料夾中的結果沒有傳回。請使用較長的名稱,或按「重新建立」只為此資料夾建立索引。",
|
||||
};
|
||||
|
||||
export default zh_TW;
|
||||
@@ -0,0 +1,37 @@
|
||||
/* The file index's name pattern match (runtime.c INDEX_SEARCH): `*` matches any run of bytes, `?`
|
||||
* exactly one byte, everything else itself with ASCII letters compared case-insensitively; the whole
|
||||
* name must match. Iterative with one backtrack point (the last `*`): O(len(pat) * len(str)) at
|
||||
* worst, whatever the pattern (a recursive matcher took ~0.4 s per name on `*?*?*?*?*?*?*?*z`, and
|
||||
* the search runs under the index lock). Header-only so the host selftest builds it. */
|
||||
#ifndef PS5UPLOAD_INDEX_GLOB_H
|
||||
#define PS5UPLOAD_INDEX_GLOB_H
|
||||
|
||||
static inline char index_glob_lower(char c) { return (c >= 'A' && c <= 'Z') ? (char)(c + 32) : c; }
|
||||
|
||||
/* 1 when `str` matches `pat`, else 0. Both NUL-terminated. */
|
||||
static inline int index_glob_match(const char *pat, const char *str) {
|
||||
const char *star = 0; /* the pattern just past the last '*' seen */
|
||||
const char *resume = 0; /* where in str that '*' would take one more byte */
|
||||
while (*str) {
|
||||
if (*pat == '*') {
|
||||
while (*pat == '*') pat++; /* a run of stars is one star */
|
||||
if (!*pat) return 1;
|
||||
star = pat;
|
||||
resume = str;
|
||||
continue;
|
||||
}
|
||||
if (*pat == '?' || (*pat && index_glob_lower(*pat) == index_glob_lower(*str))) {
|
||||
pat++;
|
||||
str++;
|
||||
continue;
|
||||
}
|
||||
if (!star) return 0;
|
||||
/* mismatch after a star: let the star take one more byte and try again from there */
|
||||
pat = star;
|
||||
str = ++resume;
|
||||
}
|
||||
while (*pat == '*') pat++;
|
||||
return *pat == '\0';
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -158,6 +158,9 @@ 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);
|
||||
|
||||
/* Runs of '*' in a pattern collapsed to one, in place (they mean the same; fewer backtrack points). */
|
||||
void webui_index_collapse_stars(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);
|
||||
|
||||
+24
-35
@@ -64,6 +64,7 @@
|
||||
#include "proc_identity.h"
|
||||
#include "smp_meta.h"
|
||||
#include "blake3.h"
|
||||
#include "index_glob.h"
|
||||
#include "mgmt_rpc.h"
|
||||
#include "mgmt_fs.h"
|
||||
#include "path_policy.h"
|
||||
@@ -6035,39 +6036,6 @@ static void *index_thread_fn(void *arg) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Tiny glob match: `*` matches any sequence, `?` matches one char.
|
||||
* Both are case-insensitive. Recursive — fine for typical patterns
|
||||
* with one or two wildcards; bounded by call depth (max 16).
|
||||
* Returns 1 on match, 0 on miss. */
|
||||
static int glob_match(const char *pat, const char *str, int depth) {
|
||||
if (depth > 16) return 0;
|
||||
while (*pat) {
|
||||
if (*pat == '*') {
|
||||
pat++;
|
||||
if (!*pat) return 1;
|
||||
while (*str) {
|
||||
if (glob_match(pat, str, depth + 1)) return 1;
|
||||
str++;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (*pat == '?') {
|
||||
if (!*str) return 0;
|
||||
pat++;
|
||||
str++;
|
||||
continue;
|
||||
}
|
||||
char a = *pat;
|
||||
char b = *str;
|
||||
if (a >= 'A' && a <= 'Z') a += 32;
|
||||
if (b >= 'A' && b <= 'Z') b += 32;
|
||||
if (a != b) return 0;
|
||||
pat++;
|
||||
str++;
|
||||
}
|
||||
return *str == '\0';
|
||||
}
|
||||
|
||||
static int handle_index_start(runtime_state_t *state, const char *body, uint64_t body_len) {
|
||||
if (!state) return -1;
|
||||
pthread_mutex_lock(&g_index_lock);
|
||||
@@ -6154,8 +6122,10 @@ static int handle_index_status(runtime_state_t *state) {
|
||||
|
||||
static int handle_search_index(runtime_state_t *state, const char *body, uint64_t body_len) {
|
||||
if (!state) return -1;
|
||||
/* Parse `{"query":"...","size_min":N,"size_max":N,"limit":N}`. */
|
||||
/* Parse `{"query":"...","under":"/dir","size_min":N,"size_max":N,"limit":N}` (under optional). */
|
||||
char qbuf[256] = {0};
|
||||
char ubuf[1024] = {0};
|
||||
size_t ulen;
|
||||
long long size_min = 0;
|
||||
long long size_max = 0;
|
||||
int limit = 200;
|
||||
@@ -6176,6 +6146,21 @@ static int handle_search_index(runtime_state_t *state, const char *body, uint64_
|
||||
}
|
||||
}
|
||||
}
|
||||
/* Optional "under":"/folder" (the console page's This folder; the engine never sends it):
|
||||
* only paths below it count, so the limit applies after the filter. */
|
||||
p = strstr(tmp, "\"under\"");
|
||||
if (p) {
|
||||
p = strchr(p, ':');
|
||||
if (p) p = strchr(p, '"');
|
||||
if (p) {
|
||||
p++;
|
||||
const char *e = strchr(p, '"');
|
||||
if (e && (size_t)(e - p) < sizeof(ubuf)) {
|
||||
memcpy(ubuf, p, (size_t)(e - p));
|
||||
ubuf[e - p] = '\0';
|
||||
}
|
||||
}
|
||||
}
|
||||
p = strstr(tmp, "\"size_min\"");
|
||||
if (p) {
|
||||
p = strchr(p, ':');
|
||||
@@ -6194,6 +6179,9 @@ static int handle_search_index(runtime_state_t *state, const char *body, uint64_
|
||||
}
|
||||
if (limit <= 0 || limit > 5000) limit = 200;
|
||||
if (!qbuf[0]) strcpy(qbuf, "*");
|
||||
ulen = strlen(ubuf);
|
||||
while (ulen > 1 && ubuf[ulen - 1] == '/') ubuf[--ulen] = '\0';
|
||||
if (ulen == 1) ulen = 0; /* "/" is everything */
|
||||
|
||||
char *resp = malloc(256 * 1024);
|
||||
if (!resp) {
|
||||
@@ -6212,7 +6200,8 @@ static int handle_search_index(runtime_state_t *state, const char *body, uint64_
|
||||
if (!path) continue;
|
||||
const char *base = strrchr(path, '/');
|
||||
base = base ? base + 1 : path;
|
||||
if (!glob_match(qbuf, base, 0)) continue;
|
||||
if (ulen && (strncmp(path, ubuf, ulen) != 0 || (path[ulen] != '/' && path[ulen] != '\0'))) continue;
|
||||
if (!index_glob_match(qbuf, base)) continue;
|
||||
long long sz = g_index_entries[i].size;
|
||||
if (size_min > 0 && sz < size_min) continue;
|
||||
if (size_max > 0 && sz > size_max) continue;
|
||||
|
||||
+11
-6
@@ -1090,8 +1090,7 @@ static void r_cheats_toggle(const char *b, size_t n, uint8_t *out, int fd) {
|
||||
* 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_ASK 500u /* INDEX_SEARCH's limit (it filters "under" first) */
|
||||
#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 */
|
||||
|
||||
@@ -1213,7 +1212,7 @@ static void r_index_status(const char *b, size_t n, uint8_t *out, int fd) {
|
||||
/* {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;
|
||||
char query[WEBUI_INDEX_ROOT_CAP + 1], under[API_PATH], body[2 * WEBUI_INDEX_ROOT_CAP + 64], *json;
|
||||
const uint8_t *t;
|
||||
size_t ol, tl;
|
||||
unsigned ask;
|
||||
@@ -1223,13 +1222,19 @@ static void r_index_search(const char *b, size_t n, uint8_t *out, int fd) {
|
||||
return;
|
||||
}
|
||||
rc = webui_json_str(b, n, "under", under, sizeof under);
|
||||
if (rc < 0 || (rc == 1 && under[0] != '/')) {
|
||||
if (rc < 0 || (rc == 1 && !webui_index_root_ok(under))) {
|
||||
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 */
|
||||
webui_index_collapse_stars(query);
|
||||
ask = API_INDEX_ASK;
|
||||
/* checked: no quote, no backslash. "under" makes the helper filter before its limit applies (an
|
||||
* older helper ignores it: the filter below still holds, and the reply says truncated). */
|
||||
if (under[0])
|
||||
k = snprintf(body, sizeof body, "{\"query\":\"%s\",\"under\":\"%s\",\"limit\":%u}", query, under, ask);
|
||||
else
|
||||
k = snprintf(body, sizeof body, "{\"query\":\"%s\",\"limit\":%u}", query, ask);
|
||||
if (k <= 0 || (size_t)k >= sizeof body) {
|
||||
send_bad(fd, "bad_query");
|
||||
return;
|
||||
|
||||
@@ -888,6 +888,17 @@ 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); }
|
||||
|
||||
void webui_index_collapse_stars(char *q) {
|
||||
char *w = q;
|
||||
const char *r = q;
|
||||
if (!q) return;
|
||||
for (; *r; r++) {
|
||||
if (*r == '*' && w > q && w[-1] == '*') continue;
|
||||
*w++ = *r;
|
||||
}
|
||||
*w = '\0';
|
||||
}
|
||||
|
||||
int webui_index_start_body(char *out, size_t cap, const char *const *roots, int n) {
|
||||
size_t at = 0;
|
||||
int i;
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/* Host test for the file index's name match (include/index_glob.h): the glob semantics INDEX_SEARCH
|
||||
* always had (* any run, ? one byte, ASCII case-insensitive, whole name), and a bound on the
|
||||
* pathological patterns that made the old recursive matcher exponential. */
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
#include "index_glob.h"
|
||||
|
||||
static int failures = 0;
|
||||
#define CHECK(expr) \
|
||||
do { \
|
||||
if (!(expr)) { \
|
||||
fprintf(stderr, "FAIL line %d: %s\n", __LINE__, #expr); \
|
||||
failures++; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
int main(void) {
|
||||
char name[256];
|
||||
struct timespec a, b;
|
||||
double ms;
|
||||
int i, hits = 0;
|
||||
CHECK(index_glob_match("*", ""));
|
||||
CHECK(index_glob_match("*", "eboot.bin"));
|
||||
CHECK(index_glob_match("", ""));
|
||||
CHECK(!index_glob_match("", "a"));
|
||||
CHECK(index_glob_match("*eboot*", "eboot.bin"));
|
||||
CHECK(index_glob_match("*EBOOT*", "my_eboot.BIN"));
|
||||
CHECK(index_glob_match("*.pkg", "Game.PKG"));
|
||||
CHECK(!index_glob_match("*.pkg", "Game.pkg.part"));
|
||||
CHECK(index_glob_match("CUSA0784?", "cusa07842"));
|
||||
CHECK(!index_glob_match("CUSA0784?", "CUSA0784"));
|
||||
CHECK(!index_glob_match("CUSA0784?", "CUSA078422"));
|
||||
CHECK(index_glob_match("a*b*c", "aXXbYYc"));
|
||||
CHECK(index_glob_match("a*b*c", "abc"));
|
||||
CHECK(!index_glob_match("a*b*c", "aXXbYY"));
|
||||
CHECK(index_glob_match("a**b", "ab"));
|
||||
CHECK(index_glob_match("*a*", "banana"));
|
||||
CHECK(index_glob_match("*ana", "banana"));
|
||||
CHECK(!index_glob_match("*anaz", "banana"));
|
||||
CHECK(index_glob_match("?*", "x"));
|
||||
CHECK(!index_glob_match("?*", ""));
|
||||
CHECK(index_glob_match("*?", "x"));
|
||||
CHECK(index_glob_match("\xc3\xa4*", "\xc3\xa4pfel")); /* UTF-8 bytes compare as themselves */
|
||||
CHECK(!index_glob_match("\xc3\x84*", "\xc3\xa4pfel")); /* non-ASCII case is not folded */
|
||||
/* the pathological pattern: 200,000 names in well under a second (the recursive matcher took
|
||||
* ~0.4 s for ONE of them) */
|
||||
memset(name, 'a', 200);
|
||||
name[200] = '\0';
|
||||
clock_gettime(CLOCK_MONOTONIC, &a);
|
||||
for (i = 0; i < 200000; i++) hits += index_glob_match("*?*?*?*?*?*?*?*z", name);
|
||||
clock_gettime(CLOCK_MONOTONIC, &b);
|
||||
ms = (double)(b.tv_sec - a.tv_sec) * 1e3 + (double)(b.tv_nsec - a.tv_nsec) / 1e6;
|
||||
CHECK(hits == 0);
|
||||
CHECK(ms < 5000.0);
|
||||
name[199] = 'z';
|
||||
CHECK(index_glob_match("*?*?*?*?*?*?*?*z", name));
|
||||
if (failures) {
|
||||
fprintf(stderr, "index_glob_selftest: %d failure(s)\n", failures);
|
||||
return 1;
|
||||
}
|
||||
printf("index_glob_selftest: all passed (pathological pattern x200000: %.0f ms)\n", ms);
|
||||
return 0;
|
||||
}
|
||||
@@ -800,6 +800,16 @@ static void index_codec(void) {
|
||||
CHECK(!webui_index_query_ok("a\"b"));
|
||||
CHECK(!webui_index_query_ok("a\\b"));
|
||||
CHECK(!webui_index_query_ok("a\tb"));
|
||||
/* runs of '*' collapse to one */
|
||||
{
|
||||
char q[32];
|
||||
strcpy(q, "**a***b*?**");
|
||||
webui_index_collapse_stars(q);
|
||||
CHECK(strcmp(q, "*a*b*?*") == 0);
|
||||
strcpy(q, "abc");
|
||||
webui_index_collapse_stars(q);
|
||||
CHECK(strcmp(q, "abc") == 0);
|
||||
}
|
||||
/* 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);
|
||||
|
||||
Reference in new issue
Block a user