259 lines
7.5 KiB
TypeScript
259 lines
7.5 KiB
TypeScript
import Dexie, { type Table } from "dexie";
|
|
import type {
|
|
AISession,
|
|
Board,
|
|
Book,
|
|
Chapter,
|
|
DocumentRef,
|
|
Entity,
|
|
NewProject,
|
|
PlotBeat,
|
|
Project,
|
|
ProjectMeta,
|
|
} from "./types.js";
|
|
import type { StorageAdapter } from "./storage.js";
|
|
import { createUlid } from "./ids.js";
|
|
import { nowIso } from "./time.js";
|
|
import { fuzzySort } from "./fuzzy.js";
|
|
|
|
interface EntityRec {
|
|
key: string;
|
|
projectId: string;
|
|
kind: string;
|
|
data: Entity;
|
|
}
|
|
|
|
class PubScriptDB extends Dexie {
|
|
projects!: Table<Project, string>;
|
|
books!: Table<Book, string>;
|
|
chapters!: Table<Chapter, string>;
|
|
entities!: Table<EntityRec, string>;
|
|
boards!: Table<Board, string>;
|
|
plotBeats!: Table<PlotBeat, string>;
|
|
aiSessions!: Table<AISession, string>;
|
|
|
|
constructor() {
|
|
super("pubscript");
|
|
this.version(1).stores({
|
|
projects: "id, projectId",
|
|
books: "id, projectId",
|
|
chapters: "id, bookId, projectId",
|
|
entities: "key, projectId, kind",
|
|
boards: "id, projectId",
|
|
// Plot beats — atomic units of story structure (CHUNK #5).
|
|
plotBeats: "id, [projectId+act], projectId, chapterId",
|
|
aiSessions: "id, projectId",
|
|
});
|
|
}
|
|
}
|
|
|
|
const db = new PubScriptDB();
|
|
|
|
const entityKey = (projectId: string, kind: string, id: string): string =>
|
|
`${projectId}|${kind}|${id}`;
|
|
const kindOf = (e: Entity): string =>
|
|
"fields" in e ? "world" : "body" in e ? "note" : "character";
|
|
|
|
/**
|
|
* Concatenate every human-readable field of an entity into a single searchable
|
|
* string (name + field values). Used by the fuzzy search index.
|
|
*/
|
|
function entitySearchText(e: Entity): string {
|
|
if ("fields" in e) {
|
|
// WorldItem
|
|
const parts = [e.name, e.kind];
|
|
for (const v of Object.values(e.fields)) if (typeof v === "string") parts.push(v);
|
|
return parts.join(" ");
|
|
}
|
|
if ("body" in e) {
|
|
// Note
|
|
return [e.body, ...e.tags].join(" ");
|
|
}
|
|
// Character
|
|
return [
|
|
e.name,
|
|
e.role,
|
|
e.bio,
|
|
e.appearance,
|
|
e.voice,
|
|
e.relationships,
|
|
e.arc,
|
|
e.status,
|
|
...e.tags,
|
|
].join(" ");
|
|
}
|
|
|
|
/**
|
|
* The local (offline-first) `StorageAdapter`, backed by IndexedDB via Dexie.
|
|
* Implements the exact `StorageAdapter` contract so the app can run fully
|
|
* offline and sync later. `onEvent` is a no-op offline (no remote peers).
|
|
*/
|
|
export const storageLocal: StorageAdapter = {
|
|
async listProjects(): Promise<ProjectMeta[]> {
|
|
const rows = await db.projects.toArray();
|
|
return rows.map(
|
|
({ id, projectId, title, ownerId, sharedWith, settings, cover, createdAt, updatedAt }) => ({
|
|
id, projectId, title, ownerId, sharedWith, settings, cover, createdAt, updatedAt,
|
|
perm: "edit" as const, owned: true,
|
|
}),
|
|
);
|
|
},
|
|
|
|
async createProject(p: NewProject): Promise<Project> {
|
|
const id = createUlid();
|
|
const t = nowIso();
|
|
const project: Project = {
|
|
id,
|
|
projectId: id,
|
|
title: p.title,
|
|
ownerId: "local",
|
|
sharedWith: [],
|
|
settings: { language: p.language ?? "en" },
|
|
cover: { color: p.cover?.color ?? "#8b5cf6", ...(p.cover?.image ? { image: p.cover.image } : {}) },
|
|
createdAt: t,
|
|
updatedAt: t,
|
|
};
|
|
await db.projects.put(project);
|
|
return project;
|
|
},
|
|
|
|
async getProject(id: string): Promise<Project> {
|
|
const p = await db.projects.get(id);
|
|
if (!p) throw new Error(`project ${id} not found`);
|
|
return p;
|
|
},
|
|
|
|
async deleteProject(id: string): Promise<void> {
|
|
await db.projects.delete(id);
|
|
await db.books.where("projectId").equals(id).delete();
|
|
await db.chapters.where("projectId").equals(id).delete();
|
|
await db.entities.where("projectId").equals(id).delete();
|
|
await db.boards.where("projectId").equals(id).delete();
|
|
await db.plotBeats.where("projectId").equals(id).delete();
|
|
await db.aiSessions.where("projectId").equals(id).delete();
|
|
},
|
|
|
|
async createBook(b: { projectId: string; title: string }): Promise<Book> {
|
|
const id = createUlid();
|
|
const book: Book = { id, projectId: b.projectId, title: b.title, order: 0, chapters: [] };
|
|
await db.books.put(book);
|
|
return book;
|
|
},
|
|
|
|
async listBooks(projectId: string): Promise<Book[]> {
|
|
const rows = await db.books.where("projectId").equals(projectId).toArray();
|
|
return rows.sort((a, b) => a.order - b.order);
|
|
},
|
|
|
|
async listChapters(bookId: string): Promise<Chapter[]> {
|
|
const rows = await db.chapters.where("bookId").equals(bookId).toArray();
|
|
return rows.sort((a, b) => a.order - b.order);
|
|
},
|
|
|
|
async deleteChapter(id: string): Promise<void> {
|
|
await db.chapters.delete(id);
|
|
},
|
|
|
|
async saveDocument(doc: DocumentRef): Promise<void> {
|
|
const existing = await db.chapters.get(doc.id);
|
|
const existingCount =
|
|
!existing && doc.bookId
|
|
? await db.chapters.where("bookId").equals(doc.bookId).count()
|
|
: 0;
|
|
const base: Chapter = existing ?? {
|
|
id: doc.id,
|
|
bookId: doc.bookId ?? "",
|
|
projectId: doc.projectId,
|
|
title: doc.title,
|
|
doc: doc.doc,
|
|
rev: 0,
|
|
order: existingCount,
|
|
meta: { wordCount: 0, scenes: [], updatedAt: nowIso() },
|
|
};
|
|
await db.chapters.put({
|
|
...base,
|
|
title: doc.title,
|
|
doc: doc.doc,
|
|
rev: doc.rev + 1,
|
|
meta: { ...base.meta, updatedAt: nowIso() },
|
|
});
|
|
},
|
|
|
|
async getChapter(id: string): Promise<Chapter | null> {
|
|
return (await db.chapters.get(id)) ?? null;
|
|
},
|
|
|
|
async saveEntity(e: Entity): Promise<void> {
|
|
const kind = kindOf(e);
|
|
await db.entities.put({ key: entityKey(e.projectId, kind, e.id), projectId: e.projectId, kind, data: e });
|
|
},
|
|
|
|
async listEntities(projectId: string, kind?: string): Promise<Entity[]> {
|
|
const rows = await db.entities
|
|
.where("projectId")
|
|
.equals(projectId)
|
|
.toArray();
|
|
const filtered = kind ? rows.filter((r) => r.kind === kind) : rows;
|
|
return filtered.map((r) => r.data);
|
|
},
|
|
|
|
async getEntity(projectId: string, kind: string, id: string): Promise<Entity | null> {
|
|
const row = await db.entities.get(entityKey(projectId, kind, id));
|
|
return row ? row.data : null;
|
|
},
|
|
|
|
async deleteEntity(projectId: string, kind: string, id: string): Promise<void> {
|
|
await db.entities.delete(entityKey(projectId, kind, id));
|
|
},
|
|
|
|
async searchEntities(projectId: string, q: string): Promise<Entity[]> {
|
|
const all = await this.listEntities(projectId);
|
|
if (!q) return all;
|
|
return fuzzySort(all, q, entitySearchText);
|
|
},
|
|
|
|
async saveBoard(b: Board): Promise<void> {
|
|
await db.boards.put(b);
|
|
},
|
|
|
|
async getBoard(projectId: string): Promise<Board | null> {
|
|
const rows = await db.boards
|
|
.where("projectId")
|
|
.equals(projectId)
|
|
.toArray();
|
|
return rows[0] ?? null;
|
|
},
|
|
|
|
async listPlotBeats(projectId: string): Promise<PlotBeat[]> {
|
|
const rows = await db.plotBeats
|
|
.where("projectId")
|
|
.equals(projectId)
|
|
.toArray();
|
|
// Stable order: by act, then order within act.
|
|
return rows.sort((a, b) => a.act - b.act || a.order - b.order);
|
|
},
|
|
|
|
async savePlotBeat(b: PlotBeat): Promise<void> {
|
|
await db.plotBeats.put(b);
|
|
},
|
|
|
|
async deletePlotBeat(projectId: string, id: string): Promise<void> {
|
|
const existing = await db.plotBeats.get(id);
|
|
if (!existing || existing.projectId !== projectId) {
|
|
throw new Error(`No plot beat ${id} in project ${projectId}.`);
|
|
}
|
|
await db.plotBeats.delete(id);
|
|
},
|
|
|
|
async saveAISession(s: AISession): Promise<void> {
|
|
await db.aiSessions.put(s);
|
|
},
|
|
|
|
onEvent(): () => void {
|
|
// Offline: no remote live events to relay.
|
|
return () => {};
|
|
},
|
|
};
|
|
|
|
export type StorageLocal = typeof storageLocal;
|