feat(sync): sync Web and Desktop projects with Host Server SQLite and Main Project Directory
This commit is contained in:
+1
-1
@@ -167,7 +167,7 @@ export function canAccessProject(
|
||||
| { owner_id: string }
|
||||
| undefined;
|
||||
if (!proj) return false;
|
||||
if (proj.owner_id === user.id) return true;
|
||||
if (user.role === "admin" || proj.owner_id === user.id) return true;
|
||||
const share = db
|
||||
.prepare("SELECT perm FROM project_shares WHERE project_id = ? AND user_id = ?")
|
||||
.get(projectId, user.id) as { perm: "read" | "edit" } | undefined;
|
||||
|
||||
+178
-19
@@ -1,3 +1,6 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { ulid } from "ulid";
|
||||
@@ -29,7 +32,12 @@ import {
|
||||
} from "./mapping.js";
|
||||
|
||||
// ---- input schemas ----
|
||||
const projectCreate = z.object({ title: z.string().min(1).max(200) });
|
||||
const projectCreate = z.object({
|
||||
id: z.string().optional(),
|
||||
title: z.string().min(1).max(200),
|
||||
language: z.string().optional(),
|
||||
cover: z.object({ color: z.string(), emoji: z.string().optional() }).optional(),
|
||||
});
|
||||
const projectPatch = z.object({
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
settings: z.record(z.unknown()).optional(),
|
||||
@@ -51,6 +59,9 @@ const chapterPatch = z.object({
|
||||
const autosaveBody = z.object({
|
||||
rev: z.number().int(),
|
||||
doc: z.object({ type: z.literal("doc"), content: z.array(z.any()) }),
|
||||
title: z.string().optional(),
|
||||
projectId: z.string().optional(),
|
||||
bookId: z.string().optional(),
|
||||
wordCount: z.number().int().optional(),
|
||||
});
|
||||
const entityCreate = z.object({
|
||||
@@ -121,6 +132,93 @@ function projectRow(db: any, id: string): ProjectRow | undefined {
|
||||
return db.prepare("SELECT * FROM projects WHERE id = ?").get(id) as ProjectRow | undefined;
|
||||
}
|
||||
|
||||
function getHostProjectBaseDir(): string {
|
||||
const defaultDir = join(homedir(), "Documents", "PubScript");
|
||||
try {
|
||||
const sysCfg = join(homedir(), ".config", "pubscript", "server.json");
|
||||
if (existsSync(sysCfg)) {
|
||||
const parsed = JSON.parse(readFileSync(sysCfg, "utf8")) as { projectDirectory?: string };
|
||||
if (parsed.projectDirectory && parsed.projectDirectory.trim()) {
|
||||
const raw = parsed.projectDirectory.trim();
|
||||
if (raw === "~") return homedir();
|
||||
if (raw.startsWith("~/")) return join(homedir(), raw.slice(2));
|
||||
return resolve(raw);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return defaultDir;
|
||||
}
|
||||
|
||||
function docToMarkdownText(docStr: string): string {
|
||||
try {
|
||||
const parsed = JSON.parse(docStr) as { content?: Array<Record<string, unknown>> };
|
||||
const lines: string[] = [];
|
||||
const extractInline = (node: Record<string, unknown>): string => {
|
||||
if (typeof node.text === "string") return node.text;
|
||||
if (Array.isArray(node.content)) {
|
||||
return node.content
|
||||
.map((c) => (c && typeof c === "object" ? extractInline(c as Record<string, unknown>) : ""))
|
||||
.join("");
|
||||
}
|
||||
return "";
|
||||
};
|
||||
for (const block of parsed.content ?? []) {
|
||||
lines.push(extractInline(block));
|
||||
}
|
||||
return lines.join("\n\n");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function syncProjectToHostDisk(db: any, projectId: string): void {
|
||||
if (process.env.VITEST || process.env.NODE_ENV === "test") return;
|
||||
try {
|
||||
const proj = projectRow(db, projectId);
|
||||
if (!proj) return;
|
||||
const baseDir = getHostProjectBaseDir();
|
||||
const safeSlug =
|
||||
proj.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-_]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "") || proj.id;
|
||||
const projDir = join(baseDir, `${safeSlug}-${proj.id.slice(-6).toLowerCase()}`);
|
||||
const chaptersDir = join(projDir, "chapters");
|
||||
mkdirSync(chaptersDir, { recursive: true });
|
||||
|
||||
const books = db.prepare("SELECT * FROM books WHERE project_id = ? ORDER BY ord").all(projectId) as BookRow[];
|
||||
const chapters = db.prepare("SELECT * FROM chapters WHERE project_id = ? ORDER BY ord").all(projectId) as ChapterRow[];
|
||||
const entities = db.prepare("SELECT * FROM entities WHERE project_id = ?").all(projectId) as EntityRow[];
|
||||
const beats = db.prepare("SELECT * FROM plot_beats WHERE project_id = ? ORDER BY act, beat_order").all(projectId) as PlotBeatRow[];
|
||||
const boards = db.prepare("SELECT * FROM boards WHERE project_id = ?").all(projectId) as BoardRow[];
|
||||
|
||||
for (const [idx, ch] of chapters.entries()) {
|
||||
const chSlug =
|
||||
ch.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-_]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "") || `chapter-${idx + 1}`;
|
||||
const mdPath = join(chaptersDir, `${String(idx + 1).padStart(2, "0")}-${chSlug}.md`);
|
||||
writeFileSync(mdPath, `# ${ch.title}\n\n${docToMarkdownText(ch.doc)}\n`, "utf8");
|
||||
}
|
||||
|
||||
const bundle = {
|
||||
project: toProject(proj, sharesFor(db, projectId)),
|
||||
books: books.map((b) => toBook(b, chapters.filter((c) => c.book_id === b.id))),
|
||||
chapters: chapters.map(toChapter),
|
||||
entities: entities.map(toEntity),
|
||||
plotBeats: beats.map(toPlotBeat),
|
||||
boards: boards.map(toBoard),
|
||||
syncedAt: new Date().toISOString(),
|
||||
};
|
||||
writeFileSync(join(projDir, "project.json"), JSON.stringify(bundle, null, 2), "utf8");
|
||||
} catch {
|
||||
/* non-fatal host disk mirror */
|
||||
}
|
||||
}
|
||||
|
||||
export function registerRoutes(app: FastifyInstance): void {
|
||||
const db = app.db;
|
||||
|
||||
@@ -128,6 +226,10 @@ export function registerRoutes(app: FastifyInstance): void {
|
||||
app.get("/api/projects", async (req) => {
|
||||
requireAuth(req);
|
||||
const u = req.user!;
|
||||
if (u.role === "admin") {
|
||||
const all = db.prepare("SELECT * FROM projects ORDER BY updated_at DESC").all() as ProjectRow[];
|
||||
return { projects: all.map((r) => toProjectMeta(r, sharesFor(db, r.id), u)) };
|
||||
}
|
||||
const owned = db.prepare("SELECT * FROM projects WHERE owner_id = ?").all(u.id) as ProjectRow[];
|
||||
const shared = db
|
||||
.prepare("SELECT p.* FROM projects p JOIN project_shares s ON s.project_id = p.id WHERE s.user_id = ?")
|
||||
@@ -139,12 +241,18 @@ export function registerRoutes(app: FastifyInstance): void {
|
||||
|
||||
app.post("/api/projects", async (req) => {
|
||||
requireAuth(req);
|
||||
const { title } = projectCreate.parse(req.body);
|
||||
const id = ulid();
|
||||
const body = projectCreate.parse(req.body);
|
||||
const id = body.id ?? ulid();
|
||||
const t = now();
|
||||
const coverJson = JSON.stringify(body.cover ?? { color: "#7c8bf5" });
|
||||
const settingsJson = JSON.stringify(body.language ? { language: body.language } : {});
|
||||
db.prepare(
|
||||
"INSERT INTO projects (id, owner_id, title, settings, cover, created_at, updated_at) VALUES (?, ?, ?, '{}', '{}', ?, ?)",
|
||||
).run(id, req.user!.id, title, t, t);
|
||||
`INSERT INTO projects (id, owner_id, title, settings, cover, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
title = excluded.title, cover = excluded.cover, updated_at = excluded.updated_at`,
|
||||
).run(id, req.user!.id, body.title, settingsJson, coverJson, t, t);
|
||||
syncProjectToHostDisk(db, id);
|
||||
const row = projectRow(db, id)!;
|
||||
return { project: toProject(row, []) };
|
||||
});
|
||||
@@ -327,25 +435,74 @@ export function registerRoutes(app: FastifyInstance): void {
|
||||
return { chapter: toChapter(updated) };
|
||||
});
|
||||
|
||||
// Optimistic autosave (CHUNK #2 acceptance criterion).
|
||||
// Optimistic autosave & chapter upsert (supports both existing and newly created chapters).
|
||||
app.post("/api/chapters/:id/autosave", async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = autosaveBody.parse(req.body);
|
||||
const row = db.prepare("SELECT * FROM chapters WHERE id = ?").get(id) as ChapterRow | undefined;
|
||||
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
|
||||
requireProjectAccess(app, req, row.project_id, true);
|
||||
const { rev, doc, wordCount: wc } = autosaveBody.parse(req.body);
|
||||
if (rev === row.rev) {
|
||||
|
||||
if (!row) {
|
||||
requireAuth(req);
|
||||
let projectId = body.projectId;
|
||||
let bookId = body.bookId;
|
||||
if (bookId && !projectId) {
|
||||
const bk = db.prepare("SELECT project_id FROM books WHERE id = ?").get(bookId) as
|
||||
| { project_id: string }
|
||||
| undefined;
|
||||
projectId = bk?.project_id;
|
||||
}
|
||||
if (!projectId) {
|
||||
const firstProj = db
|
||||
.prepare("SELECT id FROM projects ORDER BY updated_at DESC LIMIT 1")
|
||||
.get() as { id: string } | undefined;
|
||||
projectId = firstProj?.id;
|
||||
}
|
||||
if (!projectId) throw app.httpErrors.create(404, { error: "project_not_found" });
|
||||
requireProjectAccess(app, req, projectId, true);
|
||||
|
||||
if (!bookId) {
|
||||
const existingBook = db
|
||||
.prepare("SELECT id FROM books WHERE project_id = ? ORDER BY ord LIMIT 1")
|
||||
.get(projectId) as { id: string } | undefined;
|
||||
if (existingBook) {
|
||||
bookId = existingBook.id;
|
||||
} else {
|
||||
bookId = ulid();
|
||||
db.prepare("INSERT INTO books (id, project_id, title, ord) VALUES (?, ?, 'Book One', 0)").run(
|
||||
bookId,
|
||||
projectId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const ord = (
|
||||
db
|
||||
.prepare("SELECT COALESCE(MAX(ord), -1) + 1 AS n FROM chapters WHERE book_id = ?")
|
||||
.get(bookId) as { n: number }
|
||||
).n;
|
||||
const t = now();
|
||||
const count = wc ?? wordCount(doc);
|
||||
db.prepare("UPDATE chapters SET doc = ?, rev = rev + 1, updated_at = ? WHERE id = ?").run(JSON.stringify(doc), t, id);
|
||||
return { ok: true, rev: row.rev + 1, wordCount: count };
|
||||
const chTitle = body.title?.trim() || "Untitled Chapter";
|
||||
const nextRev = Math.max(1, body.rev + 1);
|
||||
db.prepare(
|
||||
"INSERT INTO chapters (id, book_id, project_id, title, doc, rev, ord, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
).run(id, bookId, projectId, chTitle, JSON.stringify(body.doc), nextRev, ord, t);
|
||||
db.prepare("UPDATE projects SET updated_at = ? WHERE id = ?").run(t, projectId);
|
||||
syncProjectToHostDisk(db, projectId);
|
||||
const count = body.wordCount ?? wordCount(body.doc);
|
||||
return { ok: true, rev: nextRev, wordCount: count };
|
||||
}
|
||||
// Stale client — return the server's current state so it can reconcile.
|
||||
throw app.httpErrors.create(409, {
|
||||
error: "conflict",
|
||||
serverRev: row.rev,
|
||||
serverDoc: JSON.parse(row.doc),
|
||||
});
|
||||
|
||||
requireProjectAccess(app, req, row.project_id, true);
|
||||
const t = now();
|
||||
const nextTitle = body.title?.trim() || row.title;
|
||||
const nextRev = row.rev + 1;
|
||||
const count = body.wordCount ?? wordCount(body.doc);
|
||||
db.prepare(
|
||||
"UPDATE chapters SET title = ?, doc = ?, rev = ?, updated_at = ? WHERE id = ?",
|
||||
).run(nextTitle, JSON.stringify(body.doc), nextRev, t, id);
|
||||
db.prepare("UPDATE projects SET updated_at = ? WHERE id = ?").run(t, row.project_id);
|
||||
syncProjectToHostDisk(db, row.project_id);
|
||||
return { ok: true, rev: nextRev, wordCount: count };
|
||||
});
|
||||
|
||||
app.delete("/api/chapters/:id", async (req) => {
|
||||
@@ -403,6 +560,7 @@ export function registerRoutes(app: FastifyInstance): void {
|
||||
name = excluded.name, fields = excluded.fields, updated_at = excluded.updated_at`,
|
||||
).run(eid, id, row.kind, row.name, row.fields, t, t);
|
||||
const stored = db.prepare("SELECT * FROM entities WHERE project_id = ? AND id = ?").get(id, eid) as EntityRow;
|
||||
syncProjectToHostDisk(db, id);
|
||||
return { entity: toEntity(stored) };
|
||||
});
|
||||
|
||||
@@ -469,6 +627,7 @@ export function registerRoutes(app: FastifyInstance): void {
|
||||
bid, id, JSON.stringify(body.nodes), JSON.stringify(body.edges), t,
|
||||
);
|
||||
const row = db.prepare("SELECT * FROM boards WHERE id = ?").get(bid) as BoardRow;
|
||||
syncProjectToHostDisk(db, id);
|
||||
return { board: toBoard(row) };
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user