viteboard 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +110 -61
- package/content/docs/guide/boards-in-markdown.md +28 -26
- package/content/docs/guide/boards.md +44 -82
- package/content/docs/guide/getting-started.md +36 -48
- package/content/docs/index.md +21 -32
- package/content/docs/product-roadmap.board.json +4 -4
- package/content/machine/board-v1.schema.json +138 -0
- package/content/machine/example.board.json +217 -0
- package/content/machine/guide.md +270 -0
- package/content/machine/llms.txt +31 -0
- package/content/machine/viteboard.json +62 -0
- package/dist/assets/index-D2W76uu9.js +96 -0
- package/dist/assets/index-rMkUjGXP.css +1 -0
- package/dist/index.html +4 -2
- package/package.json +10 -12
- package/vite.config.ts +635 -48
- package/content/docs/capabilities.md +0 -89
- package/content/docs/docs.md +0 -84
- package/content/docs/guide/test.md +0 -6
- package/content/docs/test.md +0 -219
- package/content/docs/tetst-dir/test-board.board.json +0 -15
- package/content/docs/tetst-dir/test-test.md +0 -1
- package/content/docs/workspace.md +0 -73
- package/dist/assets/index-CEpxLM2o.css +0 -1
- package/dist/assets/index-D4xvJdEQ.js +0 -60
- package/index.html +0 -13
- package/src/app/AppController.ts +0 -955
- package/src/app/BoardState.ts +0 -130
- package/src/app/ClipboardService.ts +0 -159
- package/src/app/CommandManager.ts +0 -66
- package/src/app/ElementActions.ts +0 -152
- package/src/app/EmbedRegionSelector.ts +0 -103
- package/src/app/ExportPng.ts +0 -107
- package/src/app/ImageInsertService.ts +0 -141
- package/src/app/KeyboardShortcuts.ts +0 -124
- package/src/app/main.ts +0 -6
- package/src/board/BoardPreview.ts +0 -189
- package/src/board/BoardService.ts +0 -222
- package/src/canvas/CanvasRenderer.ts +0 -253
- package/src/canvas/CanvasSurface.ts +0 -70
- package/src/canvas/HitTester.ts +0 -110
- package/src/canvas/ImageCache.ts +0 -123
- package/src/canvas/PerformanceMonitor.ts +0 -31
- package/src/canvas/RenderScheduler.ts +0 -26
- package/src/canvas/Viewport.ts +0 -77
- package/src/content/ContentApi.ts +0 -258
- package/src/content/Markdown.ts +0 -431
- package/src/content/MarkdownView.ts +0 -70
- package/src/editor/DocEditor.ts +0 -799
- package/src/editor/htmlToMarkdown.ts +0 -333
- package/src/elements/renderElement.ts +0 -509
- package/src/elements/types.ts +0 -118
- package/src/shell/Shell.ts +0 -2950
- package/src/shell/Sidebar.ts +0 -1352
- package/src/storage/AssetStore.ts +0 -86
- package/src/storage/BoardSerializer.ts +0 -114
- package/src/storage/ImportExportService.ts +0 -153
- package/src/storage/IndexedDbStore.ts +0 -92
- package/src/storage/LocalBoardStore.ts +0 -104
- package/src/styles.css +0 -3257
- package/src/templates/helpers.ts +0 -124
- package/src/templates/index.ts +0 -65
- package/src/templates/journeyMapTemplate.ts +0 -52
- package/src/templates/opportunityTreeTemplate.ts +0 -45
- package/src/templates/personaTemplate.ts +0 -41
- package/src/templates/prioritizationMatrixTemplate.ts +0 -44
- package/src/templates/requirementsFlowTemplate.ts +0 -45
- package/src/templates/screenshotReviewTemplate.ts +0 -52
- package/src/templates/systemDiagramTemplate.ts +0 -41
- package/src/testing/StressTestGenerator.ts +0 -134
- package/src/testing/StressTestPanel.ts +0 -64
- package/src/tools/ArrowTool.ts +0 -87
- package/src/tools/ImageTool.ts +0 -39
- package/src/tools/PanTool.ts +0 -34
- package/src/tools/SelectTool.ts +0 -377
- package/src/tools/ShapeTool.ts +0 -106
- package/src/tools/StickyTool.ts +0 -69
- package/src/tools/TaskTool.ts +0 -52
- package/src/tools/TextTool.ts +0 -45
- package/src/tools/ToolContext.ts +0 -44
- package/src/tools/ToolController.ts +0 -178
- package/src/ui/Modal.ts +0 -58
- package/src/ui/PerformanceOverlay.ts +0 -75
- package/src/ui/StorageManager.ts +0 -94
- package/src/ui/TemplatePicker.ts +0 -67
- package/src/ui/TextEditorOverlay.ts +0 -137
- package/src/ui/Toolbar.ts +0 -151
- package/src/ui/TopControls.ts +0 -137
- package/src/ui/icons.ts +0 -50
- package/tsconfig.json +0 -19
package/vite.config.ts
CHANGED
|
@@ -3,8 +3,10 @@ import { defineConfig, type Plugin, type Connect } from "vite";
|
|
|
3
3
|
import { promises as fs } from "node:fs";
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import { execFile } from "node:child_process";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
6
7
|
import { promisify } from "node:util";
|
|
7
8
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
8
10
|
|
|
9
11
|
const WORKSPACE_DIR = ".workspace";
|
|
10
12
|
const WORKSPACE_FILE = "workspace.json";
|
|
@@ -29,6 +31,18 @@ const MIME: Record<string, string> = {
|
|
|
29
31
|
".json": "application/json; charset=utf-8",
|
|
30
32
|
};
|
|
31
33
|
|
|
34
|
+
const MIME_EXT: Record<string, string> = {
|
|
35
|
+
"image/png": ".png",
|
|
36
|
+
"image/jpeg": ".jpg",
|
|
37
|
+
"image/webp": ".webp",
|
|
38
|
+
"image/gif": ".gif",
|
|
39
|
+
"image/svg+xml": ".svg",
|
|
40
|
+
};
|
|
41
|
+
const MANAGED_ASSET_RE = /^assets\/[a-f0-9]{64}\.(png|jpe?g|webp|gif|svg)$/i;
|
|
42
|
+
const ACTOR_TTL_MS = 30000;
|
|
43
|
+
const LOCK_TTL_MS = 30000;
|
|
44
|
+
const ROOT_LINK_HEADER = '</llms.txt>; rel="describedby"; type="text/plain", </.well-known/viteboard.json>; rel="alternate"; type="application/json"';
|
|
45
|
+
|
|
32
46
|
const execFileAsync = promisify(execFile);
|
|
33
47
|
|
|
34
48
|
type WorkspaceManifest = {
|
|
@@ -59,6 +73,7 @@ type WorkspaceResponse = {
|
|
|
59
73
|
projects: Array<{
|
|
60
74
|
id: string;
|
|
61
75
|
label: string;
|
|
76
|
+
rootPath: string;
|
|
62
77
|
docsRoots: Array<{
|
|
63
78
|
id: string;
|
|
64
79
|
projectId: string;
|
|
@@ -73,10 +88,51 @@ type TreeNode =
|
|
|
73
88
|
| { type: "file"; name: string; path: string }
|
|
74
89
|
| { type: "dir"; name: string; path: string; children: TreeNode[] };
|
|
75
90
|
|
|
91
|
+
type ActorState =
|
|
92
|
+
| "idle"
|
|
93
|
+
| "viewing-doc"
|
|
94
|
+
| "editing-doc"
|
|
95
|
+
| "viewing-board"
|
|
96
|
+
| "editing-board"
|
|
97
|
+
| "viewing-project"
|
|
98
|
+
| "writing";
|
|
99
|
+
|
|
100
|
+
type ActorSessionRecord = {
|
|
101
|
+
actorId: string;
|
|
102
|
+
actorType: "human" | "agent";
|
|
103
|
+
label: string;
|
|
104
|
+
route: string;
|
|
105
|
+
state: ActorState;
|
|
106
|
+
rootId?: string;
|
|
107
|
+
projectId?: string;
|
|
108
|
+
docPath?: string;
|
|
109
|
+
boardId?: string;
|
|
110
|
+
updatedAt: number;
|
|
111
|
+
expiresAt: number;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
type LockRecord = {
|
|
115
|
+
lockId: string;
|
|
116
|
+
actorId: string;
|
|
117
|
+
actorType: "human" | "agent";
|
|
118
|
+
label: string;
|
|
119
|
+
targetType: "doc" | "board";
|
|
120
|
+
rootId: string;
|
|
121
|
+
path?: string;
|
|
122
|
+
boardId?: string;
|
|
123
|
+
acquiredAt: number;
|
|
124
|
+
expiresAt: number;
|
|
125
|
+
};
|
|
126
|
+
|
|
76
127
|
function contentApiPlugin(): Plugin {
|
|
77
128
|
const cwd = process.cwd();
|
|
129
|
+
const packageRoot = path.dirname(fileURLToPath(import.meta.url));
|
|
78
130
|
const workspaceDir = path.resolve(cwd, WORKSPACE_DIR);
|
|
79
131
|
const workspaceFile = path.join(workspaceDir, WORKSPACE_FILE);
|
|
132
|
+
const machineDir = path.join(packageRoot, "content", "machine");
|
|
133
|
+
const actorSessions = new Map<string, ActorSessionRecord>();
|
|
134
|
+
const locks = new Map<string, LockRecord>();
|
|
135
|
+
const sseClients = new Set<ServerResponse>();
|
|
80
136
|
|
|
81
137
|
function send(res: ServerResponse, status: number, body: unknown): void {
|
|
82
138
|
res.statusCode = status;
|
|
@@ -90,6 +146,87 @@ function contentApiPlugin(): Plugin {
|
|
|
90
146
|
res.end(body);
|
|
91
147
|
}
|
|
92
148
|
|
|
149
|
+
function sendSse(res: ServerResponse, event: string, body: unknown): void {
|
|
150
|
+
res.write(`event: ${event}\n`);
|
|
151
|
+
res.write(`data: ${JSON.stringify(body)}\n\n`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function broadcast(event: string, body: unknown): void {
|
|
155
|
+
for (const client of sseClients) {
|
|
156
|
+
try {
|
|
157
|
+
sendSse(client, event, body);
|
|
158
|
+
} catch {
|
|
159
|
+
sseClients.delete(client);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function actorFromHeaders(req: IncomingMessage): { actorId: string; actorType: "human" | "agent"; label: string } | null {
|
|
165
|
+
const actorId = String(req.headers["x-viteboard-actor-id"] ?? "").trim();
|
|
166
|
+
if (!actorId) return null;
|
|
167
|
+
const actorType = String(req.headers["x-viteboard-actor-type"] ?? "human") === "agent" ? "agent" : "human";
|
|
168
|
+
const label = String(req.headers["x-viteboard-actor-label"] ?? "").trim() || (actorType === "agent" ? "Agent" : "ViteBoard UI");
|
|
169
|
+
return { actorId, actorType, label };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function lockKey(target: { targetType: "doc" | "board"; rootId: string; path?: string; boardId?: string }): string {
|
|
173
|
+
const suffix = target.targetType === "doc" ? target.path ?? "" : target.boardId ?? "";
|
|
174
|
+
return `${target.targetType}:${target.rootId}:${suffix}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function activeActors(): ActorSessionRecord[] {
|
|
178
|
+
return [...actorSessions.values()].sort((a, b) => a.label.localeCompare(b.label));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function activeLocks(): LockRecord[] {
|
|
182
|
+
return [...locks.values()].sort((a, b) => a.acquiredAt - b.acquiredAt);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function broadcastPresence(): void {
|
|
186
|
+
broadcast("presence", { actors: activeActors(), locks: activeLocks() });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function broadcastLocks(): void {
|
|
190
|
+
broadcast("lock-changed", { locks: activeLocks() });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function pruneEphemeral(): void {
|
|
194
|
+
const now = Date.now();
|
|
195
|
+
let changedPresence = false;
|
|
196
|
+
let changedLocks = false;
|
|
197
|
+
for (const [actorId, actor] of actorSessions) {
|
|
198
|
+
if (actor.expiresAt <= now) {
|
|
199
|
+
actorSessions.delete(actorId);
|
|
200
|
+
changedPresence = true;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
for (const [key, lock] of locks) {
|
|
204
|
+
if (lock.expiresAt <= now || !actorSessions.has(lock.actorId)) {
|
|
205
|
+
locks.delete(key);
|
|
206
|
+
changedLocks = true;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (changedPresence) broadcastPresence();
|
|
210
|
+
else if (changedLocks) broadcastLocks();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function extendActorLocks(actorId: string): void {
|
|
214
|
+
const now = Date.now();
|
|
215
|
+
for (const lock of locks.values()) {
|
|
216
|
+
if (lock.actorId === actorId) lock.expiresAt = now + LOCK_TTL_MS;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function readMachineAsset(fileName: string): Promise<string> {
|
|
221
|
+
return fs.readFile(path.join(machineDir, fileName), "utf-8");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function revisionForText(content: string): string {
|
|
225
|
+
return createHash("sha1").update(content).digest("hex");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
setInterval(pruneEphemeral, 10000).unref?.();
|
|
229
|
+
|
|
93
230
|
function readBody(req: IncomingMessage): Promise<string> {
|
|
94
231
|
return new Promise((resolve, reject) => {
|
|
95
232
|
let data = "";
|
|
@@ -114,6 +251,48 @@ function contentApiPlugin(): Plugin {
|
|
|
114
251
|
return (hash >>> 0).toString(36);
|
|
115
252
|
}
|
|
116
253
|
|
|
254
|
+
async function canonicalizeProjectRoot(rootPath: string): Promise<string> {
|
|
255
|
+
const resolved = path.resolve(rootPath);
|
|
256
|
+
try {
|
|
257
|
+
return path.resolve(await fs.realpath(resolved));
|
|
258
|
+
} catch {
|
|
259
|
+
return resolved;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function isNestedPath(parent: string, child: string): boolean {
|
|
264
|
+
const rel = path.relative(parent, child);
|
|
265
|
+
return rel !== "" && rel !== "." && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function findOverlappingProject(
|
|
269
|
+
projects: WorkspaceProjectRecord[],
|
|
270
|
+
candidateRoot: string,
|
|
271
|
+
ignoreProjectId?: string
|
|
272
|
+
): WorkspaceProjectRecord | null {
|
|
273
|
+
for (const project of projects) {
|
|
274
|
+
if (ignoreProjectId && project.id === ignoreProjectId) continue;
|
|
275
|
+
if (
|
|
276
|
+
project.rootPath === candidateRoot ||
|
|
277
|
+
isNestedPath(project.rootPath, candidateRoot) ||
|
|
278
|
+
isNestedPath(candidateRoot, project.rootPath)
|
|
279
|
+
) {
|
|
280
|
+
return project;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function overlapError(existing: WorkspaceProjectRecord, candidateRoot: string): string {
|
|
287
|
+
if (existing.rootPath === candidateRoot) {
|
|
288
|
+
return `\"${existing.label}\" is already added. Pick a different folder, or remove the existing project first.`;
|
|
289
|
+
}
|
|
290
|
+
if (isNestedPath(existing.rootPath, candidateRoot)) {
|
|
291
|
+
return `That folder is inside the existing workspace project \"${existing.label}\". ViteBoard only allows one project root per nested path.`;
|
|
292
|
+
}
|
|
293
|
+
return `That folder contains the existing workspace project \"${existing.label}\". Add the more specific project instead, or remove the current one first.`;
|
|
294
|
+
}
|
|
295
|
+
|
|
117
296
|
async function ensureWorkspaceDir(): Promise<void> {
|
|
118
297
|
await fs.mkdir(workspaceDir, { recursive: true });
|
|
119
298
|
}
|
|
@@ -122,17 +301,26 @@ function contentApiPlugin(): Plugin {
|
|
|
122
301
|
try {
|
|
123
302
|
const raw = await fs.readFile(workspaceFile, "utf-8");
|
|
124
303
|
const parsed = JSON.parse(raw) as Partial<WorkspaceManifest>;
|
|
125
|
-
const
|
|
126
|
-
? parsed.projects
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
})
|
|
130
|
-
.map((item) => ({
|
|
131
|
-
id: item.id,
|
|
132
|
-
label: typeof item.label === "string" && item.label.trim() ? item.label.trim() : path.basename(item.rootPath),
|
|
133
|
-
rootPath: path.resolve(item.rootPath),
|
|
134
|
-
}))
|
|
304
|
+
const rawProjects = Array.isArray(parsed.projects)
|
|
305
|
+
? parsed.projects.filter((item): item is WorkspaceProjectRecord => {
|
|
306
|
+
return !!item && typeof item.id === "string" && typeof item.rootPath === "string";
|
|
307
|
+
})
|
|
135
308
|
: [];
|
|
309
|
+
const projects: WorkspaceProjectRecord[] = [];
|
|
310
|
+
const seenIds = new Set<string>();
|
|
311
|
+
const seenPaths = new Set<string>();
|
|
312
|
+
for (const item of rawProjects) {
|
|
313
|
+
const rootPath = await canonicalizeProjectRoot(item.rootPath);
|
|
314
|
+
const id = item.id.trim() || `project-${hashPath(rootPath)}`;
|
|
315
|
+
if (seenIds.has(id) || seenPaths.has(rootPath)) continue;
|
|
316
|
+
seenIds.add(id);
|
|
317
|
+
seenPaths.add(rootPath);
|
|
318
|
+
projects.push({
|
|
319
|
+
id,
|
|
320
|
+
label: typeof item.label === "string" && item.label.trim() ? item.label.trim() : path.basename(rootPath),
|
|
321
|
+
rootPath,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
136
324
|
return { version: 1, projects };
|
|
137
325
|
} catch {
|
|
138
326
|
return { version: 1, projects: [] };
|
|
@@ -153,10 +341,7 @@ function contentApiPlugin(): Plugin {
|
|
|
153
341
|
}
|
|
154
342
|
|
|
155
343
|
async function loadProjects(): Promise<WorkspaceProjectRecord[]> {
|
|
156
|
-
|
|
157
|
-
return manifest.projects.filter((item, index, list) => {
|
|
158
|
-
return list.findIndex((other) => other.rootPath === item.rootPath) === index;
|
|
159
|
-
});
|
|
344
|
+
return (await loadManifest()).projects;
|
|
160
345
|
}
|
|
161
346
|
|
|
162
347
|
async function openProjectPicker(): Promise<string | null> {
|
|
@@ -290,6 +475,7 @@ function contentApiPlugin(): Plugin {
|
|
|
290
475
|
projects: projects.map((project) => ({
|
|
291
476
|
id: project.id,
|
|
292
477
|
label: project.label,
|
|
478
|
+
rootPath: project.rootPath,
|
|
293
479
|
docsRoots: project.docsRoots.map((root) => ({
|
|
294
480
|
id: root.id,
|
|
295
481
|
projectId: root.projectId,
|
|
@@ -323,6 +509,142 @@ function contentApiPlugin(): Plugin {
|
|
|
323
509
|
return abs;
|
|
324
510
|
}
|
|
325
511
|
|
|
512
|
+
function normalizeRelativePath(input: string): string {
|
|
513
|
+
const parts: string[] = [];
|
|
514
|
+
for (const raw of String(input ?? "").replace(/\\/g, "/").split("/")) {
|
|
515
|
+
const part = raw.trim();
|
|
516
|
+
if (!part || part === ".") continue;
|
|
517
|
+
if (part === "..") {
|
|
518
|
+
if (parts.length > 0) parts.pop();
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
parts.push(part);
|
|
522
|
+
}
|
|
523
|
+
return parts.join("/");
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function dirnamePosix(filePath: string): string {
|
|
527
|
+
const normalized = normalizeRelativePath(filePath);
|
|
528
|
+
const idx = normalized.lastIndexOf("/");
|
|
529
|
+
return idx === -1 ? "" : normalized.slice(0, idx);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function resolveDocRelativePath(fromFile: string, target: string): string | null {
|
|
533
|
+
const raw = String(target ?? "").trim();
|
|
534
|
+
if (!raw || /^(https?:|mailto:|tel:|#|data:)/i.test(raw)) return null;
|
|
535
|
+
if (raw.startsWith("/")) return normalizeRelativePath(raw);
|
|
536
|
+
const base = dirnamePosix(fromFile);
|
|
537
|
+
return normalizeRelativePath(base ? `${base}/${raw}` : raw);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function isManagedAssetPath(relPath: string): boolean {
|
|
541
|
+
return MANAGED_ASSET_RE.test(normalizeRelativePath(relPath));
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async function collectFiles(dir: string, base = ""): Promise<string[]> {
|
|
545
|
+
let entries: import("node:fs").Dirent[];
|
|
546
|
+
try {
|
|
547
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
548
|
+
} catch {
|
|
549
|
+
return [];
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const files: string[] = [];
|
|
553
|
+
for (const entry of entries) {
|
|
554
|
+
if (entry.name.startsWith(".")) continue;
|
|
555
|
+
const rel = base ? `${base}/${entry.name}` : entry.name;
|
|
556
|
+
const abs = path.join(dir, entry.name);
|
|
557
|
+
if (entry.isDirectory()) {
|
|
558
|
+
files.push(...(await collectFiles(abs, rel)));
|
|
559
|
+
} else {
|
|
560
|
+
files.push(rel);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
return files;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function collectBoardAssetRefs(raw: unknown): Set<string> {
|
|
567
|
+
const refs = new Set<string>();
|
|
568
|
+
if (typeof raw !== "object" || raw === null) return refs;
|
|
569
|
+
const elements = Array.isArray((raw as { elements?: unknown[] }).elements)
|
|
570
|
+
? (raw as { elements: unknown[] }).elements
|
|
571
|
+
: [];
|
|
572
|
+
for (const element of elements) {
|
|
573
|
+
if (typeof element !== "object" || element === null) continue;
|
|
574
|
+
const data = (element as { data?: Record<string, unknown> }).data;
|
|
575
|
+
const src = typeof data?.src === "string" ? normalizeRelativePath(data.src) : "";
|
|
576
|
+
if (isManagedAssetPath(src)) refs.add(src);
|
|
577
|
+
}
|
|
578
|
+
return refs;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function collectMarkdownAssetRefs(markdown: string, docPath: string): Set<string> {
|
|
582
|
+
const refs = new Set<string>();
|
|
583
|
+
const imagePatterns = [
|
|
584
|
+
/!\[[^\]]*\]\(([^)\s]+)\)/g,
|
|
585
|
+
/<img\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi,
|
|
586
|
+
];
|
|
587
|
+
|
|
588
|
+
for (const pattern of imagePatterns) {
|
|
589
|
+
pattern.lastIndex = 0;
|
|
590
|
+
let match: RegExpExecArray | null;
|
|
591
|
+
while ((match = pattern.exec(markdown))) {
|
|
592
|
+
const resolved = resolveDocRelativePath(docPath, match[1]);
|
|
593
|
+
if (resolved && isManagedAssetPath(resolved)) refs.add(resolved);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
return refs;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
async function cleanupManagedAssets(rootAbsPath: string, preservePaths: string[] = []): Promise<number> {
|
|
601
|
+
const allFiles = await collectFiles(rootAbsPath);
|
|
602
|
+
const managedAssets = allFiles.filter((file) => isManagedAssetPath(file));
|
|
603
|
+
if (managedAssets.length === 0) return 0;
|
|
604
|
+
|
|
605
|
+
const referenced = new Set<string>();
|
|
606
|
+
for (const relPath of allFiles) {
|
|
607
|
+
const abs = safeResolve(rootAbsPath, relPath);
|
|
608
|
+
if (!abs) continue;
|
|
609
|
+
if (relPath.endsWith(".board.json")) {
|
|
610
|
+
try {
|
|
611
|
+
const raw = JSON.parse(await fs.readFile(abs, "utf-8"));
|
|
612
|
+
for (const ref of collectBoardAssetRefs(raw)) referenced.add(ref);
|
|
613
|
+
} catch {
|
|
614
|
+
// Ignore unreadable or invalid boards; cleanup stays conservative.
|
|
615
|
+
}
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
if (relPath.endsWith(".md")) {
|
|
619
|
+
try {
|
|
620
|
+
const markdown = await fs.readFile(abs, "utf-8");
|
|
621
|
+
for (const ref of collectMarkdownAssetRefs(markdown, relPath)) referenced.add(ref);
|
|
622
|
+
} catch {
|
|
623
|
+
// Ignore unreadable docs; cleanup stays conservative.
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
for (const relPath of preservePaths) {
|
|
629
|
+
const normalized = normalizeRelativePath(relPath);
|
|
630
|
+
if (isManagedAssetPath(normalized)) referenced.add(normalized);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
let removed = 0;
|
|
634
|
+
for (const relPath of managedAssets) {
|
|
635
|
+
if (referenced.has(relPath)) continue;
|
|
636
|
+
const abs = safeResolve(rootAbsPath, relPath);
|
|
637
|
+
if (!abs) continue;
|
|
638
|
+
try {
|
|
639
|
+
await fs.unlink(abs);
|
|
640
|
+
removed++;
|
|
641
|
+
} catch (err) {
|
|
642
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
return removed;
|
|
646
|
+
}
|
|
647
|
+
|
|
326
648
|
async function walkDocsRoot(dir: string, base: string, nestedRoots: Set<string>): Promise<TreeNode[]> {
|
|
327
649
|
let entries: import("node:fs").Dirent[];
|
|
328
650
|
try {
|
|
@@ -358,7 +680,7 @@ function contentApiPlugin(): Plugin {
|
|
|
358
680
|
return nodes;
|
|
359
681
|
}
|
|
360
682
|
|
|
361
|
-
/** Walk
|
|
683
|
+
/** Walk the project tree for Raw sidebar mode. */
|
|
362
684
|
async function walkProjectTree(dir: string, base: string): Promise<TreeNode[]> {
|
|
363
685
|
let entries: import("node:fs").Dirent[];
|
|
364
686
|
try {
|
|
@@ -368,44 +690,247 @@ function contentApiPlugin(): Plugin {
|
|
|
368
690
|
}
|
|
369
691
|
const nodes: TreeNode[] = [];
|
|
370
692
|
for (const entry of entries) {
|
|
371
|
-
if (!entry.isDirectory()) continue;
|
|
372
693
|
if (entry.name.startsWith(".")) continue;
|
|
373
|
-
if (isIgnoredDir(entry.name)) continue;
|
|
374
694
|
const rel = base ? `${base}/${entry.name}` : entry.name;
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
695
|
+
if (entry.isDirectory()) {
|
|
696
|
+
if (isIgnoredDir(entry.name)) continue;
|
|
697
|
+
nodes.push({
|
|
698
|
+
type: "dir",
|
|
699
|
+
name: entry.name,
|
|
700
|
+
path: rel,
|
|
701
|
+
children: await walkProjectTree(path.join(dir, entry.name), rel),
|
|
702
|
+
});
|
|
703
|
+
} else {
|
|
704
|
+
nodes.push({ type: "file", name: entry.name, path: rel });
|
|
705
|
+
}
|
|
381
706
|
}
|
|
382
|
-
nodes.sort((a, b) =>
|
|
707
|
+
nodes.sort((a, b) => {
|
|
708
|
+
if (a.type !== b.type) return a.type === "dir" ? -1 : 1;
|
|
709
|
+
return a.name.localeCompare(b.name);
|
|
710
|
+
});
|
|
383
711
|
return nodes;
|
|
384
712
|
}
|
|
385
713
|
|
|
714
|
+
async function sendMachineFile(res: ServerResponse, fileName: string, contentType: string): Promise<void> {
|
|
715
|
+
return sendText(res, 200, await readMachineAsset(fileName), contentType);
|
|
716
|
+
}
|
|
717
|
+
|
|
386
718
|
const handler: Connect.NextHandleFunction = (req, res, next) => {
|
|
387
719
|
const url = req.url ?? "";
|
|
388
|
-
|
|
720
|
+
const parsed = new URL(url, "http://localhost");
|
|
721
|
+
const route = parsed.pathname;
|
|
722
|
+
|
|
723
|
+
if (route === "/" || route === "/index.html") {
|
|
724
|
+
res.setHeader("Link", ROOT_LINK_HEADER);
|
|
725
|
+
return next();
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
if (route === "/llms.txt") {
|
|
729
|
+
void sendMachineFile(res, "llms.txt", "text/plain; charset=utf-8");
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
if (route === "/.well-known/viteboard.json") {
|
|
734
|
+
void sendMachineFile(res, "viteboard.json", "application/json; charset=utf-8");
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
if (route === "/viteboard-guide.md") {
|
|
739
|
+
void sendMachineFile(res, "guide.md", "text/markdown; charset=utf-8");
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
if (route === "/board-v1.schema.json") {
|
|
744
|
+
void sendMachineFile(res, "board-v1.schema.json", "application/schema+json; charset=utf-8");
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
if (route === "/viteboard-example.board.json") {
|
|
749
|
+
void sendMachineFile(res, "example.board.json", "application/json; charset=utf-8");
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
if (!route.startsWith("/__api/")) return next();
|
|
389
754
|
|
|
390
755
|
void (async () => {
|
|
391
756
|
try {
|
|
392
|
-
const parsed = new URL(url, "http://localhost");
|
|
393
|
-
const route = parsed.pathname;
|
|
394
757
|
const method = (req.method ?? "GET").toUpperCase();
|
|
758
|
+
const actor = actorFromHeaders(req);
|
|
759
|
+
pruneEphemeral();
|
|
395
760
|
|
|
396
761
|
if (route === "/__api/workspace" && method === "GET") {
|
|
397
762
|
return send(res, 200, await toWorkspaceResponse(await getWorkspace()));
|
|
398
763
|
}
|
|
399
764
|
|
|
765
|
+
if (route === "/__api/events" && method === "GET") {
|
|
766
|
+
res.statusCode = 200;
|
|
767
|
+
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
|
|
768
|
+
res.setHeader("Cache-Control", "no-cache, no-transform");
|
|
769
|
+
res.setHeader("Connection", "keep-alive");
|
|
770
|
+
res.write(": connected\n\n");
|
|
771
|
+
sseClients.add(res);
|
|
772
|
+
sendSse(res, "hello", { actors: activeActors(), locks: activeLocks() });
|
|
773
|
+
req.on("close", () => {
|
|
774
|
+
sseClients.delete(res);
|
|
775
|
+
});
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
if (route === "/__api/actors/session" && method === "GET") {
|
|
780
|
+
return send(res, 200, { actors: activeActors() });
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
if (route === "/__api/actors/session" && method === "POST") {
|
|
784
|
+
const body = JSON.parse(await readBody(req));
|
|
785
|
+
const actorId = String(body.actorId ?? actor?.actorId ?? "").trim();
|
|
786
|
+
if (!actorId) return send(res, 400, { error: "actorId is required" });
|
|
787
|
+
const actorType = String(body.actorType ?? actor?.actorType ?? "human") === "agent" ? "agent" : "human";
|
|
788
|
+
const label = String(body.label ?? actor?.label ?? "").trim() || (actorType === "agent" ? "Agent" : "ViteBoard UI");
|
|
789
|
+
const now = Date.now();
|
|
790
|
+
actorSessions.set(actorId, {
|
|
791
|
+
actorId,
|
|
792
|
+
actorType,
|
|
793
|
+
label,
|
|
794
|
+
route: String(body.route ?? "#/"),
|
|
795
|
+
state: String(body.state ?? "idle") as ActorState,
|
|
796
|
+
rootId: typeof body.rootId === "string" ? body.rootId : undefined,
|
|
797
|
+
projectId: typeof body.projectId === "string" ? body.projectId : undefined,
|
|
798
|
+
docPath: typeof body.docPath === "string" ? body.docPath : undefined,
|
|
799
|
+
boardId: typeof body.boardId === "string" ? body.boardId : undefined,
|
|
800
|
+
selectedElementIds: Array.isArray(body.selectedElementIds)
|
|
801
|
+
? body.selectedElementIds.filter((item: unknown): item is string => typeof item === "string")
|
|
802
|
+
: undefined,
|
|
803
|
+
updatedAt: now,
|
|
804
|
+
expiresAt: now + ACTOR_TTL_MS,
|
|
805
|
+
});
|
|
806
|
+
extendActorLocks(actorId);
|
|
807
|
+
broadcastPresence();
|
|
808
|
+
return send(res, 200, { ok: true });
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
if (route === "/__api/actors/heartbeat" && method === "POST") {
|
|
812
|
+
const body = JSON.parse(await readBody(req));
|
|
813
|
+
const actorId = String(body.actorId ?? actor?.actorId ?? "").trim();
|
|
814
|
+
if (!actorId) return send(res, 400, { error: "actorId is required" });
|
|
815
|
+
const current = actorSessions.get(actorId);
|
|
816
|
+
if (!current) return send(res, 404, { error: "Actor session not found" });
|
|
817
|
+
const now = Date.now();
|
|
818
|
+
const next: ActorSessionRecord = {
|
|
819
|
+
...current,
|
|
820
|
+
route: typeof body.route === "string" ? body.route : current.route,
|
|
821
|
+
state: typeof body.state === "string" ? (body.state as ActorState) : current.state,
|
|
822
|
+
rootId: typeof body.rootId === "string" ? body.rootId : current.rootId,
|
|
823
|
+
projectId: typeof body.projectId === "string" ? body.projectId : current.projectId,
|
|
824
|
+
docPath: typeof body.docPath === "string" ? body.docPath : current.docPath,
|
|
825
|
+
boardId: typeof body.boardId === "string" ? body.boardId : current.boardId,
|
|
826
|
+
selectedElementIds: Array.isArray(body.selectedElementIds)
|
|
827
|
+
? body.selectedElementIds.filter((item: unknown): item is string => typeof item === "string")
|
|
828
|
+
: current.selectedElementIds,
|
|
829
|
+
updatedAt: now,
|
|
830
|
+
expiresAt: now + ACTOR_TTL_MS,
|
|
831
|
+
};
|
|
832
|
+
actorSessions.set(actorId, next);
|
|
833
|
+
extendActorLocks(actorId);
|
|
834
|
+
broadcastPresence();
|
|
835
|
+
return send(res, 200, { ok: true });
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
if (route === "/__api/actors/session" && method === "DELETE") {
|
|
839
|
+
const actorId = parsed.searchParams.get("actorId") ?? actor?.actorId ?? "";
|
|
840
|
+
if (!actorId) return send(res, 400, { error: "actorId is required" });
|
|
841
|
+
actorSessions.delete(actorId);
|
|
842
|
+
let changedLocks = false;
|
|
843
|
+
for (const [key, lock] of locks) {
|
|
844
|
+
if (lock.actorId === actorId) {
|
|
845
|
+
locks.delete(key);
|
|
846
|
+
changedLocks = true;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
if (changedLocks) broadcastLocks();
|
|
850
|
+
broadcastPresence();
|
|
851
|
+
return send(res, 200, { ok: true });
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
if (route === "/__api/locks" && method === "GET") {
|
|
855
|
+
return send(res, 200, { locks: activeLocks() });
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
if (route === "/__api/locks/acquire" && method === "POST") {
|
|
859
|
+
const body = JSON.parse(await readBody(req));
|
|
860
|
+
if (!actor?.actorId) return send(res, 400, { error: "actor identity is required" });
|
|
861
|
+
const targetType = body.targetType === "board" ? "board" : body.targetType === "doc" ? "doc" : null;
|
|
862
|
+
const rootId = String(body.rootId ?? "").trim();
|
|
863
|
+
if (!targetType || !rootId) return send(res, 400, { error: "targetType and rootId are required" });
|
|
864
|
+
const resolved = await resolveRoot(rootId);
|
|
865
|
+
if (!resolved) return send(res, 404, { error: "Docs root not found" });
|
|
866
|
+
const targetPath = targetType === "doc" ? String(body.path ?? "").trim() : undefined;
|
|
867
|
+
const targetBoardId = targetType === "board" ? String(body.boardId ?? "").trim() : undefined;
|
|
868
|
+
const targetAbs = targetType === "doc"
|
|
869
|
+
? safeResolve(resolved.root.absPath, targetPath ?? "")
|
|
870
|
+
: safeResolve(resolved.root.absPath, `${targetBoardId}.board.json`);
|
|
871
|
+
if (!targetAbs) {
|
|
872
|
+
return send(res, 400, { error: targetType === "doc" ? "Invalid path" : "Invalid boardId" });
|
|
873
|
+
}
|
|
874
|
+
const key = lockKey({ targetType, rootId, path: targetPath, boardId: targetBoardId });
|
|
875
|
+
const now = Date.now();
|
|
876
|
+
const existing = locks.get(key);
|
|
877
|
+
if (existing && existing.actorId !== actor.actorId && existing.expiresAt > now) {
|
|
878
|
+
return send(res, 409, { error: `${existing.label} is editing this ${targetType}.`, lock: existing });
|
|
879
|
+
}
|
|
880
|
+
const nextLock: LockRecord = existing && existing.actorId === actor.actorId
|
|
881
|
+
? { ...existing, expiresAt: now + LOCK_TTL_MS }
|
|
882
|
+
: {
|
|
883
|
+
lockId: `lock-${hashPath(`${key}:${now}`)}`,
|
|
884
|
+
actorId: actor.actorId,
|
|
885
|
+
actorType: actor.actorType,
|
|
886
|
+
label: actor.label,
|
|
887
|
+
targetType,
|
|
888
|
+
rootId,
|
|
889
|
+
path: targetPath,
|
|
890
|
+
boardId: targetBoardId,
|
|
891
|
+
acquiredAt: now,
|
|
892
|
+
expiresAt: now + LOCK_TTL_MS,
|
|
893
|
+
};
|
|
894
|
+
locks.set(key, nextLock);
|
|
895
|
+
broadcastLocks();
|
|
896
|
+
broadcastPresence();
|
|
897
|
+
return send(res, 200, { ok: true, lock: nextLock });
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
if (route === "/__api/locks/release" && method === "POST") {
|
|
901
|
+
const body = JSON.parse(await readBody(req));
|
|
902
|
+
if (!actor?.actorId) return send(res, 400, { error: "actor identity is required" });
|
|
903
|
+
const targetType = body.targetType === "board" ? "board" : body.targetType === "doc" ? "doc" : null;
|
|
904
|
+
const rootId = String(body.rootId ?? "").trim();
|
|
905
|
+
if (!targetType || !rootId) return send(res, 400, { error: "targetType and rootId are required" });
|
|
906
|
+
const key = lockKey({
|
|
907
|
+
targetType,
|
|
908
|
+
rootId,
|
|
909
|
+
path: targetType === "doc" ? String(body.path ?? "").trim() : undefined,
|
|
910
|
+
boardId: targetType === "board" ? String(body.boardId ?? "").trim() : undefined,
|
|
911
|
+
});
|
|
912
|
+
const existing = locks.get(key);
|
|
913
|
+
if (!existing) return send(res, 200, { ok: true });
|
|
914
|
+
if (existing.actorId !== actor.actorId) return send(res, 403, { error: "Lock owned by another actor" });
|
|
915
|
+
locks.delete(key);
|
|
916
|
+
broadcastLocks();
|
|
917
|
+
broadcastPresence();
|
|
918
|
+
return send(res, 200, { ok: true });
|
|
919
|
+
}
|
|
920
|
+
|
|
400
921
|
if (route === "/__api/workspace/project" && method === "POST") {
|
|
401
922
|
const { rootPath, label } = JSON.parse(await readBody(req));
|
|
402
|
-
const absRoot =
|
|
923
|
+
const absRoot = await canonicalizeProjectRoot(String(rootPath ?? "").trim());
|
|
403
924
|
if (!absRoot || !(await dirExists(absRoot))) {
|
|
404
925
|
return send(res, 400, { error: "Project root not found" });
|
|
405
926
|
}
|
|
406
927
|
const manifest = await loadManifest();
|
|
928
|
+
const existingProject = manifest.projects.find((item) => item.rootPath === absRoot);
|
|
929
|
+
const projectId = existingProject?.id ?? `project-${hashPath(absRoot)}`;
|
|
930
|
+
const overlap = findOverlappingProject(manifest.projects, absRoot, projectId);
|
|
931
|
+
if (overlap) return send(res, 409, { error: overlapError(overlap, absRoot) });
|
|
407
932
|
const project: WorkspaceProjectRecord = {
|
|
408
|
-
id:
|
|
933
|
+
id: projectId,
|
|
409
934
|
label: String(label ?? "").trim() || path.basename(absRoot) || absRoot,
|
|
410
935
|
rootPath: absRoot,
|
|
411
936
|
};
|
|
@@ -413,17 +938,22 @@ function contentApiPlugin(): Plugin {
|
|
|
413
938
|
if (existingIndex === -1) manifest.projects.push(project);
|
|
414
939
|
else manifest.projects.splice(existingIndex, 1, project);
|
|
415
940
|
await saveManifest(manifest);
|
|
941
|
+
broadcast("workspace-changed", { action: "project-added", projectId: project.id, actorId: actor?.actorId ?? null });
|
|
416
942
|
return send(res, 200, await toWorkspaceResponse(await getWorkspace()));
|
|
417
943
|
}
|
|
418
944
|
|
|
419
945
|
if (route === "/__api/workspace/pick-project" && method === "POST") {
|
|
420
946
|
const picked = await openProjectPicker();
|
|
421
947
|
if (!picked) return send(res, 200, { cancelled: true, workspace: await toWorkspaceResponse(await getWorkspace()) });
|
|
422
|
-
const absRoot =
|
|
948
|
+
const absRoot = await canonicalizeProjectRoot(picked);
|
|
423
949
|
if (!(await dirExists(absRoot))) return send(res, 400, { error: "Project root not found" });
|
|
424
950
|
const manifest = await loadManifest();
|
|
951
|
+
const existingProject = manifest.projects.find((item) => item.rootPath === absRoot);
|
|
952
|
+
const projectId = existingProject?.id ?? `project-${hashPath(absRoot)}`;
|
|
953
|
+
const overlap = findOverlappingProject(manifest.projects, absRoot, projectId);
|
|
954
|
+
if (overlap) return send(res, 409, { error: overlapError(overlap, absRoot) });
|
|
425
955
|
const project: WorkspaceProjectRecord = {
|
|
426
|
-
id:
|
|
956
|
+
id: projectId,
|
|
427
957
|
label: path.basename(absRoot) || absRoot,
|
|
428
958
|
rootPath: absRoot,
|
|
429
959
|
};
|
|
@@ -431,6 +961,7 @@ function contentApiPlugin(): Plugin {
|
|
|
431
961
|
if (existingIndex === -1) manifest.projects.push(project);
|
|
432
962
|
else manifest.projects.splice(existingIndex, 1, project);
|
|
433
963
|
await saveManifest(manifest);
|
|
964
|
+
broadcast("workspace-changed", { action: "project-added", projectId: project.id, actorId: actor?.actorId ?? null });
|
|
434
965
|
return send(res, 200, { cancelled: false, workspace: await toWorkspaceResponse(await getWorkspace()), addedProjectId: project.id });
|
|
435
966
|
}
|
|
436
967
|
|
|
@@ -446,6 +977,7 @@ function contentApiPlugin(): Plugin {
|
|
|
446
977
|
const root = refreshedProject?.docsRoots.find((item) => item.absPath === docsPath)
|
|
447
978
|
?? refreshedProject?.docsRoots[0];
|
|
448
979
|
if (!root) return send(res, 500, { error: "Could not create docs root" });
|
|
980
|
+
broadcast("workspace-changed", { action: "docs-root-created", projectId: project.id, rootId: root.id, actorId: actor?.actorId ?? null });
|
|
449
981
|
return send(res, 200, {
|
|
450
982
|
root: {
|
|
451
983
|
id: root.id,
|
|
@@ -470,6 +1002,7 @@ function contentApiPlugin(): Plugin {
|
|
|
470
1002
|
const refreshedProject = workspace.find((item) => item.id === project.id);
|
|
471
1003
|
const root = refreshedProject?.docsRoots.find((item) => item.absPath === docsPath);
|
|
472
1004
|
if (!root) return send(res, 500, { error: "Could not create docs area" });
|
|
1005
|
+
broadcast("workspace-changed", { action: "docs-area-created", projectId: project.id, rootId: root.id, actorId: actor?.actorId ?? null });
|
|
473
1006
|
return send(res, 200, {
|
|
474
1007
|
root: {
|
|
475
1008
|
id: root.id,
|
|
@@ -489,6 +1022,7 @@ function contentApiPlugin(): Plugin {
|
|
|
489
1022
|
if (!project) return send(res, 404, { error: "Project not found" });
|
|
490
1023
|
const docsPath = path.join(project.rootPath, String(parentPath ?? ""), "docs");
|
|
491
1024
|
await fs.mkdir(docsPath, { recursive: true });
|
|
1025
|
+
broadcast("workspace-changed", { action: "docs-folder-created", projectId: project.id, actorId: actor?.actorId ?? null });
|
|
492
1026
|
return send(res, 200, { ok: true, workspace: await toWorkspaceResponse(await getWorkspace()) });
|
|
493
1027
|
}
|
|
494
1028
|
|
|
@@ -497,6 +1031,7 @@ function contentApiPlugin(): Plugin {
|
|
|
497
1031
|
const manifest = await loadManifest();
|
|
498
1032
|
manifest.projects = manifest.projects.filter((item) => item.id !== id);
|
|
499
1033
|
await saveManifest(manifest);
|
|
1034
|
+
broadcast("workspace-changed", { action: "project-removed", projectId: id, actorId: actor?.actorId ?? null });
|
|
500
1035
|
return send(res, 200, await toWorkspaceResponse(await getWorkspace()));
|
|
501
1036
|
}
|
|
502
1037
|
|
|
@@ -526,21 +1061,38 @@ function contentApiPlugin(): Plugin {
|
|
|
526
1061
|
if (!abs) return send(res, 400, { error: "Invalid path" });
|
|
527
1062
|
try {
|
|
528
1063
|
const content = await fs.readFile(abs, "utf-8");
|
|
529
|
-
return send(res, 200, { path: rel, content });
|
|
1064
|
+
return send(res, 200, { path: rel, content, revision: revisionForText(content) });
|
|
530
1065
|
} catch {
|
|
531
1066
|
return send(res, 404, { error: "Not found" });
|
|
532
1067
|
}
|
|
533
1068
|
}
|
|
534
1069
|
|
|
535
1070
|
if (route === "/__api/file" && method === "PUT") {
|
|
536
|
-
const { rootId, path: rel, content } = JSON.parse(await readBody(req));
|
|
1071
|
+
const { rootId, path: rel, content, expectedRevision } = JSON.parse(await readBody(req));
|
|
537
1072
|
const resolved = await resolveRoot(String(rootId ?? ""));
|
|
538
1073
|
if (!resolved) return send(res, 404, { error: "Docs root not found" });
|
|
539
1074
|
const abs = safeResolve(resolved.root.absPath, String(rel));
|
|
540
1075
|
if (!abs) return send(res, 400, { error: "Invalid path" });
|
|
1076
|
+
let currentContent: string | null = null;
|
|
1077
|
+
try {
|
|
1078
|
+
currentContent = await fs.readFile(abs, "utf-8");
|
|
1079
|
+
} catch (err) {
|
|
1080
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
1081
|
+
}
|
|
1082
|
+
const currentRevision = currentContent === null ? null : revisionForText(currentContent);
|
|
1083
|
+
if (typeof expectedRevision === "string" && expectedRevision !== currentRevision) {
|
|
1084
|
+
return send(res, 409, {
|
|
1085
|
+
error: "File changed on disk",
|
|
1086
|
+
currentRevision,
|
|
1087
|
+
path: rel,
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
541
1090
|
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
542
|
-
|
|
543
|
-
|
|
1091
|
+
const nextContent = String(content ?? "");
|
|
1092
|
+
await fs.writeFile(abs, nextContent, "utf-8");
|
|
1093
|
+
const revision = revisionForText(nextContent);
|
|
1094
|
+
broadcast("file-changed", { rootId: resolved.root.id, path: String(rel), action: "write", actorId: actor?.actorId ?? null });
|
|
1095
|
+
return send(res, 200, { ok: true, path: rel, revision });
|
|
544
1096
|
}
|
|
545
1097
|
|
|
546
1098
|
if (route === "/__api/directory" && method === "PUT") {
|
|
@@ -550,6 +1102,7 @@ function contentApiPlugin(): Plugin {
|
|
|
550
1102
|
const abs = safeResolve(resolved.root.absPath, String(rel));
|
|
551
1103
|
if (!abs) return send(res, 400, { error: "Invalid path" });
|
|
552
1104
|
await fs.mkdir(abs, { recursive: true });
|
|
1105
|
+
broadcast("workspace-changed", { action: "directory-created", rootId: resolved.root.id, path: String(rel), actorId: actor?.actorId ?? null });
|
|
553
1106
|
return send(res, 200, { ok: true, path: rel });
|
|
554
1107
|
}
|
|
555
1108
|
|
|
@@ -565,6 +1118,7 @@ function contentApiPlugin(): Plugin {
|
|
|
565
1118
|
} catch (err) {
|
|
566
1119
|
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
567
1120
|
}
|
|
1121
|
+
broadcast("file-changed", { rootId: resolved.root.id, path: rel, action: "delete", actorId: actor?.actorId ?? null });
|
|
568
1122
|
return send(res, 200, { ok: true });
|
|
569
1123
|
}
|
|
570
1124
|
|
|
@@ -585,6 +1139,7 @@ function contentApiPlugin(): Plugin {
|
|
|
585
1139
|
}
|
|
586
1140
|
throw err;
|
|
587
1141
|
}
|
|
1142
|
+
broadcast("file-changed", { rootId: resolved.root.id, path: String(newPath), oldPath: String(oldPath), action: "rename", actorId: actor?.actorId ?? null });
|
|
588
1143
|
return send(res, 200, { ok: true, path: newPath });
|
|
589
1144
|
}
|
|
590
1145
|
|
|
@@ -597,21 +1152,38 @@ function contentApiPlugin(): Plugin {
|
|
|
597
1152
|
if (!abs) return send(res, 400, { error: "Invalid id" });
|
|
598
1153
|
try {
|
|
599
1154
|
const content = await fs.readFile(abs, "utf-8");
|
|
600
|
-
return send(res, 200, { id, doc: JSON.parse(content) });
|
|
1155
|
+
return send(res, 200, { id, doc: JSON.parse(content), revision: revisionForText(content) });
|
|
601
1156
|
} catch {
|
|
602
1157
|
return send(res, 404, { error: "Not found" });
|
|
603
1158
|
}
|
|
604
1159
|
}
|
|
605
1160
|
|
|
606
1161
|
if (route === "/__api/board" && method === "PUT") {
|
|
607
|
-
const { rootId, id, doc } = JSON.parse(await readBody(req));
|
|
1162
|
+
const { rootId, id, doc, expectedRevision } = JSON.parse(await readBody(req));
|
|
608
1163
|
const resolved = await resolveRoot(String(rootId ?? ""));
|
|
609
1164
|
if (!resolved) return send(res, 404, { error: "Docs root not found" });
|
|
610
1165
|
const abs = safeResolve(resolved.root.absPath, `${String(id)}.board.json`);
|
|
611
1166
|
if (!abs) return send(res, 400, { error: "Invalid id" });
|
|
1167
|
+
let currentContent: string | null = null;
|
|
1168
|
+
try {
|
|
1169
|
+
currentContent = await fs.readFile(abs, "utf-8");
|
|
1170
|
+
} catch (err) {
|
|
1171
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
1172
|
+
}
|
|
1173
|
+
const currentRevision = currentContent === null ? null : revisionForText(currentContent);
|
|
1174
|
+
if (typeof expectedRevision === "string" && expectedRevision !== currentRevision) {
|
|
1175
|
+
return send(res, 409, {
|
|
1176
|
+
error: "Board changed on disk",
|
|
1177
|
+
currentRevision,
|
|
1178
|
+
id,
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
612
1181
|
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
613
|
-
|
|
614
|
-
|
|
1182
|
+
const nextContent = JSON.stringify(doc, null, 2) + "\n";
|
|
1183
|
+
await fs.writeFile(abs, nextContent, "utf-8");
|
|
1184
|
+
const revision = revisionForText(nextContent);
|
|
1185
|
+
broadcast("board-changed", { rootId: resolved.root.id, boardId: String(id), action: "write", actorId: actor?.actorId ?? null });
|
|
1186
|
+
return send(res, 200, { ok: true, id, revision });
|
|
615
1187
|
}
|
|
616
1188
|
|
|
617
1189
|
if (route === "/__api/board" && method === "DELETE") {
|
|
@@ -626,6 +1198,7 @@ function contentApiPlugin(): Plugin {
|
|
|
626
1198
|
} catch (err) {
|
|
627
1199
|
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
628
1200
|
}
|
|
1201
|
+
broadcast("board-changed", { rootId: resolved.root.id, boardId: id, action: "delete", actorId: actor?.actorId ?? null });
|
|
629
1202
|
return send(res, 200, { ok: true, id });
|
|
630
1203
|
}
|
|
631
1204
|
|
|
@@ -648,23 +1221,37 @@ function contentApiPlugin(): Plugin {
|
|
|
648
1221
|
}
|
|
649
1222
|
|
|
650
1223
|
if (route === "/__api/asset" && method === "POST") {
|
|
651
|
-
const { rootId, name, base64 } = JSON.parse(await readBody(req));
|
|
1224
|
+
const { rootId, name, base64, mimeType } = JSON.parse(await readBody(req));
|
|
652
1225
|
const resolved = await resolveRoot(String(rootId ?? ""));
|
|
653
1226
|
if (!resolved) return send(res, 404, { error: "Docs root not found" });
|
|
654
|
-
const
|
|
655
|
-
const
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
.slice(0, 40);
|
|
659
|
-
const unique = `${safeBase || "image"}-${Date.now().toString(36)}${ext}`;
|
|
660
|
-
const rel = `assets/${unique}`;
|
|
1227
|
+
const bytes = Buffer.from(String(base64), "base64");
|
|
1228
|
+
const ext = MIME_EXT[String(mimeType ?? "")] ?? (path.extname(String(name ?? "")) || ".png").toLowerCase();
|
|
1229
|
+
const digest = createHash("sha256").update(bytes).digest("hex");
|
|
1230
|
+
const rel = `assets/${digest}${ext}`;
|
|
661
1231
|
const abs = safeResolve(resolved.root.absPath, rel);
|
|
662
1232
|
if (!abs) return send(res, 400, { error: "Invalid path" });
|
|
663
1233
|
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
664
|
-
|
|
1234
|
+
try {
|
|
1235
|
+
await fs.access(abs);
|
|
1236
|
+
} catch {
|
|
1237
|
+
await fs.writeFile(abs, bytes);
|
|
1238
|
+
}
|
|
1239
|
+
broadcast("asset-changed", { rootId: resolved.root.id, path: rel, action: "write", actorId: actor?.actorId ?? null });
|
|
665
1240
|
return send(res, 200, { ok: true, path: rel });
|
|
666
1241
|
}
|
|
667
1242
|
|
|
1243
|
+
if (route === "/__api/asset/cleanup" && method === "POST") {
|
|
1244
|
+
const { rootId, preservePaths } = JSON.parse(await readBody(req));
|
|
1245
|
+
const resolved = await resolveRoot(String(rootId ?? ""));
|
|
1246
|
+
if (!resolved) return send(res, 404, { error: "Docs root not found" });
|
|
1247
|
+
const removed = await cleanupManagedAssets(
|
|
1248
|
+
resolved.root.absPath,
|
|
1249
|
+
Array.isArray(preservePaths) ? preservePaths.filter((item): item is string => typeof item === "string") : []
|
|
1250
|
+
);
|
|
1251
|
+
if (removed > 0) broadcast("asset-changed", { rootId: resolved.root.id, action: "cleanup", removed, actorId: actor?.actorId ?? null });
|
|
1252
|
+
return send(res, 200, { ok: true, removed });
|
|
1253
|
+
}
|
|
1254
|
+
|
|
668
1255
|
if (route === "/__api/workspace-file" && method === "GET") {
|
|
669
1256
|
await ensureWorkspaceDir();
|
|
670
1257
|
const manifest = await loadManifest();
|