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> {
|
||||
const token = getStoredToken();
|
||||
return {
|
||||
@@ -602,10 +614,7 @@ export const api = {
|
||||
startOnBoot: boolean;
|
||||
projectDirectory?: string;
|
||||
}> => {
|
||||
const endpoints = [
|
||||
"/api/system/server-config",
|
||||
...(portHint ? [`http://localhost:${portHint}/api/system/server-config`] : []),
|
||||
];
|
||||
const endpoints = buildHostEndpoints("/api/system/server-config", portHint);
|
||||
for (const url of endpoints) {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
@@ -679,10 +688,7 @@ export const api = {
|
||||
startOnBoot: boolean;
|
||||
projectDirectory?: string;
|
||||
}> => {
|
||||
const endpoints = [
|
||||
"/api/system/server-config",
|
||||
...(portHint ? [`http://localhost:${portHint}/api/system/server-config`] : []),
|
||||
];
|
||||
const endpoints = buildHostEndpoints("/api/system/server-config", portHint);
|
||||
for (const url of endpoints) {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
@@ -746,10 +752,7 @@ export const api = {
|
||||
directories: { name: string; path: string }[];
|
||||
}> => {
|
||||
const query = dirPath ? `?path=${encodeURIComponent(dirPath)}` : "";
|
||||
const endpoints = [
|
||||
`/api/system/list-dirs${query}`,
|
||||
...(portHint ? [`http://localhost:${portHint}/api/system/list-dirs${query}`] : []),
|
||||
];
|
||||
const endpoints = buildHostEndpoints(`/api/system/list-dirs${query}`, portHint);
|
||||
for (const url of endpoints) {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
@@ -804,10 +807,7 @@ export const api = {
|
||||
}
|
||||
const base64Data = btoa(binary);
|
||||
|
||||
const endpoints = [
|
||||
"/api/system/save-export",
|
||||
...(portHint ? [`http://localhost:${portHint}/api/system/save-export`] : []),
|
||||
];
|
||||
const endpoints = buildHostEndpoints("/api/system/save-export", portHint);
|
||||
for (const url of endpoints) {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
|
||||
@@ -2,6 +2,8 @@ import React, { createContext, useCallback, useContext, useEffect, useMemo, useS
|
||||
import type { User } from "@pubscript/core";
|
||||
import { Badge, Button, toast } from "@pubscript/ui";
|
||||
import { api } from "../api.js";
|
||||
import { useProjectStore } from "../store/project-store.js";
|
||||
import { useSettings } from "../store/settings.js";
|
||||
|
||||
interface AuthContextValue {
|
||||
user: User | null;
|
||||
@@ -77,6 +79,23 @@ export function AuthGate({ children }: { children: React.ReactNode }) {
|
||||
return () => window.removeEventListener("focus", onFocus);
|
||||
}, [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 () => {
|
||||
await api.logout();
|
||||
setAuthenticated(false);
|
||||
|
||||
@@ -43,6 +43,7 @@ export function ExportDialog({ open, onClose }: ExportDialogProps) {
|
||||
const [chapterId, setChapterId] = useState<string>("");
|
||||
const [fileName, setFileName] = useState<string>("");
|
||||
const [saveDir, setSaveDir] = useState<string>(projectDirectory || "~/Documents/PubScript");
|
||||
const [alsoDownloadBrowser, setAlsoDownloadBrowser] = useState<boolean>(false);
|
||||
const [browseOpen, setBrowseOpen] = useState<boolean>(false);
|
||||
const [books, setBooks] = useState<{ id: string; title: string }[]>([]);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
@@ -138,7 +139,14 @@ export function ExportDialog({ open, onClose }: ExportDialogProps) {
|
||||
);
|
||||
|
||||
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 {
|
||||
downloadFile(
|
||||
finalFileName,
|
||||
@@ -239,7 +247,7 @@ export function ExportDialog({ open, onClose }: ExportDialogProps) {
|
||||
</label>
|
||||
|
||||
<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" }}>
|
||||
<input
|
||||
type="text"
|
||||
@@ -256,9 +264,27 @@ export function ExportDialog({ open, onClose }: ExportDialogProps) {
|
||||
onClick={() => setBrowseOpen(true)}
|
||||
disabled={exporting}
|
||||
>
|
||||
📁 Browse…
|
||||
📁 Browse Host…
|
||||
</Button>
|
||||
</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>
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import {
|
||||
createAutosave,
|
||||
createPlotBeat,
|
||||
createServerClient,
|
||||
type AutosaveState,
|
||||
type Board,
|
||||
type Chapter,
|
||||
@@ -24,6 +25,8 @@ import {
|
||||
import { storageLocal } from "@pubscript/core/local";
|
||||
import { createUlid } from "@pubscript/core";
|
||||
import { create } from "zustand";
|
||||
import { getStoredToken } from "../api.js";
|
||||
import { useSettings } from "./settings.js";
|
||||
import { seedDemoProject } from "./seed.js";
|
||||
import { PLOT_TEMPLATES } from "../features/plot/templates.js";
|
||||
|
||||
@@ -295,6 +298,78 @@ export const useProjectStore = create<ProjectStore>((set, get) => {
|
||||
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 {
|
||||
adapter: null,
|
||||
projects: [],
|
||||
@@ -321,8 +396,9 @@ export const useProjectStore = create<ProjectStore>((set, get) => {
|
||||
init: async () => {
|
||||
set({ status: "loading", error: null });
|
||||
try {
|
||||
const adapter = storageLocal;
|
||||
let projects = await adapter.listProjects();
|
||||
const resolved = await resolveHostOrLocalAdapter();
|
||||
const adapter = resolved.adapter;
|
||||
let projects = resolved.projects;
|
||||
if (projects.length === 0) {
|
||||
await seedDemoProject(adapter);
|
||||
projects = await adapter.listProjects();
|
||||
@@ -341,9 +417,13 @@ export const useProjectStore = create<ProjectStore>((set, get) => {
|
||||
(preferredId ? projects.find((p) => p.id === preferredId) : undefined) ??
|
||||
projects[0]!;
|
||||
|
||||
const books = await adapter.listBooks(project.id);
|
||||
const book = books[0];
|
||||
const chapters = book ? await adapter.listChapters(book.id) : [];
|
||||
let books = await adapter.listBooks(project.id);
|
||||
let book = books[0];
|
||||
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.onStatus((status) => {
|
||||
|
||||
Reference in New Issue
Block a user