Explicit, optional OPFS cache for attachments

This commit is contained in:
2025-10-16 22:56:08 +02:00
parent a6ad34c721
commit c3cd6ea6b2
6 changed files with 262 additions and 20 deletions

View File

@@ -0,0 +1,97 @@
import { Sha256 } from "common";
import * as Body from "common/Body";
import { Effect, Encoding, pipe } from "effect";
import { UnknownException } from "effect/Cause";
import { dual } from "effect/Function";
export const getDirectoryHandle = dual<
(name: string, options?: FileSystemGetDirectoryOptions) => (handle: FileSystemDirectoryHandle) => Effect.Effect<FileSystemDirectoryHandle, UnknownException>,
(handle: FileSystemDirectoryHandle, name: string, options?: FileSystemGetDirectoryOptions) => Effect.Effect<FileSystemDirectoryHandle, UnknownException>
>(
(args) => args[0].kind === "directory",
(handle, name, options) => Effect.tryPromise(() => handle.getDirectoryHandle(name, options)),
);
export const getFileHandle = dual<
(name: string, options?: FileSystemGetFileOptions) => (handle: FileSystemDirectoryHandle) => Effect.Effect<FileSystemFileHandle, UnknownException>,
(handle: FileSystemDirectoryHandle, name: string, options?: FileSystemGetFileOptions) => Effect.Effect<FileSystemFileHandle, UnknownException>
>(
(args) => args[0].kind === "directory",
(handle, name, options) => Effect.tryPromise(() => handle.getFileHandle(name, options)),
);
export const createWritable = dual<
(options?: FileSystemCreateWritableOptions) => (handle: FileSystemFileHandle) => Effect.Effect<FileSystemWritableFileStream, UnknownException>,
(handle: FileSystemFileHandle, options?: FileSystemCreateWritableOptions) => Effect.Effect<FileSystemWritableFileStream, UnknownException>
>(
(args) => args[0].kind === "file",
(handle, options) => Effect.tryPromise(() => handle.createWritable(options)),
);
export const removeEntry = dual<
(name: string, options?: FileSystemRemoveOptions) => (handle: FileSystemDirectoryHandle) => Effect.Effect<void, UnknownException>,
(handle: FileSystemDirectoryHandle, name: string, options?: FileSystemRemoveOptions) => Effect.Effect<void, UnknownException>
>(
(args) => args[0].kind === "directory",
(handle, name, options) => Effect.tryPromise(() => handle.removeEntry(name, options)),
);
export const getOpfsRoot = Effect.tryPromise(() => navigator.storage.getDirectory());
export const getFilesDir = pipe(
getOpfsRoot,
Effect.flatMap(getDirectoryHandle("files", { create: true })),
);
export const getFile = (sha256: Sha256) => pipe(
sha256,
Encoding.encodeBase64Url,
getFile_base64url,
);
export const getFile_base64url = (sha256_base64url: string) => Effect.gen(function* () {
const filesDir = yield* getFilesDir;
const fileHandle = yield* getFileHandle(filesDir, sha256_base64url, { create: false });
const file = yield* Effect.tryPromise(() => fileHandle.getFile());
const buffer = yield* Body.arrayBuffer(file);
const data = new Uint8Array(buffer);
return data;
}).pipe(Effect.option);
export const storeFile = (data: Uint8Array<ArrayBuffer>, sha256: Sha256) => Effect.gen(function* () {
const sha256_base64url = Encoding.encodeBase64Url(sha256);
const filesDir = yield* getFilesDir;
const file = yield* getFileHandle(filesDir, sha256_base64url, { create: true });
const writable = yield* createWritable(file, { keepExistingData: false });
yield* Effect.tryPromise(() => writable.write(data));
yield* Effect.tryPromise(() => writable.close());
});
export const hasFile = (sha256: Sha256) => Effect.gen(function* () {
const sha256_base64url = Encoding.encodeBase64Url(sha256);
const filesDir = yield* getFilesDir;
yield* getFileHandle(filesDir, sha256_base64url, { create: false });
return true;
}).pipe(Effect.catchAll(() => Effect.succeed(false)));
export const removeFile = (sha256: Sha256) => Effect.gen(function* () {
const sha256_base64url = Encoding.encodeBase64Url(sha256);
const filesDir = yield* getFilesDir;
yield* removeEntry(filesDir, sha256_base64url, { recursive: false });
}).pipe(Effect.ignore);
export const stats = Effect.tryPromise(() => navigator.storage.estimate());

View File

@@ -1,18 +1,36 @@
import { client } from "@/client";
import * as FSCache from "@/fscache";
import { useLoading } from "@/hooks/useLoading.ts";
import { AttachmentId } from "common";
import { Match } from "effect";
import { Effect, Match, Option } from "effect";
import JSZip from "jszip";
import { OpenSheetMusicDisplay } from "opensheetmusicdisplay";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { useParams, useSearchParams } from "react-router-dom";
export default function Attachment() {
const params = useParams();
const [searchParams] = useSearchParams();
const attachmentId = AttachmentId.make(params.attachmentId!);
const { isLoading, error, data } = useLoading(client.getAttachment(attachmentId), [attachmentId]);
const { isLoading, error, data } = useLoading(Effect.gen(function* () {
const sha256_base64url = searchParams.get("sha256");
const mediaType = searchParams.get("mediaType");
if (sha256_base64url !== null && mediaType !== null) {
const data = yield* FSCache.getFile_base64url(sha256_base64url);
if (Option.isSome(data)) {
return { data: data.value, mediaType };
}
}
const res = yield* client.getAttachment(attachmentId);
return { data: res.data, mediaType: res.mediaType };
}), [attachmentId, searchParams]);
if (isLoading) {
return (
@@ -41,7 +59,7 @@ export default function Attachment() {
if (data.mediaType === "application/vnd.recordare.musicxml" || data.mediaType === "application/vnd.recordare.musicxml+xml") {
return <MusicXmlAttachment data={data.data} mediaType={data.mediaType} />;
} else if (data.mediaType === "application/pdf") {
return <PdfAttachment data={data.data} filename={data.filename} mediaType={data.mediaType} />;
return <PdfAttachment data={data.data} mediaType={data.mediaType} />;
} else {
return null;
}
@@ -141,21 +159,19 @@ function MusicXmlAttachment({ data, mediaType }: MusicXmlAttachment.Props) {
namespace PdfAttachment {
export interface Props {
readonly data: Uint8Array<ArrayBuffer>;
readonly filename: string;
readonly mediaType: "application/pdf";
}
}
function PdfAttachment({
data,
filename,
mediaType,
}: PdfAttachment.Props) {
const [url, setUrl] = useState<string>("");
useLayoutEffect(() => {
const file = new File([data], filename, { type: mediaType });
const file = new Blob([data], { type: mediaType });
const url = URL.createObjectURL(file);
setUrl(url);

View File

@@ -2,10 +2,11 @@ import { Attachment, denormalizePiece, denormalizeSystemInformation, pieceCache,
import { client } from "@/client";
import { PieceDialogContent } from "@/components/PieceDialog";
import { Button } from "@/components/ui/button";
import { Dialog, DialogTrigger } from "@/components/ui/dialog";
import { Dialog } from "@/components/ui/dialog";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import * as FSCache from "@/fscache";
import { useCache } from "@/hooks/useCache";
import { mapProp } from "@/hooks/useStore";
import { created, modified, SAVE_DELAY, saveDelay } from "@/snippets";
@@ -14,10 +15,10 @@ import clsx from "clsx";
import { PieceId } from "common";
import * as Body from "common/Body";
import { getMediaTypeForFilename } from "common/MediaType";
import { Array, Cause, DateTime, Effect, Exit, Fiber, Option, pipe, Scope } from "effect";
import { Array, Cause, DateTime, Effect, Encoding, Exit, Fiber, Match, Option, pipe, Scope } from "effect";
import { constant } from "effect/Function";
import { Download, EllipsisVertical, Loader2, Trash, UploadCloud } from "lucide-react";
import { DragEventHandler, FormEventHandler, MouseEvent, useCallback, useId, useState } from "react";
import { CloudAlert, CloudCheck, CloudDownload, Download, EllipsisVertical, Loader2, Trash, UploadCloud } from "lucide-react";
import { DragEventHandler, FormEventHandler, useCallback, useId, useEffect as useLayoutEffect, useMemo, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
export function Piece() {
@@ -209,15 +210,43 @@ namespace AttachmentRow {
}
}
enum LocalCopyState {
Uninitialized = "uninitialized",
Online = "online",
Offline = "offline",
Loading = "loading",
Error = "error",
}
function AttachmentRow(props: AttachmentRow.Props) {
const [cloneDialogOpen, setCloneDialogOpen] = useState(false);
const [localCopyState, setLocalCopyState] = useState(LocalCopyState.Uninitialized);
useLayoutEffect(() => {
if (localCopyState !== LocalCopyState.Uninitialized) return;
setLocalCopyState(LocalCopyState.Loading);
pipe(
FSCache.hasFile(props.attachment.sha256),
Effect.tap((exists) => setLocalCopyState(exists ? LocalCopyState.Offline : LocalCopyState.Online)),
Effect.runFork,
);
}, [props.attachment.sha256, localCopyState]);
const download = () => Effect.gen(function* () {
const { data, mediaType, filename } = yield* client.getAttachment(props.attachment.attachmentId);
const data = yield* pipe(
FSCache.getFile(props.attachment.sha256),
Effect.flatten,
Effect.catchTag("NoSuchElementException", () => pipe(
client.getAttachment(props.attachment.attachmentId),
Effect.map((res) => res.data),
)),
);
const file = new File([data], filename, {
type: mediaType,
const file = new File([data], props.attachment.filename, {
type: props.attachment.mediaType,
lastModified: pipe(
props.attachment.modifiedAt,
Option.getOrElse(() => props.attachment.createdAt),
@@ -228,11 +257,24 @@ function AttachmentRow(props: AttachmentRow.Props) {
const url = URL.createObjectURL(file);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.download = props.attachment.filename;
a.click();
URL.revokeObjectURL(url);
}).pipe(Effect.runPromise);
const downloadAndSave = () => Effect.gen(function* () {
setLocalCopyState(LocalCopyState.Loading);
const { data } = yield* client.getAttachment(props.attachment.attachmentId);
const exit = yield* pipe(
FSCache.storeFile(data, props.attachment.sha256),
Effect.exit,
);
Exit.match(exit, {
onFailure: () => setLocalCopyState(LocalCopyState.Error),
onSuccess: () => setLocalCopyState(LocalCopyState.Offline),
});
}).pipe(Effect.runPromise);
const doDelete = () => Effect.gen(function* () {
yield* client.deleteAttachment(props.attachment.attachmentId);
yield* pieceCache.update(props.attachment.pieceId, mapProp("attachments", Array.filter<Attachment>((a) => a.attachmentId !== props.attachment.attachmentId)));
@@ -259,13 +301,18 @@ function AttachmentRow(props: AttachmentRow.Props) {
setCloneDialogOpen(false);
}).pipe(Effect.runPromise);
const searchParams = useMemo(() => new URLSearchParams({
sha256: Encoding.encodeBase64Url(props.attachment.sha256),
mediaType: props.attachment.mediaType,
}).toString(), [props.attachment.sha256, props.attachment.mediaType]);
return (<>
<TableRow>
<TableCell>
{props.attachment.mediaType === "application/vnd.recordare.musicxml"
|| props.attachment.mediaType === "application/vnd.recordare.musicxml+xml"
|| props.attachment.mediaType === "application/pdf" ? (
<Link className="underline" to={`attachment/${props.attachment.attachmentId}`}>
<Link className="underline" to={`attachment/${props.attachment.attachmentId}?${searchParams}`}>
{props.attachment.filename}
</Link>
) : (
@@ -281,13 +328,23 @@ function AttachmentRow(props: AttachmentRow.Props) {
<TableCell className="text-center text-xs">
{modified(props.attachment)}
</TableCell>
<TableCell className="text-center flex justify-center gap-4">
<TableCell className="text-center flex justify-center gap-1">
<Button type="button" variant="ghost" size="icon" title="Pobierz" onClick={download}>
<Download />
</Button>
<Button type="button" variant="ghost" size="icon" title="Usuń" onClick={doDelete}>
<Trash />
</Button>
<Button type="button" variant="ghost" size="icon" title="Zapisz" onClick={localCopyState === LocalCopyState.Online ? downloadAndSave : undefined}>
{Match.value(localCopyState).pipe(
Match.when(LocalCopyState.Uninitialized, () => <Loader2 className="animate-spin" />),
Match.when(LocalCopyState.Online, () => <CloudDownload />),
Match.when(LocalCopyState.Loading, () => <Loader2 className="animate-spin" />),
Match.when(LocalCopyState.Offline, () => <CloudCheck />),
Match.when(LocalCopyState.Error, () => <CloudAlert />),
Match.exhaustive,
)}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="ghost" size="icon" title="Więcej">

View File

@@ -255,7 +255,7 @@ function EntryRow({
<TableCell>
{authors(piece)}
</TableCell>
<TableCell className="text-center flex justify-center gap-4">
<TableCell className="text-center flex justify-center gap-1">
<Button type="button" variant="ghost" size="icon" title="Przesuń w górę" onClick={moveUp} disabled={no === 1}>
<ChevronUp />
</Button>

View File

@@ -1,7 +1,54 @@
import { Button } from "@/components/ui/button";
import * as FSCache from "@/fscache";
import { useLoading } from "@/hooks/useLoading";
import { bytes, SAVE_DELAY } from "@/snippets";
import { Effect, Fiber, Scope } from "effect";
import { Loader2, Trash } from "lucide-react";
import { useState } from "react";
export function Settings() {
const { isLoading, error, data, refresh } = useLoading(Effect.gen(function* () {
const stats = yield* FSCache.stats;
return { stats };
}), []);
const [isCleanupPending, setIsCleanupPending] = useState(false);
const cleanup = () => Effect.scopedWith((scope) => Effect.gen(function* () {
const delay = yield* Effect.fork(SAVE_DELAY);
setIsCleanupPending(true);
yield* Scope.addFinalizer(scope, Effect.gen(function* () {
yield* Fiber.join(delay);
setIsCleanupPending(false);
}));
const root = yield* FSCache.getOpfsRoot;
yield* FSCache.removeEntry(root, "files", { recursive: true });
yield* refresh;
})).pipe(Effect.runFork);
return (
<div className="p-4 overflow-y-auto grow flex flex-wrap items-start gap-4">
Jakby były ustawienia, to by tu były.
<div className="flex flex-col gap-4 p-4 border rounded">
<h3 className="font-bold text-lg">Załączniki offline</h3>
{!isLoading && error === null && (
<div className="grid grid-cols-2 gap-2">
<div>W użyciu:</div>
<div>{data.stats.usage !== undefined ? bytes(data.stats.usage) : "b/d"}</div>
<div>Limit:</div>
<div>{data.stats.quota !== undefined ? bytes(data.stats.quota) : "b/d"}</div>
</div>
)}
<Button variant="destructive" onClick={cleanup} disabled={isCleanupPending}>
{isCleanupPending && <Loader2 className="animate-spin" />}
<Trash />Wyczyść
</Button>
</div>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { Piece, DenormalizedSystemInformation } from "@/cache";
import { DenormalizedSystemInformation, Piece } from "@/cache";
import { timeout } from "@/lib/utils";
import { Clock, DateTime, Duration, Option } from "effect";
import { ReactNode } from "react";
@@ -74,3 +74,28 @@ export function modified({ modifiedAt, modifiedBy }: DenormalizedSystemInformati
return nodes;
}
export function bytes(bytes: number): string {
if (bytes < 1024) return bytes.toLocaleString("pl-PL", {
maximumFractionDigits: 0,
}) + " B";
const kib = bytes / 1024;
if (kib < 1024) return kib.toLocaleString("pl-PL", {
minimumSignificantDigits: 1,
maximumSignificantDigits: 4,
}) + " kiB";
const mib = kib / 1024;
if (mib < 1024) return mib.toLocaleString("pl-PL", {
minimumSignificantDigits: 1,
maximumSignificantDigits: 4,
}) + " MiB";
const gib = mib / 1024;
return gib.toLocaleString("pl-PL", {
minimumSignificantDigits: 1,
maximumSignificantDigits: 4,
}) + " GiB";
}