Spaces:
Sleeping
Sleeping
| import { promises as fs } from 'node:fs' | |
| import path from 'node:path' | |
| import { | |
| downloadFile, | |
| uploadFiles, | |
| repoExists, | |
| createRepo, | |
| listFiles, | |
| } from '@huggingface/hub' | |
| /** | |
| * Durable storage for decks. Each deck persists two artifacts: | |
| * decks/<id>.ydoc — binary Yjs snapshot (lossless reload) | |
| * decks/<id>.md — human-readable markdown (= version history in git) | |
| * | |
| * Two backends: local filesystem (dev) and a git-backed HF Dataset repo (prod, since | |
| * HF Space disks are ephemeral). Chosen by env at startup. | |
| */ | |
| export interface DeckStorage { | |
| readonly kind: string | |
| load(deckId: string): Promise<Uint8Array | null> | |
| save(deckId: string, state: Uint8Array, markdown: string): Promise<void> | |
| listDecks(): Promise<string[]> | |
| /** Generic small text files (e.g. per-user deck index at users/<id>.json). */ | |
| readText(path: string): Promise<string | null> | |
| writeText(path: string, content: string): Promise<void> | |
| } | |
| const safeId = (id: string): string => | |
| id.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64) || 'default' | |
| class LocalDeckStorage implements DeckStorage { | |
| readonly kind = 'local' | |
| constructor(private readonly dir: string) {} | |
| private file(id: string, ext: string) { | |
| return path.join(this.dir, safeId(id) + ext) | |
| } | |
| async load(deckId: string): Promise<Uint8Array | null> { | |
| try { | |
| return new Uint8Array(await fs.readFile(this.file(deckId, '.ydoc'))) | |
| } catch { | |
| return null | |
| } | |
| } | |
| async save(deckId: string, state: Uint8Array, markdown: string): Promise<void> { | |
| await fs.mkdir(this.dir, { recursive: true }) | |
| await fs.writeFile(this.file(deckId, '.ydoc'), Buffer.from(state)) | |
| await fs.writeFile(this.file(deckId, '.md'), markdown, 'utf8') | |
| } | |
| async listDecks(): Promise<string[]> { | |
| try { | |
| const names = await fs.readdir(this.dir) | |
| return names.filter((n) => n.endsWith('.md')).map((n) => n.replace(/\.md$/, '')) | |
| } catch { | |
| return [] | |
| } | |
| } | |
| // Generic files live next to the decks dir (base = parent of decks dir). | |
| private base() { | |
| return path.dirname(this.dir) | |
| } | |
| async readText(p: string): Promise<string | null> { | |
| try { | |
| return await fs.readFile(path.resolve(this.base(), p), 'utf8') | |
| } catch { | |
| return null | |
| } | |
| } | |
| async writeText(p: string, content: string): Promise<void> { | |
| const full = path.resolve(this.base(), p) | |
| await fs.mkdir(path.dirname(full), { recursive: true }) | |
| await fs.writeFile(full, content, 'utf8') | |
| } | |
| } | |
| class HfDeckStorage implements DeckStorage { | |
| readonly kind = 'hf-dataset' | |
| private readonly repo: { type: 'dataset'; name: string } | |
| constructor(repoName: string, private readonly token: string) { | |
| this.repo = { type: 'dataset', name: repoName } | |
| } | |
| async ensureRepo(): Promise<void> { | |
| try { | |
| if (!(await repoExists({ repo: this.repo, accessToken: this.token }))) { | |
| await createRepo({ repo: this.repo, accessToken: this.token, visibility: 'private' }) | |
| console.log(`[storage] created dataset repo ${this.repo.name}`) | |
| } | |
| } catch (e) { | |
| console.error('[storage] ensureRepo failed:', (e as Error)?.message) | |
| } | |
| } | |
| async load(deckId: string): Promise<Uint8Array | null> { | |
| try { | |
| const blob = await downloadFile({ | |
| repo: this.repo, | |
| path: `decks/${safeId(deckId)}.ydoc`, | |
| accessToken: this.token, | |
| }) | |
| if (!blob) return null | |
| return new Uint8Array(await blob.arrayBuffer()) | |
| } catch (e) { | |
| console.error('[storage] load failed:', (e as Error)?.message) | |
| return null | |
| } | |
| } | |
| async save(deckId: string, state: Uint8Array, markdown: string): Promise<void> { | |
| const id = safeId(deckId) | |
| await uploadFiles({ | |
| repo: this.repo, | |
| accessToken: this.token, | |
| commitTitle: `Update deck ${id}`, | |
| files: [ | |
| { path: `decks/${id}.ydoc`, content: new Blob([new Uint8Array(state)]) }, | |
| { path: `decks/${id}.md`, content: new Blob([new TextEncoder().encode(markdown)]) }, | |
| ], | |
| }) | |
| } | |
| async listDecks(): Promise<string[]> { | |
| try { | |
| const out: string[] = [] | |
| for await (const f of listFiles({ repo: this.repo, path: 'decks', accessToken: this.token })) { | |
| if (f.path.endsWith('.md')) { | |
| out.push(f.path.replace(/^decks\//, '').replace(/\.md$/, '')) | |
| } | |
| } | |
| return out | |
| } catch { | |
| return [] | |
| } | |
| } | |
| async readText(p: string): Promise<string | null> { | |
| try { | |
| const blob = await downloadFile({ repo: this.repo, path: p, accessToken: this.token }) | |
| return blob ? await blob.text() : null | |
| } catch { | |
| return null | |
| } | |
| } | |
| async writeText(p: string, content: string): Promise<void> { | |
| await uploadFiles({ | |
| repo: this.repo, | |
| accessToken: this.token, | |
| commitTitle: `Update ${p}`, | |
| files: [{ path: p, content: new Blob([new TextEncoder().encode(content)]) }], | |
| }) | |
| } | |
| } | |
| export function createStorage(): DeckStorage { | |
| const repo = process.env.HF_DATASET_REPO | |
| const token = process.env.HF_TOKEN | |
| if (repo && token) { | |
| const storage = new HfDeckStorage(repo, token) | |
| void storage.ensureRepo() | |
| console.log(`[storage] using HF dataset: ${repo}`) | |
| return storage | |
| } | |
| const dir = process.env.DATA_DIR || './data/decks' | |
| console.log(`[storage] using local filesystem: ${dir}`) | |
| return new LocalDeckStorage(dir) | |
| } | |