Files
siftlode/frontend/src/lib/api/files.ts
T
peter d6dada7fe5 refactor(frontend): one account per browser, its local state under plain keys
The per-tab account pin, the X-Siftlode-Account header and ?account= go: the session cookie alone
says who is asking. localStorage keys lose their account suffix; the browser records whose state it
holds (siftlode.owner), BootGate hands it to the account the server answered as, and a different
account starts from a clean slate (the public /watch keys stay). The first boot moves the account's
old per-account copies onto the plain keys and drops other accounts' copies. Until the claim, and
after another tab claimed the browser for someone else, the account helpers read and write nothing.

The account panel lists no other accounts: "Add another account" becomes "Sign in as someone else"
(Google's account chooser; the pick replaces this account, a cancel keeps it), shown where Google
sign-in is configured.
2026-10-20 15:29:41 +02:00

157 lines
7.1 KiB
TypeScript

// The Files library: a user's done files, the folders they sit in, and the file itself
// (backend `routes/files.py`). A library file is a done download job, so it is typed as one.
import { req, uploadReq } from "./core";
import type { DownloadJob } from "./downloads";
// "source": filled by where a file came from (YouTube, Plex, a site, uploads); "own": made by the user;
// "hub": the one folder the user swaps files with their hub machine through (made by its token).
export type FolderKind = "source" | "own" | "hub";
export interface FileFolder {
id: number;
kind: FolderKind;
// A source folder's origin — `youtube`, `plex`, `upload`, `site:<host>` — which the client names
// in the reader's language; null on an own folder.
source_key: string | null;
name: string | null; // an own folder's name; null on a source folder
retention_days: number | null; // null = kept until deleted
count: number; // its done files
size_bytes: number;
created_at: string | null;
// A live public link opens it: whatever is moved in is shown to whoever holds the link.
has_link: boolean;
}
// The most files one list answers with (backend `routes/files.py` MAX_LIST, held equal by
// files.contract.test.ts): a list this long may be missing older files, so the page asks the
// server to search and sort it rather than doing either on the rows it got.
export const FILES_LIST_CAP = 500;
export type FileSort = "added" | "name" | "size";
export const FILE_SORTS: readonly FileSort[] = ["added", "name", "size"];
export type FileOrder = "asc" | "desc";
export interface FileListParams {
folderId?: number | null; // one folder's files; absent = every file
q?: string;
sort?: FileSort;
order?: FileOrder;
shared?: boolean; // the files others shared with me instead of mine
}
export interface LibraryFile extends DownloadJob {
// When it was shared with me (the "added" of a shared file); null on my own.
shared_at?: string | null;
// Where it came from — its source folder's key (`youtube`, `site:<host>`, …), wherever it sits now.
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;
uploader?: string | null;
uploader_url?: string | null;
extra_links?: string[];
folder_id?: number;
kept?: boolean;
}
export interface StorageUsage {
footprint_bytes: number;
active_jobs: number;
running_jobs: number;
max_bytes: number;
max_concurrent: number;
max_jobs: number;
unlimited: boolean;
// The per-file cap on an upload, so the dialog can refuse an over-cap file before spending the
// user's bandwidth on it. Not the same thing as `max_bytes`, which is the whole library
// footprint: this one is a single request's ceiling and is enforced twice server-side anyway.
upload_max_bytes: number;
}
/** The list's query string — exported for the contract test that holds its names to the route's. */
export function listQuery(p: FileListParams): string {
const q = new URLSearchParams();
if (p.shared) q.set("scope", "shared");
else if (p.folderId != null) q.set("folder_id", String(p.folderId));
if (p.q?.trim()) q.set("q", p.q.trim());
if (p.sort) q.set("sort", p.sort);
if (p.order) q.set("order", p.order);
const s = q.toString();
return s ? `?${s}` : "";
}
// What a folder PATCH may change. Presence decides: `retention_days: null` = keep its files forever.
export interface FolderPatch {
name?: string; // an own folder's only
retention_days?: number | null;
}
export const filesApi = {
fileFolders: (): Promise<FileFolder[]> => req("/api/files/folders"),
createFolder: (name: string): Promise<FileFolder> =>
req("/api/files/folders", { method: "POST", body: JSON.stringify({ name }) }),
updateFolder: (id: number, patch: FolderPatch): Promise<FileFolder> =>
req(`/api/files/folders/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
// An own folder only; its files go back to the folders they came from.
deleteFolder: (id: number): Promise<{ ok: true; deleted: number; files_moved: number }> =>
req(`/api/files/folders/${id}`, { method: "DELETE" }),
files: (p: FileListParams = {}): Promise<LibraryFile[]> => req(`/api/files${listQuery(p)}`),
filesUsage: (): Promise<StorageUsage> => req("/api/files/usage"),
updateFile: (id: number, patch: FilePatch): Promise<DownloadJob> =>
req(`/api/files/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
deleteFile: (id: number): Promise<{ ok: true; deleted: number }> =>
req(`/api/files/${id}`, { method: "DELETE" }),
// Drop a file someone shared with me from my list (only my grant goes, not their file).
removeSharedFile: (id: number): Promise<{ ok: true; removed: number }> =>
req(`/api/files/shared/${id}`, { method: "DELETE" }),
// Put a local file in the library. Sent via XHR rather than `req` so the dialog can show upload
// progress; any file type is accepted (media gets the full function set, a generic file is
// download-only). Returns the job the ingest creates: `done`, or `queued` while the worker
// converts media no phone could play as it is (`downloads/upload.py`).
// The body IS the file, and the names ride as headers. Not a style choice: a multipart part is a
// DECLARED body, which FastAPI reads and spools BEFORE the route's `current_user` dependency runs
// — so with that shape an unauthenticated caller could make the server write the whole per-file
// allowance to temp. Headers are latin-1 on the wire, hence `encodeURIComponent`; the server
// percent-decodes them. Content-Type is left to the browser (the File's own type). No folder =
// the Uploads folder.
uploadFile: (
file: File,
opts: {
displayName?: string;
folderId?: number | null;
onProgress?: (pct: number) => void;
signal?: AbortSignal;
} = {},
): Promise<DownloadJob> => {
const headers: Record<string, string> = {
"X-Upload-Filename": encodeURIComponent(file.name || "upload"),
};
if (opts.displayName) headers["X-Upload-Display-Name"] = encodeURIComponent(opts.displayName);
if (opts.folderId != null) headers["X-Upload-Folder"] = String(opts.folderId);
return uploadReq<DownloadJob>("/api/files/upload", file, {
onProgress: opts.onProgress,
signal: opts.signal,
headers,
});
},
// The file itself, for a plain <a> navigation.
fileUrl: (id: number): string => `/api/files/${id}/file`,
// The viewer's copy of an image or a PDF (`/preview`, sandboxed): an <img> / <iframe> source.
previewUrl: (id: number): string => `/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" };
},
};