Signal drop!
Relay (operand.online) is unreachable.
Usually, a dropped signal means an upgrade is happening. Hold on!
Sorry, no connección.
Hang in there while we get back on track
gram: luck
> ./src/persistence.tsx
Lenses
(coming soon!)
import { Text } from "@codemirror/state";
import { fileSave } from "browser-fs-access";
import { comparer, observable, reaction, runInAction } from "mobx";
import {
selectedTextDocumentIdBox,
SheetConfig,
sheetConfigsMobx,
Span,
TextDocument,
TextDocumentSheet,
textDocumentsMobx,
} from "./primitives";
import React, { useState } from "react";
import { generateNanoid } from "./utils";
import * as Toast from "@radix-ui/react-toast";
import { get, set } from "idb-keyval";
function prettyStringify(value: any): string {
return JSON.stringify(value, null, 2);
}
const IDB_DIRECTORY_HANDLE_KEY = "directoryHandle";
// we'll always start file paths with / so "/foo.txt" is a foo.txt in the root
// directory.
export const TEXT_FILE_EXTENSION = "txt";
export const DOCUMENT_METADATA_EXTENSION = "metadata";
export const HIGHLIGHTER_FILE_EXTENSION = "highlighter";
export const directoryPersistenceBox = observable.box<
DirectoryPersistence | undefined
>(undefined, { deep: false });
export const existingDirectoryHandleBox = observable.box<
FileSystemDirectoryHandle | undefined
>(undefined);
let lastSelectedSyncDocumentId =
localStorage.getItem("lastSyncDocumentId") ?? undefined;
reaction(
() =>
directoryPersistenceBox.get() !== undefined
? selectedTextDocumentIdBox.get()
: undefined,
(selectedTextDocumentId) => {
if (selectedTextDocumentId !== undefined) {
localStorage.setItem("lastSyncDocumentId", selectedTextDocumentId);
lastSelectedSyncDocumentId = selectedTextDocumentId;
}
}
);
async function init() {
const existingDirectoryHandle = await get(IDB_DIRECTORY_HANDLE_KEY);
if (existingDirectoryHandle !== undefined) {
const permission = await existingDirectoryHandle.queryPermission({
mode: "readwrite",
});
if (permission === "prompt" || permission === "granted") {
runInAction(() => {
existingDirectoryHandleBox.set(existingDirectoryHandle);
});
}
}
}
init();
function getRelativePath(fileHandle: File) {
return fileHandle.webkitRelativePath.substring(
fileHandle.webkitRelativePath.indexOf("/")
);
}
function getTextDocumentId(filePath: string) {
return filePath.substring(
1,
filePath.length - TEXT_FILE_EXTENSION.length - 1
);
}
function getTextDocumentIdFromMetadata(filePath: string) {
return filePath.substring(
1,
filePath.length - DOCUMENT_METADATA_EXTENSION.length - 1
);
}
function getHighlighterId(filePath: string) {
return filePath.substring(
1,
filePath.length - HIGHLIGHTER_FILE_EXTENSION.length - 1
);
}
declare global {
interface Window {
showDirectoryPicker(options: any): Promise<any>;
}
}
export class DirectoryPersistence {
directoryHandle: FileSystemDirectoryHandle | undefined;
fileHandles: { [filePath: string]: FileSystemFileHandle } = {};
fileLastModified: { [filePath: string]: number } = {};
fileCache: { [filePath: string]: string } = {};
unsubscribes: (() => void)[] = [];
constructor() {}
async init(
writePrimitives: boolean,
directoryHandle?: FileSystemDirectoryHandle
) {
if (directoryHandle !== undefined) {
// const perm = await lastDirectoryHandle.queryPermission({
// mode: "readwrite",
// });
// if (perm !== "granted") {
// const perm = await lastDirectoryHandle.requestPermission({
// mode: "readwrite",
// });
// }
this.directoryHandle = directoryHandle;
} else {
this.directoryHandle = await window.showDirectoryPicker({
mode: "readwrite",
});
set(IDB_DIRECTORY_HANDLE_KEY, this.directoryHandle);
}
// @ts-ignore
for await (const entry of this.directoryHandle.values()) {
const relativePath = `/${entry.name}`;
if (entry.kind === "file") {
const file = await entry.getFile().then((file: any) => {
file.directoryHandle = this.directoryHandle;
file.handle = entry;
return Object.defineProperty(file, "webkitRelativePath", {
configurable: true,
enumerable: true,
get: () => relativePath,
});
});
if (
file.name.endsWith(`.${HIGHLIGHTER_FILE_EXTENSION}`) ||
file.name.endsWith(`.${TEXT_FILE_EXTENSION}`) ||
file.name.endsWith(`.${DOCUMENT_METADATA_EXTENSION}`)
) {
if (file.handle !== undefined) {
this.fileHandles[relativePath] = file.handle;
}
this.fileLastModified[relativePath] = file.lastModified;
this.fileCache[relativePath] = await file.text();
}
}
}
await this.initSync(writePrimitives);
runInAction(() => {
directoryPersistenceBox.set(this);
});
}
async initSync(writePrimitives: boolean) {
if (!writePrimitives) {
runInAction(() => {
const { textDocuments, sheetConfigs } = getStateFromFiles(
this.fileCache,
this.fileLastModified
);
if (textDocuments.length === 0) {
textDocuments.push({
id: generateNanoid(),
name: "Untitled",
text: Text.empty,
sheets: [],
});
}
textDocumentsMobx.replace(
new Map(
textDocuments.map((textDocument) => [textDocument.id, textDocument])
)
);
sheetConfigsMobx.replace(
new Map(
sheetConfigs.map((sheetConfig) => [sheetConfig.id, sheetConfig])
)
);
const textDocumentIds = textDocuments.map((d) => d.id);
if (
lastSelectedSyncDocumentId !== undefined &&
textDocumentIds.includes(lastSelectedSyncDocumentId)
) {
selectedTextDocumentIdBox.set(lastSelectedSyncDocumentId);
} else if (!textDocumentIds.includes(selectedTextDocumentIdBox.get())) {
selectedTextDocumentIdBox.set(textDocumentIds[0]);
}
});
}
this.unsubscribes = [
reaction(
() => {
return [...textDocumentsMobx.values()].map((textDocument) => {
return [
textDocument.id,
`${textDocument.name}\n${textDocument.text.toString()}`,
];
});
},
async (serializedDocuments) => {
for (const [id, contents] of serializedDocuments) {
await this.writeFile(
`/${id}.${TEXT_FILE_EXTENSION}`,
contents,
textDocumentsMobx.get(id)
);
}
},
{
equals: comparer.structural,
delay: 100,
fireImmediately: writePrimitives,
}
),
reaction(
() =>
[...textDocumentsMobx.values()].map((textDocument) => [
textDocument.id,
prettyStringify({ sheets: getDocumentSheetConfig(textDocument) }),
]),
async (documentSheetConfig) => {
for (const [id, contents] of documentSheetConfig) {
await this.writeFile(
`/${id}.${DOCUMENT_METADATA_EXTENSION}`,
contents
);
}
},
{
equals: comparer.structural,
delay: 100,
fireImmediately: writePrimitives,
}
),
reaction(
() => {
return [...sheetConfigsMobx.values()].map((sheetConfig) => {
return [sheetConfig.id, prettyStringify(sheetConfig)];
});
},
async (serializedSheetConfigs) => {
for (const [id, contents] of serializedSheetConfigs) {
await this.writeFile(
`/${id}.${HIGHLIGHTER_FILE_EXTENSION}`,
contents
);
}
},
{
equals: comparer.structural,
delay: 100,
fireImmediately: writePrimitives,
}
),
];
}
async writeFile(
filePath: string,
contents: string,
textDocument?: TextDocument
) {
if (this.fileCache[filePath] === contents) {
return;
}
if (
!filePath.endsWith(`.${HIGHLIGHTER_FILE_EXTENSION}`) &&
!filePath.endsWith(`.${TEXT_FILE_EXTENSION}`) &&
!filePath.endsWith(`.${DOCUMENT_METADATA_EXTENSION}`)
) {
throw new Error();
}
if (this.directoryHandle === undefined) {
throw new Error();
}
if (filePath.split("/").length !== 2 && filePath[0] === "/") {
throw new Error("only files in root directory are supported");
}
const fileName = filePath.substring(1);
let fileHandle = this.fileHandles[filePath];
if (fileHandle === undefined) {
fileHandle = await this.directoryHandle.getFileHandle(fileName, {
create: true,
});
this.fileHandles[filePath] = fileHandle;
}
await fileSave(
new Blob([contents], { type: "text/plain" }),
{},
fileHandle,
true
);
this.fileLastModified[filePath] = Date.now();
this.fileCache[filePath] = contents;
if (textDocument !== undefined) {
runInAction(() => {
textDocument.lastModified = Date.now();
});
}
}
destroy() {
for (const unsubscribe of this.unsubscribes) {
unsubscribe();
}
runInAction(() => {
directoryPersistenceBox.set(undefined);
});
}
}
export function FileDropWrapper({
children,
className,
}: {
children: React.ReactNode;
className: string;
}) {
const [toastMessage, setToastMessage] = useState<
[newDocumentNames: string[], newSheetConfigNames: string[]] | undefined
>(undefined);
return (
<div
onDragOver={(e) => {
e.preventDefault();
}}
onDrop={async (e) => {
e.preventDefault();
const newTextDocumentNames: string[] = [];
const newSheetConfigNames: string[] = [];
for (const file of e.dataTransfer.files) {
if (file.name.endsWith(TEXT_FILE_EXTENSION)) {
const fileText = await file.text();
const lines = fileText.split("\n");
runInAction(() => {
const id = generateNanoid();
textDocumentsMobx.set(id, {
id,
name: lines[0],
text: Text.of(lines.slice(1)),
sheets: [],
});
});
newTextDocumentNames.push(lines[0]);
} else if (file.name.endsWith(HIGHLIGHTER_FILE_EXTENSION)) {
const fileText = await file.text();
const id = generateNanoid();
const sheetConfig = { ...JSON.parse(fileText), id };
runInAction(() => {
sheetConfigsMobx.set(id, sheetConfig);
});
newSheetConfigNames.push(sheetConfig.name);
}
if (
newTextDocumentNames.length > 0 ||
newSheetConfigNames.length > 0
) {
setToastMessage([newTextDocumentNames, newSheetConfigNames]);
}
}
}}
className={className}
>
{children}
{toastMessage !== undefined ? (
<Toast.Root
duration={3000}
onOpenChange={(open) => {
if (!open) {
setToastMessage(undefined);
}
}}
className="w-80 text-sm bg-white border border-gray-200 p-4 rounded shadow-lg z-1"
>
<Toast.Title className="font-semibold pb-2">Files added!</Toast.Title>
<Toast.Description className="whitespace-pre-wrap">
{toastMessage[0].length > 0 ? (
<span>
{toastMessage[0].join(", ")} added as text documents.{" "}
</span>
) : null}
{toastMessage[1].length > 0 ? (
<span>{toastMessage[1].join(", ")} added as sheet configs.</span>
) : null}
</Toast.Description>
</Toast.Root>
) : null}
</div>
);
}
type SerializedDocumentSheet = {
id: string;
configId: string;
highlightSearchRange: Span | undefined;
hideHighlightsInDocument: boolean;
};
function getDocumentSheetConfig(
textDocument: TextDocument
): SerializedDocumentSheet[] {
return textDocument.sheets.map((documentSheet) => ({
id: documentSheet.id,
configId: documentSheet.configId,
highlightSearchRange: documentSheet.highlightSearchRange,
hideHighlightsInDocument: documentSheet.hideHighlightsInDocument || false,
}));
}
export function getStateFromFiles(
files: { [filePath: string]: string },
fileLastModified: { [filePath: string]: number }
): {
textDocuments: TextDocument[];
sheetConfigs: SheetConfig[];
} {
const sheetConfigs = Object.entries(files)
.filter(([filePath]) => filePath.endsWith(HIGHLIGHTER_FILE_EXTENSION))
.map(([filePath, contents]) => {
const id = getHighlighterId(filePath);
return JSON.parse(contents);
});
const documentSheets: {
[textDocumentId: string]: { sheets: TextDocumentSheet[] };
} = Object.fromEntries(
Object.entries(files)
.filter(([filePath]) => filePath.endsWith(DOCUMENT_METADATA_EXTENSION))
.map(([filePath, contents]) => {
const id = getTextDocumentIdFromMetadata(filePath);
return [id, JSON.parse(contents)];
})
);
const textDocuments: TextDocument[] = Object.entries(files)
.filter(([filePath]) => filePath.endsWith(TEXT_FILE_EXTENSION))
.map(([filePath, contents]) => {
const id = getTextDocumentId(filePath);
const lines = contents.split("\n");
const text = Text.of(
lines.length > 1 ? contents.split("\n").slice(1) : [""]
);
return {
id,
name: lines[0] ?? "",
text,
sheets: (documentSheets[id]?.sheets ?? [])
.filter((c) =>
sheetConfigs.some((config) => config.id === c.configId)
)
.map((c) => ({
id: c.id,
configId: c.configId,
highlightSearchRange: c.highlightSearchRange,
hideHighlightsInDocument: c.hideHighlightsInDocument || false,
})),
lastModified: fileLastModified[filePath],
};
});
return { textDocuments, sheetConfigs };
}