feat(files): a file that is not media opens in a viewer — an image, a PDF, text as text
- GET /api/files/{id}/preview: an allow-list on the stored extension (downloads/preview.py) picks
image / PDF / text; served inline under `CSP: sandbox; frame-ancestors 'self'`, the type from the
table; HTML and SVG arrive as plain text; a text is its first 1 MiB (X-Preview-Truncated), sent
as UTF-8 whatever it was written in (BOM, UTF-8, else cp1250); a NUL means binary: 415
- the list says which preview each file gets (asset.preview); FE/BE contract on kinds and header
- the viewer (shared dialog shell): ← / → through the list's such files in its order, Save, open in
a tab, the file's menu; one stage height for every kind; a no-preview card offers the file
- the name, the card's picture and the menu's Preview open it; an image up to 8 MB is its own
thumbnail
This commit is contained in:
@@ -36,3 +36,33 @@ describe("display-name bound", () => {
|
||||
for (const m of declared) expect(m[1]).toBe("DISPLAY_TEXT_MAX");
|
||||
});
|
||||
});
|
||||
|
||||
// The viewer reads what the server decides (backend downloads/preview.py): the preview kinds it may
|
||||
// be told and the header that says a text was cut. Each side names them in its own language, so the
|
||||
// gate reads both: a kind the server adds that the viewer does not know would fall to "no preview".
|
||||
describe("file preview", () => {
|
||||
it("knows the kinds the server hands out", () => {
|
||||
const backend = src("../../../../backend/app/downloads/preview.py").match(
|
||||
/^PreviewKind = Literal\[(.*)\]$/m,
|
||||
);
|
||||
const frontend = src("./downloads.ts").match(/^export type FilePreviewKind = (.*);$/m);
|
||||
expect(backend, "downloads/preview.py no longer defines PreviewKind").toBeTruthy();
|
||||
expect(frontend, "lib/api/downloads.ts no longer exports FilePreviewKind").toBeTruthy();
|
||||
const kinds = (s: string, sep: string) =>
|
||||
s
|
||||
.split(sep)
|
||||
.map((k) => k.trim().replace(/"/g, ""))
|
||||
.sort();
|
||||
expect(kinds(frontend![1]!, "|")).toEqual(kinds(backend![1]!, ","));
|
||||
});
|
||||
|
||||
it("reads the header the server cuts a text with", () => {
|
||||
const backend = src("../../../../backend/app/routes/files.py").match(
|
||||
/^TRUNCATED_HEADER = "(.*)"$/m,
|
||||
);
|
||||
const frontend = src("./files.ts").match(/^const PREVIEW_TRUNCATED_HEADER = "(.*)";$/m);
|
||||
expect(backend, "routes/files.py no longer defines TRUNCATED_HEADER").toBeTruthy();
|
||||
expect(frontend, "lib/api/files.ts no longer defines PREVIEW_TRUNCATED_HEADER").toBeTruthy();
|
||||
expect(frontend![1]).toBe(backend![1]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,9 +39,14 @@ interface DownloadAsset {
|
||||
// (video/audio) gets play/edit; a generic uploaded file is download+share only, no player. Null
|
||||
// while still in flight (set at finalize). See backend downloads/mediakind.py.
|
||||
media_kind: "video" | "audio" | "file" | null;
|
||||
// What the library's viewer shows of a generic file (backend downloads/preview.py): an image, a PDF
|
||||
// in the browser's own viewer, plain text; null = no preview (media plays instead).
|
||||
preview?: FilePreviewKind | null;
|
||||
thumbnail_url: string | null;
|
||||
}
|
||||
|
||||
export type FilePreviewKind = "image" | "pdf" | "text";
|
||||
|
||||
export type DownloadStatus = "queued" | "running" | "paused" | "done" | "error" | "canceled";
|
||||
// "download" (default), "edit" (a per-user trim/crop derivative cut from another job), "upload"
|
||||
// (a locally-uploaded file) or "clip" (a marked range of a YouTube video or a Plex item, cut straight
|
||||
|
||||
@@ -37,6 +37,14 @@ export interface LibraryFile extends DownloadJob {
|
||||
source_key: string;
|
||||
}
|
||||
|
||||
// Set by the server on a text preview cut at its size cap (`routes/files.py` TRUNCATED_HEADER).
|
||||
const PREVIEW_TRUNCATED_HEADER = "X-Preview-Truncated";
|
||||
|
||||
export interface FilePreviewText {
|
||||
text: string;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
// What a PATCH may change. Presence decides: a null / "" text falls back to the system value.
|
||||
export interface FilePatch {
|
||||
name?: string | null;
|
||||
@@ -133,4 +141,15 @@ export const filesApi = {
|
||||
const a = getActiveAccount();
|
||||
return a != null ? `/api/files/${id}/file?account=${a}` : `/api/files/${id}/file`;
|
||||
},
|
||||
// The viewer's copy of an image or a PDF (`/preview`, sandboxed): an <img> / <iframe> source, so it
|
||||
// carries the account the same way.
|
||||
previewUrl: (id: number): string => {
|
||||
const a = getActiveAccount();
|
||||
return a != null ? `/api/files/${id}/preview?account=${a}` : `/api/files/${id}/preview`;
|
||||
},
|
||||
// A text file's head as text, and whether the server cut it there. Quiet: the viewer says why.
|
||||
previewText: async (id: number): Promise<FilePreviewText> => {
|
||||
const r = await req<Response>(`/api/files/${id}/preview`, {}, { raw: true, quiet: true });
|
||||
return { text: await r.text(), truncated: r.headers.get(PREVIEW_TRUNCATED_HEADER) === "1" };
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user