feat(sync): sync Web and Desktop projects with Host Server SQLite and Main Project Directory
This commit is contained in:
+16
-16
@@ -190,6 +190,18 @@ function appendLocalAudit(action: string, detail: Record<string, unknown>, userI
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildHostEndpoints(path: string, portHint?: number): string[] {
|
||||||
|
const list = [path];
|
||||||
|
if (portHint && typeof window !== "undefined" && window.location) {
|
||||||
|
const host = window.location.hostname || "localhost";
|
||||||
|
const proto = window.location.protocol === "https:" ? "https:" : "http:";
|
||||||
|
if (String(portHint) !== window.location.port) {
|
||||||
|
list.push(`${proto}//${host}:${portHint}${path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
function authHeaders(extra?: Record<string, string>): Record<string, string> {
|
function authHeaders(extra?: Record<string, string>): Record<string, string> {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
return {
|
return {
|
||||||
@@ -602,10 +614,7 @@ export const api = {
|
|||||||
startOnBoot: boolean;
|
startOnBoot: boolean;
|
||||||
projectDirectory?: string;
|
projectDirectory?: string;
|
||||||
}> => {
|
}> => {
|
||||||
const endpoints = [
|
const endpoints = buildHostEndpoints("/api/system/server-config", portHint);
|
||||||
"/api/system/server-config",
|
|
||||||
...(portHint ? [`http://localhost:${portHint}/api/system/server-config`] : []),
|
|
||||||
];
|
|
||||||
for (const url of endpoints) {
|
for (const url of endpoints) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
@@ -679,10 +688,7 @@ export const api = {
|
|||||||
startOnBoot: boolean;
|
startOnBoot: boolean;
|
||||||
projectDirectory?: string;
|
projectDirectory?: string;
|
||||||
}> => {
|
}> => {
|
||||||
const endpoints = [
|
const endpoints = buildHostEndpoints("/api/system/server-config", portHint);
|
||||||
"/api/system/server-config",
|
|
||||||
...(portHint ? [`http://localhost:${portHint}/api/system/server-config`] : []),
|
|
||||||
];
|
|
||||||
for (const url of endpoints) {
|
for (const url of endpoints) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
@@ -746,10 +752,7 @@ export const api = {
|
|||||||
directories: { name: string; path: string }[];
|
directories: { name: string; path: string }[];
|
||||||
}> => {
|
}> => {
|
||||||
const query = dirPath ? `?path=${encodeURIComponent(dirPath)}` : "";
|
const query = dirPath ? `?path=${encodeURIComponent(dirPath)}` : "";
|
||||||
const endpoints = [
|
const endpoints = buildHostEndpoints(`/api/system/list-dirs${query}`, portHint);
|
||||||
`/api/system/list-dirs${query}`,
|
|
||||||
...(portHint ? [`http://localhost:${portHint}/api/system/list-dirs${query}`] : []),
|
|
||||||
];
|
|
||||||
for (const url of endpoints) {
|
for (const url of endpoints) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
@@ -804,10 +807,7 @@ export const api = {
|
|||||||
}
|
}
|
||||||
const base64Data = btoa(binary);
|
const base64Data = btoa(binary);
|
||||||
|
|
||||||
const endpoints = [
|
const endpoints = buildHostEndpoints("/api/system/save-export", portHint);
|
||||||
"/api/system/save-export",
|
|
||||||
...(portHint ? [`http://localhost:${portHint}/api/system/save-export`] : []),
|
|
||||||
];
|
|
||||||
for (const url of endpoints) {
|
for (const url of endpoints) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import React, { createContext, useCallback, useContext, useEffect, useMemo, useS
|
|||||||
import type { User } from "@pubscript/core";
|
import type { User } from "@pubscript/core";
|
||||||
import { Badge, Button, toast } from "@pubscript/ui";
|
import { Badge, Button, toast } from "@pubscript/ui";
|
||||||
import { api } from "../api.js";
|
import { api } from "../api.js";
|
||||||
|
import { useProjectStore } from "../store/project-store.js";
|
||||||
|
import { useSettings } from "../store/settings.js";
|
||||||
|
|
||||||
interface AuthContextValue {
|
interface AuthContextValue {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
@@ -77,6 +79,23 @@ export function AuthGate({ children }: { children: React.ReactNode }) {
|
|||||||
return () => window.removeEventListener("focus", onFocus);
|
return () => window.removeEventListener("focus", onFocus);
|
||||||
}, [refreshAuth]);
|
}, [refreshAuth]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (authenticated) {
|
||||||
|
void useProjectStore.getState().init();
|
||||||
|
void api.getServerConfig().then((cfg) => {
|
||||||
|
if (cfg.online && cfg.projectDirectory) {
|
||||||
|
useSettings.getState().set({
|
||||||
|
projectDirectory: cfg.projectDirectory,
|
||||||
|
...(cfg.host ? { serverHost: cfg.host } : {}),
|
||||||
|
...(cfg.allowedHosts ? { allowedHosts: cfg.allowedHosts } : {}),
|
||||||
|
...(cfg.port ? { serverPort: cfg.port } : {}),
|
||||||
|
...(cfg.webPort ? { webPort: cfg.webPort } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [authenticated]);
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
await api.logout();
|
await api.logout();
|
||||||
setAuthenticated(false);
|
setAuthenticated(false);
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export function ExportDialog({ open, onClose }: ExportDialogProps) {
|
|||||||
const [chapterId, setChapterId] = useState<string>("");
|
const [chapterId, setChapterId] = useState<string>("");
|
||||||
const [fileName, setFileName] = useState<string>("");
|
const [fileName, setFileName] = useState<string>("");
|
||||||
const [saveDir, setSaveDir] = useState<string>(projectDirectory || "~/Documents/PubScript");
|
const [saveDir, setSaveDir] = useState<string>(projectDirectory || "~/Documents/PubScript");
|
||||||
|
const [alsoDownloadBrowser, setAlsoDownloadBrowser] = useState<boolean>(false);
|
||||||
const [browseOpen, setBrowseOpen] = useState<boolean>(false);
|
const [browseOpen, setBrowseOpen] = useState<boolean>(false);
|
||||||
const [books, setBooks] = useState<{ id: string; title: string }[]>([]);
|
const [books, setBooks] = useState<{ id: string; title: string }[]>([]);
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
@@ -138,7 +139,14 @@ export function ExportDialog({ open, onClose }: ExportDialogProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (saved.savedOnServer && saved.savedPath) {
|
if (saved.savedOnServer && saved.savedPath) {
|
||||||
toast.success(`Exported to ${saved.savedPath}`);
|
if (alsoDownloadBrowser) {
|
||||||
|
downloadFile(
|
||||||
|
finalFileName,
|
||||||
|
data,
|
||||||
|
formatInfo?.mime ?? "application/octet-stream",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
toast.success(`Saved on host machine: ${saved.savedPath}`);
|
||||||
} else {
|
} else {
|
||||||
downloadFile(
|
downloadFile(
|
||||||
finalFileName,
|
finalFileName,
|
||||||
@@ -239,7 +247,7 @@ export function ExportDialog({ open, onClose }: ExportDialogProps) {
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="ps-export-field" style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
<div className="ps-export-field" style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||||
<span style={{ fontSize: 12.5, fontWeight: 600 }}>Save Directory</span>
|
<span style={{ fontSize: 12.5, fontWeight: 600 }}>Save Directory on Host Machine</span>
|
||||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -256,9 +264,27 @@ export function ExportDialog({ open, onClose }: ExportDialogProps) {
|
|||||||
onClick={() => setBrowseOpen(true)}
|
onClick={() => setBrowseOpen(true)}
|
||||||
disabled={exporting}
|
disabled={exporting}
|
||||||
>
|
>
|
||||||
📁 Browse…
|
📁 Browse Host…
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--ps-text-muted)",
|
||||||
|
marginTop: 4,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={alsoDownloadBrowser}
|
||||||
|
onChange={(e) => setAlsoDownloadBrowser(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Also download a copy to this browser device</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import {
|
import {
|
||||||
createAutosave,
|
createAutosave,
|
||||||
createPlotBeat,
|
createPlotBeat,
|
||||||
|
createServerClient,
|
||||||
type AutosaveState,
|
type AutosaveState,
|
||||||
type Board,
|
type Board,
|
||||||
type Chapter,
|
type Chapter,
|
||||||
@@ -24,6 +25,8 @@ import {
|
|||||||
import { storageLocal } from "@pubscript/core/local";
|
import { storageLocal } from "@pubscript/core/local";
|
||||||
import { createUlid } from "@pubscript/core";
|
import { createUlid } from "@pubscript/core";
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
|
import { getStoredToken } from "../api.js";
|
||||||
|
import { useSettings } from "./settings.js";
|
||||||
import { seedDemoProject } from "./seed.js";
|
import { seedDemoProject } from "./seed.js";
|
||||||
import { PLOT_TEMPLATES } from "../features/plot/templates.js";
|
import { PLOT_TEMPLATES } from "../features/plot/templates.js";
|
||||||
|
|
||||||
@@ -295,6 +298,78 @@ export const useProjectStore = create<ProjectStore>((set, get) => {
|
|||||||
return stats;
|
return stats;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function migrateLocalToHostIfEmpty(serverAdapter: StorageAdapter): Promise<ProjectMeta[]> {
|
||||||
|
let hostProjects = await serverAdapter.listProjects();
|
||||||
|
if (hostProjects.length > 0) return hostProjects;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const localProjects = await storageLocal.listProjects();
|
||||||
|
if (localProjects.length > 0) {
|
||||||
|
for (const lp of localProjects) {
|
||||||
|
const created = await serverAdapter.createProject({
|
||||||
|
title: lp.title,
|
||||||
|
cover: lp.cover,
|
||||||
|
});
|
||||||
|
const localBooks = await storageLocal.listBooks(lp.id);
|
||||||
|
for (const lb of localBooks) {
|
||||||
|
const createdBook = await serverAdapter.createBook({
|
||||||
|
projectId: created.id,
|
||||||
|
title: lb.title,
|
||||||
|
});
|
||||||
|
const localChapters = await storageLocal.listChapters(lb.id);
|
||||||
|
for (const lch of localChapters) {
|
||||||
|
await serverAdapter.saveDocument({
|
||||||
|
id: lch.id,
|
||||||
|
projectId: created.id,
|
||||||
|
bookId: createdBook.id,
|
||||||
|
title: lch.title,
|
||||||
|
doc: lch.doc,
|
||||||
|
rev: Math.max(0, (lch.rev ?? 1) - 1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const localEntities = await storageLocal.listEntities(lp.id);
|
||||||
|
for (const le of localEntities) {
|
||||||
|
await serverAdapter.saveEntity({ ...le, projectId: created.id });
|
||||||
|
}
|
||||||
|
const localBeats = await storageLocal.listPlotBeats(lp.id);
|
||||||
|
for (const lbeat of localBeats) {
|
||||||
|
await serverAdapter.savePlotBeat({ ...lbeat, projectId: created.id });
|
||||||
|
}
|
||||||
|
const localBoard = await storageLocal.getBoard(lp.id);
|
||||||
|
if (localBoard) {
|
||||||
|
await serverAdapter.saveBoard({ ...localBoard, projectId: created.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hostProjects = await serverAdapter.listProjects();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore local migration errors */
|
||||||
|
}
|
||||||
|
return hostProjects;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveHostOrLocalAdapter(): Promise<{
|
||||||
|
adapter: StorageAdapter;
|
||||||
|
projects: ProjectMeta[];
|
||||||
|
}> {
|
||||||
|
const { serverEnabled } = useSettings.getState();
|
||||||
|
if (serverEnabled) {
|
||||||
|
try {
|
||||||
|
const serverAdapter = createServerClient({
|
||||||
|
baseUrl: "",
|
||||||
|
getToken: () => getStoredToken(),
|
||||||
|
});
|
||||||
|
const projects = await migrateLocalToHostIfEmpty(serverAdapter);
|
||||||
|
return { adapter: serverAdapter, projects };
|
||||||
|
} catch {
|
||||||
|
/* Host server unreachable or unauthenticated (e.g. unit test environment) */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const projects = await storageLocal.listProjects();
|
||||||
|
return { adapter: storageLocal, projects };
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
adapter: null,
|
adapter: null,
|
||||||
projects: [],
|
projects: [],
|
||||||
@@ -321,8 +396,9 @@ export const useProjectStore = create<ProjectStore>((set, get) => {
|
|||||||
init: async () => {
|
init: async () => {
|
||||||
set({ status: "loading", error: null });
|
set({ status: "loading", error: null });
|
||||||
try {
|
try {
|
||||||
const adapter = storageLocal;
|
const resolved = await resolveHostOrLocalAdapter();
|
||||||
let projects = await adapter.listProjects();
|
const adapter = resolved.adapter;
|
||||||
|
let projects = resolved.projects;
|
||||||
if (projects.length === 0) {
|
if (projects.length === 0) {
|
||||||
await seedDemoProject(adapter);
|
await seedDemoProject(adapter);
|
||||||
projects = await adapter.listProjects();
|
projects = await adapter.listProjects();
|
||||||
@@ -341,9 +417,13 @@ export const useProjectStore = create<ProjectStore>((set, get) => {
|
|||||||
(preferredId ? projects.find((p) => p.id === preferredId) : undefined) ??
|
(preferredId ? projects.find((p) => p.id === preferredId) : undefined) ??
|
||||||
projects[0]!;
|
projects[0]!;
|
||||||
|
|
||||||
const books = await adapter.listBooks(project.id);
|
let books = await adapter.listBooks(project.id);
|
||||||
const book = books[0];
|
let book = books[0];
|
||||||
const chapters = book ? await adapter.listChapters(book.id) : [];
|
if (!book) {
|
||||||
|
book = await adapter.createBook({ projectId: project.id, title: "Book One" });
|
||||||
|
books = [book];
|
||||||
|
}
|
||||||
|
const chapters = await adapter.listChapters(book.id);
|
||||||
|
|
||||||
autosave = createAutosave(adapter);
|
autosave = createAutosave(adapter);
|
||||||
autosave.onStatus((status) => {
|
autosave.onStatus((status) => {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import type {
|
|||||||
import type { StorageAdapter, StoreEvent } from "./storage.js";
|
import type { StorageAdapter, StoreEvent } from "./storage.js";
|
||||||
|
|
||||||
export interface ServerClientOptions {
|
export interface ServerClientOptions {
|
||||||
/** Base URL of the PubScript server, e.g. `http://localhost:8080`. */
|
/** Base URL of the PubScript server, e.g. `http://localhost:8080` or `""` for same-origin. */
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
/** Provide a custom fetch (e.g. for tests). Defaults to global fetch. */
|
/** Provide a custom fetch (e.g. for tests). Defaults to global fetch. */
|
||||||
fetch?: typeof fetch;
|
fetch?: typeof fetch;
|
||||||
@@ -37,6 +37,7 @@ export function createServerClient(opts: ServerClientOptions): StorageAdapter {
|
|||||||
if (token) headers.authorization = `Bearer ${token}`;
|
if (token) headers.authorization = `Bearer ${token}`;
|
||||||
const res = await fetchImpl(`${opts.baseUrl}${path}`, {
|
const res = await fetchImpl(`${opts.baseUrl}${path}`, {
|
||||||
method,
|
method,
|
||||||
|
credentials: "include",
|
||||||
headers,
|
headers,
|
||||||
body: body === undefined ? null : JSON.stringify(body),
|
body: body === undefined ? null : JSON.stringify(body),
|
||||||
});
|
});
|
||||||
@@ -53,8 +54,9 @@ export function createServerClient(opts: ServerClientOptions): StorageAdapter {
|
|||||||
return r.projects;
|
return r.projects;
|
||||||
},
|
},
|
||||||
|
|
||||||
async createProject(p: NewProject): Promise<Project> {
|
async createProject(p: NewProject & { id?: string }): Promise<Project> {
|
||||||
const r = await req<{ project: Project }>("POST", "/api/projects", {
|
const r = await req<{ project: Project }>("POST", "/api/projects", {
|
||||||
|
...(p.id ? { id: p.id } : {}),
|
||||||
title: p.title,
|
title: p.title,
|
||||||
language: p.language,
|
language: p.language,
|
||||||
cover: p.cover,
|
cover: p.cover,
|
||||||
@@ -67,8 +69,14 @@ export function createServerClient(opts: ServerClientOptions): StorageAdapter {
|
|||||||
return r.project;
|
return r.project;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async deleteProject(id: string): Promise<void> {
|
||||||
|
await req("DELETE", `/api/projects/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
async createBook(b: { projectId: string; title: string }): Promise<Book> {
|
async createBook(b: { projectId: string; title: string }): Promise<Book> {
|
||||||
const r = await req<{ book: Book }>("POST", `/api/projects/${b.projectId}/books`, { title: b.title });
|
const r = await req<{ book: Book }>("POST", `/api/projects/${b.projectId}/books`, {
|
||||||
|
title: b.title,
|
||||||
|
});
|
||||||
return r.book;
|
return r.book;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -86,6 +94,9 @@ export function createServerClient(opts: ServerClientOptions): StorageAdapter {
|
|||||||
await req("POST", `/api/chapters/${doc.id}/autosave`, {
|
await req("POST", `/api/chapters/${doc.id}/autosave`, {
|
||||||
rev: doc.rev,
|
rev: doc.rev,
|
||||||
doc: doc.doc,
|
doc: doc.doc,
|
||||||
|
title: doc.title,
|
||||||
|
projectId: doc.projectId,
|
||||||
|
bookId: doc.bookId,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -98,23 +109,43 @@ export function createServerClient(opts: ServerClientOptions): StorageAdapter {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async deleteChapter(id: string): Promise<void> {
|
||||||
|
await req("DELETE", `/api/chapters/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
async saveEntity(e: Entity): Promise<void> {
|
async saveEntity(e: Entity): Promise<void> {
|
||||||
if ("fields" in e) {
|
if ("fields" in e) {
|
||||||
// WorldItem — the server nests the world kind inside `fields`.
|
// WorldItem — the server nests the world kind inside `fields`.
|
||||||
await req("POST", `/api/projects/${e.projectId}/entities`, {
|
await req("POST", `/api/projects/${e.projectId}/entities`, {
|
||||||
id: e.id, kind: "world", name: e.name, fields: { kind: e.kind, ...e.fields },
|
id: e.id,
|
||||||
|
kind: "world",
|
||||||
|
name: e.name,
|
||||||
|
fields: { kind: e.kind, ...e.fields },
|
||||||
});
|
});
|
||||||
} else if ("body" in e) {
|
} else if ("body" in e) {
|
||||||
// Note
|
// Note
|
||||||
await req("POST", `/api/projects/${e.projectId}/entities`, {
|
await req("POST", `/api/projects/${e.projectId}/entities`, {
|
||||||
id: e.id, kind: "note", body: e.body, pinned: e.pinned, tags: e.tags, linkedTo: e.linkedTo,
|
id: e.id,
|
||||||
|
kind: "note",
|
||||||
|
body: e.body,
|
||||||
|
pinned: e.pinned,
|
||||||
|
tags: e.tags,
|
||||||
|
linkedTo: e.linkedTo,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Character
|
// Character
|
||||||
await req("POST", `/api/projects/${e.projectId}/entities`, {
|
await req("POST", `/api/projects/${e.projectId}/entities`, {
|
||||||
id: e.id, kind: "character", name: e.name, role: e.role, bio: e.bio,
|
id: e.id,
|
||||||
appearance: e.appearance, voice: e.voice, relationships: e.relationships,
|
kind: "character",
|
||||||
arc: e.arc, status: e.status, tags: e.tags,
|
name: e.name,
|
||||||
|
role: e.role,
|
||||||
|
bio: e.bio,
|
||||||
|
appearance: e.appearance,
|
||||||
|
voice: e.voice,
|
||||||
|
relationships: e.relationships,
|
||||||
|
arc: e.arc,
|
||||||
|
status: e.status,
|
||||||
|
tags: e.tags,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -212,8 +243,6 @@ export function createServerClient(opts: ServerClientOptions): StorageAdapter {
|
|||||||
},
|
},
|
||||||
|
|
||||||
onEvent(_cb: (e: StoreEvent) => void): () => void {
|
onEvent(_cb: (e: StoreEvent) => void): () => void {
|
||||||
// Live updates arrive over the `/api/ws` socket; wiring that up is the
|
|
||||||
// app's concern. For the contract, return a no-op unsubscribe.
|
|
||||||
return () => {};
|
return () => {};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-1
@@ -167,7 +167,7 @@ export function canAccessProject(
|
|||||||
| { owner_id: string }
|
| { owner_id: string }
|
||||||
| undefined;
|
| undefined;
|
||||||
if (!proj) return false;
|
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
|
const share = db
|
||||||
.prepare("SELECT perm FROM project_shares WHERE project_id = ? AND user_id = ?")
|
.prepare("SELECT perm FROM project_shares WHERE project_id = ? AND user_id = ?")
|
||||||
.get(projectId, user.id) as { perm: "read" | "edit" } | undefined;
|
.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 type { FastifyInstance } from "fastify";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ulid } from "ulid";
|
import { ulid } from "ulid";
|
||||||
@@ -29,7 +32,12 @@ import {
|
|||||||
} from "./mapping.js";
|
} from "./mapping.js";
|
||||||
|
|
||||||
// ---- input schemas ----
|
// ---- 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({
|
const projectPatch = z.object({
|
||||||
title: z.string().min(1).max(200).optional(),
|
title: z.string().min(1).max(200).optional(),
|
||||||
settings: z.record(z.unknown()).optional(),
|
settings: z.record(z.unknown()).optional(),
|
||||||
@@ -51,6 +59,9 @@ const chapterPatch = z.object({
|
|||||||
const autosaveBody = z.object({
|
const autosaveBody = z.object({
|
||||||
rev: z.number().int(),
|
rev: z.number().int(),
|
||||||
doc: z.object({ type: z.literal("doc"), content: z.array(z.any()) }),
|
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(),
|
wordCount: z.number().int().optional(),
|
||||||
});
|
});
|
||||||
const entityCreate = z.object({
|
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;
|
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 {
|
export function registerRoutes(app: FastifyInstance): void {
|
||||||
const db = app.db;
|
const db = app.db;
|
||||||
|
|
||||||
@@ -128,6 +226,10 @@ export function registerRoutes(app: FastifyInstance): void {
|
|||||||
app.get("/api/projects", async (req) => {
|
app.get("/api/projects", async (req) => {
|
||||||
requireAuth(req);
|
requireAuth(req);
|
||||||
const u = req.user!;
|
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 owned = db.prepare("SELECT * FROM projects WHERE owner_id = ?").all(u.id) as ProjectRow[];
|
||||||
const shared = db
|
const shared = db
|
||||||
.prepare("SELECT p.* FROM projects p JOIN project_shares s ON s.project_id = p.id WHERE s.user_id = ?")
|
.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) => {
|
app.post("/api/projects", async (req) => {
|
||||||
requireAuth(req);
|
requireAuth(req);
|
||||||
const { title } = projectCreate.parse(req.body);
|
const body = projectCreate.parse(req.body);
|
||||||
const id = ulid();
|
const id = body.id ?? ulid();
|
||||||
const t = now();
|
const t = now();
|
||||||
|
const coverJson = JSON.stringify(body.cover ?? { color: "#7c8bf5" });
|
||||||
|
const settingsJson = JSON.stringify(body.language ? { language: body.language } : {});
|
||||||
db.prepare(
|
db.prepare(
|
||||||
"INSERT INTO projects (id, owner_id, title, settings, cover, created_at, updated_at) VALUES (?, ?, ?, '{}', '{}', ?, ?)",
|
`INSERT INTO projects (id, owner_id, title, settings, cover, created_at, updated_at)
|
||||||
).run(id, req.user!.id, title, t, t);
|
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)!;
|
const row = projectRow(db, id)!;
|
||||||
return { project: toProject(row, []) };
|
return { project: toProject(row, []) };
|
||||||
});
|
});
|
||||||
@@ -327,25 +435,74 @@ export function registerRoutes(app: FastifyInstance): void {
|
|||||||
return { chapter: toChapter(updated) };
|
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) => {
|
app.post("/api/chapters/:id/autosave", async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
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;
|
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);
|
if (!row) {
|
||||||
const { rev, doc, wordCount: wc } = autosaveBody.parse(req.body);
|
requireAuth(req);
|
||||||
if (rev === row.rev) {
|
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 t = now();
|
||||||
const count = wc ?? wordCount(doc);
|
const chTitle = body.title?.trim() || "Untitled Chapter";
|
||||||
db.prepare("UPDATE chapters SET doc = ?, rev = rev + 1, updated_at = ? WHERE id = ?").run(JSON.stringify(doc), t, id);
|
const nextRev = Math.max(1, body.rev + 1);
|
||||||
return { ok: true, rev: row.rev + 1, wordCount: count };
|
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, {
|
requireProjectAccess(app, req, row.project_id, true);
|
||||||
error: "conflict",
|
const t = now();
|
||||||
serverRev: row.rev,
|
const nextTitle = body.title?.trim() || row.title;
|
||||||
serverDoc: JSON.parse(row.doc),
|
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) => {
|
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`,
|
name = excluded.name, fields = excluded.fields, updated_at = excluded.updated_at`,
|
||||||
).run(eid, id, row.kind, row.name, row.fields, t, t);
|
).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;
|
const stored = db.prepare("SELECT * FROM entities WHERE project_id = ? AND id = ?").get(id, eid) as EntityRow;
|
||||||
|
syncProjectToHostDisk(db, id);
|
||||||
return { entity: toEntity(stored) };
|
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,
|
bid, id, JSON.stringify(body.nodes), JSON.stringify(body.edges), t,
|
||||||
);
|
);
|
||||||
const row = db.prepare("SELECT * FROM boards WHERE id = ?").get(bid) as BoardRow;
|
const row = db.prepare("SELECT * FROM boards WHERE id = ?").get(bid) as BoardRow;
|
||||||
|
syncProjectToHostDisk(db, id);
|
||||||
return { board: toBoard(row) };
|
return { board: toBoard(row) };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user