marp-share / server /src /storage.ts
Nipun's picture
Multi-deck: unique presentation IDs + per-user list + sharing
2cd1b66 verified
Raw
History Blame Contribute Delete
5.36 kB
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)
}