Explicit, optional OPFS cache for attachments
This commit is contained in:
97
packages/frontend/src/fscache.ts
Normal file
97
packages/frontend/src/fscache.ts
Normal 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());
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user