studiograph 1.3.48-next.76 → 1.3.48-next.78
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/dist/agent/prompts/entity-types-section.d.ts +8 -0
- package/dist/agent/prompts/entity-types-section.js +27 -2
- package/dist/agent/prompts/entity-types-section.js.map +1 -1
- package/dist/agent/tools/folder-tools.js +18 -7
- package/dist/agent/tools/folder-tools.js.map +1 -1
- package/dist/agent/tools/graph-tools.js +65 -9
- package/dist/agent/tools/graph-tools.js.map +1 -1
- package/dist/cli/commands/index.js +1 -1
- package/dist/cli/commands/index.js.map +1 -1
- package/dist/core/entity-types-catalog.js +2 -0
- package/dist/core/entity-types-catalog.js.map +1 -1
- package/dist/core/graph.d.ts +106 -2
- package/dist/core/graph.js +281 -10
- package/dist/core/graph.js.map +1 -1
- package/dist/core/validation.d.ts +85 -4
- package/dist/core/validation.js +34 -3
- package/dist/core/validation.js.map +1 -1
- package/dist/core/workspace-manager.js +3 -1
- package/dist/core/workspace-manager.js.map +1 -1
- package/dist/mcp/tools.js +6 -1
- package/dist/mcp/tools.js.map +1 -1
- package/dist/server/routes/asset-api.js +29 -7
- package/dist/server/routes/asset-api.js.map +1 -1
- package/dist/server/routes/graph-api.js +290 -38
- package/dist/server/routes/graph-api.js.map +1 -1
- package/dist/services/asset-upload-service.d.ts +53 -0
- package/dist/services/asset-upload-service.js +199 -0
- package/dist/services/asset-upload-service.js.map +1 -0
- package/dist/services/assets/base.d.ts +15 -0
- package/dist/services/assets/base.js +63 -0
- package/dist/services/assets/base.js.map +1 -1
- package/dist/services/assets/index.d.ts +17 -0
- package/dist/services/assets/index.js +17 -0
- package/dist/services/assets/index.js.map +1 -1
- package/dist/services/import-service.d.ts +33 -1
- package/dist/services/import-service.js +234 -48
- package/dist/services/import-service.js.map +1 -1
- package/dist/services/vector-service.d.ts +20 -2
- package/dist/services/vector-service.js +46 -5
- package/dist/services/vector-service.js.map +1 -1
- package/dist/web/_app/immutable/assets/0.C-U08O83.css +2 -0
- package/dist/web/_app/immutable/assets/2.D4UyD6ef.css +1 -0
- package/dist/web/_app/immutable/assets/{4.DMWmOCtr.css → 4.CMuW9ER9.css} +1 -1
- package/dist/web/_app/immutable/chunks/{BxqZoi16.js → B2dQ_HtA.js} +1 -1
- package/dist/web/_app/immutable/chunks/{CbChB7Sk.js → BVG-59G_.js} +1 -1
- package/dist/web/_app/immutable/chunks/{CcKbicMh.js → BYMboxS1.js} +1 -1
- package/dist/web/_app/immutable/chunks/BcnRmtrP2.js +1 -0
- package/dist/web/_app/immutable/chunks/Bi49oCkW.js +1 -0
- package/dist/web/_app/immutable/chunks/{9wFWX30W.js → BkG8IWXL.js} +1 -1
- package/dist/web/_app/immutable/chunks/BljXMCRN2.js +1 -0
- package/dist/web/_app/immutable/chunks/C1eadUOT.js +1 -0
- package/dist/web/_app/immutable/chunks/{DgmiIA_n.js → C7ZXf1Ei.js} +1 -1
- package/dist/web/_app/immutable/chunks/{BGQcC8WM.js → C9fPdp9e.js} +1 -1
- package/dist/web/_app/immutable/chunks/{CpjCnsRC2.js → CABEPnKi2.js} +1 -1
- package/dist/web/_app/immutable/chunks/{C4o1fi_w.js → CFFhRHgg.js} +1 -1
- package/dist/web/_app/immutable/chunks/{C3q48eWV.js → CMv-UMQD.js} +1 -1
- package/dist/web/_app/immutable/chunks/{14Ks5neZ.js → CO4LsWNt.js} +1 -1
- package/dist/web/_app/immutable/chunks/{DNod4IR72.js → CXGYcrGq2.js} +1 -1
- package/dist/web/_app/immutable/chunks/ClxmxcVF.js +1 -0
- package/dist/web/_app/immutable/chunks/Co7pw0iy.js +1 -0
- package/dist/web/_app/immutable/chunks/Cu_13EXg.js +8 -0
- package/dist/web/_app/immutable/chunks/{DxmEO50C.js → Cy1yuSCN.js} +1 -1
- package/dist/web/_app/immutable/chunks/{_VOdw24P.js → CzT0T6FJ.js} +1 -1
- package/dist/web/_app/immutable/chunks/{1TGSC9bG.js → D1AVtJaJ.js} +1 -1
- package/dist/web/_app/immutable/chunks/{CHdVOK6e.js → D6ObqpSX.js} +1 -1
- package/dist/web/_app/immutable/chunks/DBMIhZt72.js +1 -0
- package/dist/web/_app/immutable/chunks/{CUVp7kiS.js → DFE7ldAP.js} +1 -1
- package/dist/web/_app/immutable/chunks/{f1DrsN7w.js → DKDDh_Qn.js} +1 -1
- package/dist/web/_app/immutable/chunks/DNneJjJN.js +1 -0
- package/dist/web/_app/immutable/chunks/{r4OgPVQH.js → DOastlFY.js} +1 -1
- package/dist/web/_app/immutable/chunks/DPHJu-Bj.js +5 -0
- package/dist/web/_app/immutable/chunks/{D00taIEV.js → DQKT4stP.js} +1 -1
- package/dist/web/_app/immutable/chunks/DQe4a6BS.js +1 -0
- package/dist/web/_app/immutable/chunks/{Ce2P7b-X.js → DQpUZZbo.js} +1 -1
- package/dist/web/_app/immutable/chunks/DVxZLm0W.js +2 -0
- package/dist/web/_app/immutable/chunks/{Bzy2ehL_.js → DYbq0sax.js} +1 -1
- package/dist/web/_app/immutable/chunks/{hlnmlkiT.js → Ddr2JLfY.js} +1 -1
- package/dist/web/_app/immutable/chunks/DeQkDfnZ.js +1 -0
- package/dist/web/_app/immutable/chunks/Df-4W2cE2.js +2 -0
- package/dist/web/_app/immutable/chunks/{e9oCENPh.js → Dfu55H0O.js} +1 -1
- package/dist/web/_app/immutable/chunks/{DDQLyGcm.js → DhERF52D.js} +1 -1
- package/dist/web/_app/immutable/chunks/{C2UI_KQ1.js → Dt_-Caf8.js} +1 -1
- package/dist/web/_app/immutable/chunks/DxS997Bi.js +1 -0
- package/dist/web/_app/immutable/chunks/{ChPgixWE.js → P-vv4BQi2.js} +1 -1
- package/dist/web/_app/immutable/chunks/{Fgrpvl6l.js → UqWZFiDw.js} +1 -1
- package/dist/web/_app/immutable/chunks/{DPXpm86N.js → ZhCPwTv_.js} +1 -1
- package/dist/web/_app/immutable/chunks/anxNIaXZ.js +1 -0
- package/dist/web/_app/immutable/chunks/{BO35Fv1U.js → dTznEU-N.js} +4 -4
- package/dist/web/_app/immutable/chunks/{wY5DYt0W.js → iqOW8ttz.js} +1 -1
- package/dist/web/_app/immutable/chunks/{1h1Q-dyp.js → j7kuJOP8.js} +1 -1
- package/dist/web/_app/immutable/chunks/{IXuV70l3.js → mbVGz96e.js} +1 -1
- package/dist/web/_app/immutable/chunks/rmFsC7j32.js +1 -0
- package/dist/web/_app/immutable/chunks/{CLBsX7ax.js → sdhQg9Jq.js} +1 -1
- package/dist/web/_app/immutable/entry/{app.DbP3pP9I.js → app.BUM2MbTk.js} +2 -2
- package/dist/web/_app/immutable/entry/start.BObvxb93.js +1 -0
- package/dist/web/_app/immutable/nodes/{0.CirGvHiz.js → 0.BMXqKYhl.js} +2 -2
- package/dist/web/_app/immutable/nodes/{1.kTjENnhN.js → 1.QaGjZyIl.js} +1 -1
- package/dist/web/_app/immutable/nodes/10.F1nNX_z9.js +1 -0
- package/dist/web/_app/immutable/nodes/{11.DIQCUxzh.js → 11.BfZsU6Vh.js} +1 -1
- package/dist/web/_app/immutable/nodes/{12.DOlW7sto.js → 12.C5hRguPI.js} +1 -1
- package/dist/web/_app/immutable/nodes/{13.qikQg_xf.js → 13.BqOI1wyC.js} +1 -1
- package/dist/web/_app/immutable/nodes/2.CCWtRh8O.js +130 -0
- package/dist/web/_app/immutable/nodes/3.DDPCGvvJ.js +2 -0
- package/dist/web/_app/immutable/nodes/4.BhR6-Pss.js +11 -0
- package/dist/web/_app/immutable/nodes/5.DkAirE7a.js +1 -0
- package/dist/web/_app/immutable/nodes/{6.C1tDoWWj.js → 6.CF014BWC.js} +1 -1
- package/dist/web/_app/immutable/nodes/{7.ZR0ZhRen.js → 7.B6NRypBk.js} +1 -1
- package/dist/web/_app/immutable/nodes/{8.DUZm9iwH.js → 8.B4moM7KQ.js} +1 -1
- package/dist/web/_app/immutable/nodes/{9.BuV4JbTd.js → 9.DVcGpCuy.js} +1 -1
- package/dist/web/_app/version.json +1 -1
- package/dist/web/index.html +16 -16
- package/package.json +1 -1
- package/dist/web/_app/immutable/assets/0.DNW-Nd5m.css +0 -2
- package/dist/web/_app/immutable/assets/2.R677OFd1.css +0 -1
- package/dist/web/_app/immutable/chunks/-jdP5JqO.js +0 -5
- package/dist/web/_app/immutable/chunks/B-z4a3eR.js +0 -1
- package/dist/web/_app/immutable/chunks/B6o1oq9U2.js +0 -2
- package/dist/web/_app/immutable/chunks/BDCOho_G.js +0 -1
- package/dist/web/_app/immutable/chunks/Be0wk0_s2.js +0 -1
- package/dist/web/_app/immutable/chunks/C7o6G8Ak2.js +0 -1
- package/dist/web/_app/immutable/chunks/CHRfvdkq2.js +0 -1
- package/dist/web/_app/immutable/chunks/CSWuNPeq.js +0 -1
- package/dist/web/_app/immutable/chunks/CTFOXiYo.js +0 -1
- package/dist/web/_app/immutable/chunks/CXYTfe_P2.js +0 -1
- package/dist/web/_app/immutable/chunks/DBATbpMA.js +0 -2
- package/dist/web/_app/immutable/chunks/DDzRa0WU.js +0 -1
- package/dist/web/_app/immutable/chunks/DTnTQDdN.js +0 -1
- package/dist/web/_app/immutable/chunks/DYG9AA8k.js +0 -8
- package/dist/web/_app/immutable/chunks/DhikmMsg2.js +0 -1
- package/dist/web/_app/immutable/chunks/Pr10v8I7.js +0 -1
- package/dist/web/_app/immutable/chunks/q69OiNAp.js +0 -1
- package/dist/web/_app/immutable/entry/start.Cl9IsfI4.js +0 -1
- package/dist/web/_app/immutable/nodes/10.5xATla_3.js +0 -1
- package/dist/web/_app/immutable/nodes/2.CiklFX4J.js +0 -130
- package/dist/web/_app/immutable/nodes/3.Byts3_Iy.js +0 -2
- package/dist/web/_app/immutable/nodes/4.BWfkHbqv.js +0 -11
- package/dist/web/_app/immutable/nodes/5.tH1sz1qb.js +0 -1
- /package/dist/web/_app/immutable/chunks/{D5VdX2NC.js → CQwUl_3l.js} +0 -0
- /package/dist/web/_app/immutable/chunks/{EdMwt2az.js → CvZmtMIf.js} +0 -0
- /package/dist/web/_app/immutable/chunks/{CUyyAHze.js → Dn6yJ9ZG.js} +0 -0
- /package/dist/web/_app/immutable/chunks/{DXeZwJCC2.js → rsjIo1y12.js} +0 -0
package/dist/core/graph.d.ts
CHANGED
|
@@ -20,6 +20,10 @@ export interface GraphConfig {
|
|
|
20
20
|
schemaRegistry?: SchemaRegistry;
|
|
21
21
|
/** When true, all git operations are skipped. Used for the private/ graph. */
|
|
22
22
|
noGit?: boolean;
|
|
23
|
+
/** Workspace root path. Used to anchor cross-repo artefacts like the
|
|
24
|
+
* PDF text sidecar at `<workspaceRoot>/.studiograph/assets-text/...`.
|
|
25
|
+
* Optional — when undefined, sidecar cleanup is a no-op. */
|
|
26
|
+
workspaceRoot?: string;
|
|
23
27
|
}
|
|
24
28
|
export interface EntityFile {
|
|
25
29
|
id: string;
|
|
@@ -35,6 +39,10 @@ export interface FolderEntry {
|
|
|
35
39
|
path: string;
|
|
36
40
|
/** Last segment of the path. */
|
|
37
41
|
name: string;
|
|
42
|
+
/** Optional human-readable label. Set only when an explicit override exists
|
|
43
|
+
* in the folder's `.studiograph-folder.json` sidecar; clients fall back to
|
|
44
|
+
* toDisplayName(name) when undefined. */
|
|
45
|
+
display_name?: string;
|
|
38
46
|
}
|
|
39
47
|
export interface FolderDeleteResult {
|
|
40
48
|
entriesDeleted: number;
|
|
@@ -89,9 +97,15 @@ export declare class BaseGraphManager {
|
|
|
89
97
|
private csvService;
|
|
90
98
|
private vectorService?;
|
|
91
99
|
private schemaRegistry?;
|
|
100
|
+
private workspaceRoot?;
|
|
92
101
|
constructor(config: GraphConfig);
|
|
93
102
|
/** Expose git service for deferred commit operations (SessionManager). */
|
|
94
103
|
getGitService(): GitService | undefined;
|
|
104
|
+
/** Workspace root passed in via GraphConfig. Used by the asset upload
|
|
105
|
+
* service to anchor PDF text sidecars at workspace level (so all repos
|
|
106
|
+
* share the same `assets-text/` tree, mirroring the shared
|
|
107
|
+
* `.studiograph/assets/` directory the local backend uses). */
|
|
108
|
+
getWorkspaceRoot(): string | undefined;
|
|
95
109
|
/**
|
|
96
110
|
* Ensure repository exists
|
|
97
111
|
*/
|
|
@@ -171,6 +185,62 @@ export declare class BaseGraphManager {
|
|
|
171
185
|
/**
|
|
172
186
|
* Delete an entity
|
|
173
187
|
*/
|
|
188
|
+
/**
|
|
189
|
+
* Delete multiple entities in a single git transaction. Each entity's
|
|
190
|
+
* file is removed from disk; folder markers (.gitkeep) are added where
|
|
191
|
+
* the deletion left a folder empty. Then everything commits as one
|
|
192
|
+
* atomic operation.
|
|
193
|
+
*
|
|
194
|
+
* Compared to looping `delete()` per entity, this is a real performance
|
|
195
|
+
* primitive: one fork of git, one index update, one commit, one ref
|
|
196
|
+
* advance — instead of N. Bulk delete of a few hundred entities goes
|
|
197
|
+
* from minutes to seconds.
|
|
198
|
+
*
|
|
199
|
+
* Errors per entity (e.g. "not found") don't abort the batch — they're
|
|
200
|
+
* collected in `errors` and the rest still commit. Returns both the
|
|
201
|
+
* successfully-deleted set (for vector cleanup + WS broadcast) and the
|
|
202
|
+
* errors so the caller can surface partial failures.
|
|
203
|
+
*/
|
|
204
|
+
bulkDelete(specs: Array<{
|
|
205
|
+
entityType: EntityType;
|
|
206
|
+
entityId: string;
|
|
207
|
+
}>, options?: {
|
|
208
|
+
commitMessage?: string;
|
|
209
|
+
user?: GitUser;
|
|
210
|
+
}): Promise<{
|
|
211
|
+
deleted: Array<{
|
|
212
|
+
entityType: EntityType;
|
|
213
|
+
entityId: string;
|
|
214
|
+
path: string;
|
|
215
|
+
}>;
|
|
216
|
+
errors: Array<{
|
|
217
|
+
entityType: EntityType;
|
|
218
|
+
entityId: string;
|
|
219
|
+
error: string;
|
|
220
|
+
}>;
|
|
221
|
+
}>;
|
|
222
|
+
/**
|
|
223
|
+
* Commit a set of already-on-disk file changes as a single git
|
|
224
|
+
* operation. Used by callers (e.g. the import route) that perform many
|
|
225
|
+
* `skipCommit: true` writes and want one transaction at the end instead
|
|
226
|
+
* of per-file commits.
|
|
227
|
+
*
|
|
228
|
+
* `entries` describes what each file represents so the transaction can
|
|
229
|
+
* be staged correctly. No-op when nothing is staged or git isn't
|
|
230
|
+
* available.
|
|
231
|
+
*/
|
|
232
|
+
commitPending(entries: Array<{
|
|
233
|
+
operation: 'create' | 'update' | 'delete';
|
|
234
|
+
entityType: EntityType;
|
|
235
|
+
entityId: string;
|
|
236
|
+
filePath: string;
|
|
237
|
+
}>, options: {
|
|
238
|
+
commitMessage: string;
|
|
239
|
+
user?: GitUser;
|
|
240
|
+
extraPaths?: string[];
|
|
241
|
+
}): Promise<{
|
|
242
|
+
committed: boolean;
|
|
243
|
+
}>;
|
|
174
244
|
delete(entityType: EntityType, entityId: string, commitMessage?: string, user?: GitUser): Promise<void>;
|
|
175
245
|
/**
|
|
176
246
|
* Rename an entity (change its ID). Preserves all data, content, and source_ref.
|
|
@@ -285,18 +355,52 @@ export declare class BaseGraphManager {
|
|
|
285
355
|
* The repo root ("") is intentionally omitted — it's implicit.
|
|
286
356
|
*/
|
|
287
357
|
listFolders(): FolderEntry[];
|
|
358
|
+
/**
|
|
359
|
+
* Read the display_name override for a single folder, or null if no sidecar
|
|
360
|
+
* exists / is unreadable. Used by mutation paths that need the current
|
|
361
|
+
* override before deciding whether to rewrite it.
|
|
362
|
+
*/
|
|
363
|
+
readFolderDisplayName(folderPath: string): string | null;
|
|
364
|
+
/**
|
|
365
|
+
* Write or remove the per-folder display_name sidecar. Pass `null` /
|
|
366
|
+
* empty-string to remove (returns to slug fallback). Returns the absolute
|
|
367
|
+
* path of the sidecar (for git staging) when one was written or removed,
|
|
368
|
+
* or null when no change was needed (idempotent).
|
|
369
|
+
*
|
|
370
|
+
* Caller is responsible for committing — the helper only touches the
|
|
371
|
+
* filesystem. Folder must already exist; this never creates one.
|
|
372
|
+
*/
|
|
373
|
+
writeFolderDisplayName(folderPath: string, displayName: string | null): string | null;
|
|
374
|
+
/**
|
|
375
|
+
* Bulk-write display_name sidecars for many folders at once. Used by the
|
|
376
|
+
* import path to record original-casing overrides for every nested
|
|
377
|
+
* directory it slugified. First-writer-wins: existing sidecars are NOT
|
|
378
|
+
* overwritten, so a second import doesn't clobber a user's manual rename.
|
|
379
|
+
*
|
|
380
|
+
* Returns the list of sidecar paths actually written (for git staging).
|
|
381
|
+
* Skipped paths (folder missing, slug already matches display, sidecar
|
|
382
|
+
* already exists) are quietly omitted.
|
|
383
|
+
*/
|
|
384
|
+
recordFolderDisplays(entries: Array<{
|
|
385
|
+
path: string;
|
|
386
|
+
display_name: string;
|
|
387
|
+
}>): string[];
|
|
288
388
|
/**
|
|
289
389
|
* Create a folder at the given path (mkdir -p semantics) and drop a
|
|
290
390
|
* .gitkeep so git tracks it even when empty. Validates the path. No-op if
|
|
291
391
|
* the folder already exists; .gitkeep is added if missing.
|
|
292
392
|
*/
|
|
293
|
-
createFolder(folderPath: string, commitMessage?: string, user?: GitUser
|
|
393
|
+
createFolder(folderPath: string, commitMessage?: string, user?: GitUser, options?: {
|
|
394
|
+
displayName?: string;
|
|
395
|
+
}): Promise<void>;
|
|
294
396
|
/**
|
|
295
397
|
* Rename / move a folder within the repo. Moves the entire subtree
|
|
296
398
|
* atomically via filesystem rename. Refuses if the target path is already
|
|
297
399
|
* occupied.
|
|
298
400
|
*/
|
|
299
|
-
renameFolder(fromPath: string, toPath: string, commitMessage?: string, user?: GitUser
|
|
401
|
+
renameFolder(fromPath: string, toPath: string, commitMessage?: string, user?: GitUser, options?: {
|
|
402
|
+
displayName?: string | null;
|
|
403
|
+
}): Promise<void>;
|
|
300
404
|
/**
|
|
301
405
|
* Move a folder (and its full subtree) into a different repo at `targetFolderPath`.
|
|
302
406
|
*
|
package/dist/core/graph.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Integrates Git, Markdown, and Asset services.
|
|
6
6
|
*/
|
|
7
7
|
import { join, dirname, relative } from 'path';
|
|
8
|
-
import { existsSync, readdirSync, rmSync, statSync, writeFileSync, mkdirSync } from 'fs';
|
|
8
|
+
import { existsSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, mkdirSync } from 'fs';
|
|
9
9
|
import { GitService } from '../services/git.js';
|
|
10
10
|
import { MarkdownService } from '../services/markdown.js';
|
|
11
11
|
import { AssetService } from '../services/assets/index.js';
|
|
@@ -15,6 +15,34 @@ import { folderPathFromFile, normalizeFolderPath, validateFolderPath, InvalidFol
|
|
|
15
15
|
import { z } from 'zod';
|
|
16
16
|
/** Names skipped during recursive repo walks. */
|
|
17
17
|
const WALK_IGNORE = new Set(['.git', '.studiograph', 'node_modules']);
|
|
18
|
+
/**
|
|
19
|
+
* Per-folder sidecar carrying optional display_name override. Lives inside
|
|
20
|
+
* the folder itself so it travels atomically with FS rename / move / cross-repo
|
|
21
|
+
* transfer — the same way an entity .md file's frontmatter travels with the
|
|
22
|
+
* file. Sparse: only written when display_name differs from the slug fallback
|
|
23
|
+
* (toDisplayName), so kebab-named folders never produce a sidecar.
|
|
24
|
+
*/
|
|
25
|
+
const FOLDER_SIDECAR = '.studiograph-folder.json';
|
|
26
|
+
/**
|
|
27
|
+
* Read the display_name override from a folder's sidecar. Returns null when
|
|
28
|
+
* the sidecar is absent, unreadable, or malformed — drift is tolerated, the
|
|
29
|
+
* caller falls back to toDisplayName(slug) just like a folder with no
|
|
30
|
+
* override at all.
|
|
31
|
+
*/
|
|
32
|
+
function readFolderSidecar(absDir) {
|
|
33
|
+
const sidecarPath = join(absDir, FOLDER_SIDECAR);
|
|
34
|
+
if (!existsSync(sidecarPath))
|
|
35
|
+
return null;
|
|
36
|
+
try {
|
|
37
|
+
const raw = readFileSync(sidecarPath, 'utf-8');
|
|
38
|
+
const parsed = JSON.parse(raw);
|
|
39
|
+
const display = parsed?.display_name;
|
|
40
|
+
return typeof display === 'string' && display.trim() ? display.trim() : null;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
18
46
|
export class BaseGraphManager {
|
|
19
47
|
repoPath;
|
|
20
48
|
repoName;
|
|
@@ -26,6 +54,7 @@ export class BaseGraphManager {
|
|
|
26
54
|
csvService;
|
|
27
55
|
vectorService;
|
|
28
56
|
schemaRegistry;
|
|
57
|
+
workspaceRoot;
|
|
29
58
|
constructor(config) {
|
|
30
59
|
this.repoPath = config.repoPath;
|
|
31
60
|
this.repoName = config.repoName;
|
|
@@ -33,6 +62,7 @@ export class BaseGraphManager {
|
|
|
33
62
|
this.noGit = config.noGit ?? false;
|
|
34
63
|
this.vectorService = config.vectorService;
|
|
35
64
|
this.schemaRegistry = config.schemaRegistry;
|
|
65
|
+
this.workspaceRoot = config.workspaceRoot;
|
|
36
66
|
// Initialize services
|
|
37
67
|
if (!this.noGit) {
|
|
38
68
|
this.gitService = new GitService(this.repoPath);
|
|
@@ -50,6 +80,13 @@ export class BaseGraphManager {
|
|
|
50
80
|
getGitService() {
|
|
51
81
|
return this.gitService;
|
|
52
82
|
}
|
|
83
|
+
/** Workspace root passed in via GraphConfig. Used by the asset upload
|
|
84
|
+
* service to anchor PDF text sidecars at workspace level (so all repos
|
|
85
|
+
* share the same `assets-text/` tree, mirroring the shared
|
|
86
|
+
* `.studiograph/assets/` directory the local backend uses). */
|
|
87
|
+
getWorkspaceRoot() {
|
|
88
|
+
return this.workspaceRoot;
|
|
89
|
+
}
|
|
53
90
|
/**
|
|
54
91
|
* Ensure repository exists
|
|
55
92
|
*/
|
|
@@ -463,11 +500,116 @@ export class BaseGraphManager {
|
|
|
463
500
|
/**
|
|
464
501
|
* Delete an entity
|
|
465
502
|
*/
|
|
503
|
+
/**
|
|
504
|
+
* Delete multiple entities in a single git transaction. Each entity's
|
|
505
|
+
* file is removed from disk; folder markers (.gitkeep) are added where
|
|
506
|
+
* the deletion left a folder empty. Then everything commits as one
|
|
507
|
+
* atomic operation.
|
|
508
|
+
*
|
|
509
|
+
* Compared to looping `delete()` per entity, this is a real performance
|
|
510
|
+
* primitive: one fork of git, one index update, one commit, one ref
|
|
511
|
+
* advance — instead of N. Bulk delete of a few hundred entities goes
|
|
512
|
+
* from minutes to seconds.
|
|
513
|
+
*
|
|
514
|
+
* Errors per entity (e.g. "not found") don't abort the batch — they're
|
|
515
|
+
* collected in `errors` and the rest still commit. Returns both the
|
|
516
|
+
* successfully-deleted set (for vector cleanup + WS broadcast) and the
|
|
517
|
+
* errors so the caller can surface partial failures.
|
|
518
|
+
*/
|
|
519
|
+
async bulkDelete(specs, options) {
|
|
520
|
+
const deleted = [];
|
|
521
|
+
const errors = [];
|
|
522
|
+
const transaction = this.gitService?.createTransaction();
|
|
523
|
+
for (const { entityType, entityId } of specs) {
|
|
524
|
+
try {
|
|
525
|
+
const existing = this.get(entityType, entityId);
|
|
526
|
+
rmSync(existing.path, { force: true });
|
|
527
|
+
// Folder marker if the deletion emptied the folder — same lifecycle
|
|
528
|
+
// semantics as single delete().
|
|
529
|
+
const addedMarker = this.ensureFolderMarker(dirname(existing.path));
|
|
530
|
+
if (transaction) {
|
|
531
|
+
transaction.add({ operation: 'delete', entityType, entityId, filePath: existing.path });
|
|
532
|
+
if (addedMarker) {
|
|
533
|
+
transaction.add({ operation: 'create', entityType, entityId, filePath: addedMarker });
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
deleted.push({ entityType, entityId, path: existing.path });
|
|
537
|
+
}
|
|
538
|
+
catch (err) {
|
|
539
|
+
errors.push({
|
|
540
|
+
entityType,
|
|
541
|
+
entityId,
|
|
542
|
+
error: err instanceof Error ? err.message : String(err),
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
this.invalidateIndex();
|
|
547
|
+
if (transaction && deleted.length > 0) {
|
|
548
|
+
const message = options?.commitMessage
|
|
549
|
+
?? `Delete ${deleted.length} ${deleted.length === 1 ? 'entry' : 'entries'} from web UI`;
|
|
550
|
+
await transaction.commit(options?.user ?? this.gitUser, message);
|
|
551
|
+
}
|
|
552
|
+
// Vector index cleanup, fire-and-forget (matches single delete() behavior).
|
|
553
|
+
for (const { entityType, entityId } of deleted) {
|
|
554
|
+
this.vectorService?.delete(this.repoName, entityType, entityId).catch(err => console.warn(`[vector] delete failed for ${entityType}/${entityId}:`, err.message));
|
|
555
|
+
}
|
|
556
|
+
return { deleted, errors };
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Commit a set of already-on-disk file changes as a single git
|
|
560
|
+
* operation. Used by callers (e.g. the import route) that perform many
|
|
561
|
+
* `skipCommit: true` writes and want one transaction at the end instead
|
|
562
|
+
* of per-file commits.
|
|
563
|
+
*
|
|
564
|
+
* `entries` describes what each file represents so the transaction can
|
|
565
|
+
* be staged correctly. No-op when nothing is staged or git isn't
|
|
566
|
+
* available.
|
|
567
|
+
*/
|
|
568
|
+
async commitPending(entries, options) {
|
|
569
|
+
if (!this.gitService)
|
|
570
|
+
return { committed: false };
|
|
571
|
+
if (entries.length === 0 && !options.extraPaths?.length)
|
|
572
|
+
return { committed: false };
|
|
573
|
+
const transaction = this.gitService.createTransaction();
|
|
574
|
+
for (const e of entries)
|
|
575
|
+
transaction.add(e);
|
|
576
|
+
// Auxiliary paths (folder-display sidecars from the import flow) are
|
|
577
|
+
// staged alongside the entity files so they land in the same commit.
|
|
578
|
+
// The transaction only uses `filePath` for staging — the other fields
|
|
579
|
+
// are metadata, so we fill them with sentinel values.
|
|
580
|
+
if (options.extraPaths?.length) {
|
|
581
|
+
for (const filePath of options.extraPaths) {
|
|
582
|
+
transaction.add({
|
|
583
|
+
operation: 'create',
|
|
584
|
+
entityType: '_sidecar',
|
|
585
|
+
entityId: '_sidecar',
|
|
586
|
+
filePath,
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
await transaction.commit(options.user ?? this.gitUser, options.commitMessage);
|
|
591
|
+
return { committed: true };
|
|
592
|
+
}
|
|
466
593
|
async delete(entityType, entityId, commitMessage, user) {
|
|
467
594
|
// Verify entity exists
|
|
468
595
|
const existing = this.get(entityType, entityId);
|
|
469
596
|
// Delete the flat entity file
|
|
470
597
|
rmSync(existing.path, { force: true });
|
|
598
|
+
// Asset entities own a binary blob (and optionally a PDF text sidecar).
|
|
599
|
+
// Both must be cleaned up — leaving them orphans the storage layer and
|
|
600
|
+
// poisons future entity_id reuse with a stale text dump. Fire-and-forget:
|
|
601
|
+
// the markdown is the system of record, so a stuck binary shouldn't
|
|
602
|
+
// block the delete commit. (Same approach as vector-index cleanup below.)
|
|
603
|
+
if (entityType === 'asset' && this.assetService) {
|
|
604
|
+
this.assetService.deleteAll('asset', entityId).catch(err => console.warn(`[asset] deleteAll failed for asset/${entityId}: ${err?.message ?? err}`));
|
|
605
|
+
if (this.workspaceRoot) {
|
|
606
|
+
const sidecarPath = join(this.workspaceRoot, '.studiograph', 'assets-text', 'asset', `${entityId}.txt`);
|
|
607
|
+
try {
|
|
608
|
+
rmSync(sidecarPath, { force: true });
|
|
609
|
+
}
|
|
610
|
+
catch { /* sidecar cleanup is best-effort */ }
|
|
611
|
+
}
|
|
612
|
+
}
|
|
471
613
|
// Folder lifecycle: deleting an entry never deletes its containing folder.
|
|
472
614
|
// If this was the last .md in the folder, write a .gitkeep so it stays
|
|
473
615
|
// visible to listFolders() (and therefore to the sidebar). A user who
|
|
@@ -537,6 +679,18 @@ export class BaseGraphManager {
|
|
|
537
679
|
* folder when changed to a note), they invoke move() separately.
|
|
538
680
|
*/
|
|
539
681
|
async changeType(entityId, fromType, toType, commitMessage, user) {
|
|
682
|
+
// Asset entities are bound to a binary in the storage backend plus
|
|
683
|
+
// (for PDFs) a text sidecar. Changing TO asset would leave the entity
|
|
684
|
+
// without an asset_url / asset_kind / blob; changing FROM asset would
|
|
685
|
+
// orphan the binary and sidecar. Both directions must be a
|
|
686
|
+
// delete-and-recreate, not a type swap.
|
|
687
|
+
if (toType === 'asset' || fromType === 'asset') {
|
|
688
|
+
const direction = toType === 'asset' ? 'to' : 'from';
|
|
689
|
+
throw new Error(`Cannot change_type ${direction} 'asset' — assets are bound to a binary in storage. ` +
|
|
690
|
+
(toType === 'asset'
|
|
691
|
+
? 'Delete this entry and re-upload the file via the asset upload route to convert it to an asset.'
|
|
692
|
+
: 'Delete this asset entry (which removes the binary) and create a fresh entry of the target type.'));
|
|
693
|
+
}
|
|
540
694
|
const existing = this.get(fromType, entityId);
|
|
541
695
|
// (type, id) uniqueness check — if an entry with the new type already
|
|
542
696
|
// exists with this id, refuse rather than silently colliding in the index.
|
|
@@ -1064,6 +1218,7 @@ export class BaseGraphManager {
|
|
|
1064
1218
|
if (!existsSync(this.repoPath))
|
|
1065
1219
|
return [];
|
|
1066
1220
|
const folders = new Set();
|
|
1221
|
+
const sidecarByPath = new Map();
|
|
1067
1222
|
const stack = [this.repoPath];
|
|
1068
1223
|
const repoRoot = this.repoPath.replace(/\/+$/, '');
|
|
1069
1224
|
while (stack.length > 0) {
|
|
@@ -1076,9 +1231,12 @@ export class BaseGraphManager {
|
|
|
1076
1231
|
continue;
|
|
1077
1232
|
}
|
|
1078
1233
|
let kept = false;
|
|
1234
|
+
let hasSidecar = false;
|
|
1079
1235
|
for (const entry of entries) {
|
|
1080
1236
|
if (entry === '.gitkeep')
|
|
1081
1237
|
kept = true;
|
|
1238
|
+
if (entry === FOLDER_SIDECAR)
|
|
1239
|
+
hasSidecar = true;
|
|
1082
1240
|
if (entry.startsWith('.'))
|
|
1083
1241
|
continue;
|
|
1084
1242
|
if (WALK_IGNORE.has(entry))
|
|
@@ -1096,23 +1254,113 @@ export class BaseGraphManager {
|
|
|
1096
1254
|
}
|
|
1097
1255
|
if (dir === repoRoot)
|
|
1098
1256
|
continue;
|
|
1099
|
-
// Include this directory if it has any .md files OR a .gitkeep marker
|
|
1257
|
+
// Include this directory if it has any .md files OR a .gitkeep marker
|
|
1258
|
+
// OR a sidecar (which carries display metadata even for empty folders).
|
|
1100
1259
|
const hasMarkdown = entries.some(e => e.endsWith('.md') && !e.startsWith('.'));
|
|
1101
|
-
if (hasMarkdown || kept) {
|
|
1102
|
-
|
|
1260
|
+
if (hasMarkdown || kept || hasSidecar) {
|
|
1261
|
+
const folderPath = folderPathFromFile(join(dir, 'placeholder.md'), this.repoPath);
|
|
1262
|
+
folders.add(folderPath);
|
|
1263
|
+
if (hasSidecar) {
|
|
1264
|
+
const display = readFolderSidecar(dir);
|
|
1265
|
+
if (display !== null)
|
|
1266
|
+
sidecarByPath.set(folderPath, display);
|
|
1267
|
+
}
|
|
1103
1268
|
}
|
|
1104
1269
|
}
|
|
1105
1270
|
return [...folders]
|
|
1106
1271
|
.filter(p => p.length > 0)
|
|
1107
1272
|
.sort()
|
|
1108
|
-
.map(p =>
|
|
1273
|
+
.map(p => {
|
|
1274
|
+
const entry = { path: p, name: p.slice(p.lastIndexOf('/') + 1) };
|
|
1275
|
+
const display = sidecarByPath.get(p);
|
|
1276
|
+
if (display)
|
|
1277
|
+
entry.display_name = display;
|
|
1278
|
+
return entry;
|
|
1279
|
+
});
|
|
1280
|
+
}
|
|
1281
|
+
/**
|
|
1282
|
+
* Read the display_name override for a single folder, or null if no sidecar
|
|
1283
|
+
* exists / is unreadable. Used by mutation paths that need the current
|
|
1284
|
+
* override before deciding whether to rewrite it.
|
|
1285
|
+
*/
|
|
1286
|
+
readFolderDisplayName(folderPath) {
|
|
1287
|
+
const normalized = validateFolderPath(folderPath);
|
|
1288
|
+
if (!normalized)
|
|
1289
|
+
return null;
|
|
1290
|
+
return readFolderSidecar(join(this.repoPath, normalized));
|
|
1291
|
+
}
|
|
1292
|
+
/**
|
|
1293
|
+
* Write or remove the per-folder display_name sidecar. Pass `null` /
|
|
1294
|
+
* empty-string to remove (returns to slug fallback). Returns the absolute
|
|
1295
|
+
* path of the sidecar (for git staging) when one was written or removed,
|
|
1296
|
+
* or null when no change was needed (idempotent).
|
|
1297
|
+
*
|
|
1298
|
+
* Caller is responsible for committing — the helper only touches the
|
|
1299
|
+
* filesystem. Folder must already exist; this never creates one.
|
|
1300
|
+
*/
|
|
1301
|
+
writeFolderDisplayName(folderPath, displayName) {
|
|
1302
|
+
const normalized = validateFolderPath(folderPath);
|
|
1303
|
+
if (!normalized) {
|
|
1304
|
+
throw new InvalidFolderPathError('Folder path cannot be empty');
|
|
1305
|
+
}
|
|
1306
|
+
const abs = join(this.repoPath, normalized);
|
|
1307
|
+
if (!existsSync(abs)) {
|
|
1308
|
+
throw new Error(`Folder not found: ${normalized}`);
|
|
1309
|
+
}
|
|
1310
|
+
const sidecarPath = join(abs, FOLDER_SIDECAR);
|
|
1311
|
+
const trimmed = displayName?.trim();
|
|
1312
|
+
if (!trimmed) {
|
|
1313
|
+
if (!existsSync(sidecarPath))
|
|
1314
|
+
return null;
|
|
1315
|
+
rmSync(sidecarPath, { force: true });
|
|
1316
|
+
this.invalidateIndex();
|
|
1317
|
+
return sidecarPath;
|
|
1318
|
+
}
|
|
1319
|
+
const existing = readFolderSidecar(abs);
|
|
1320
|
+
if (existing === trimmed)
|
|
1321
|
+
return null;
|
|
1322
|
+
writeFileSync(sidecarPath, JSON.stringify({ display_name: trimmed }, null, 2) + '\n', 'utf-8');
|
|
1323
|
+
this.invalidateIndex();
|
|
1324
|
+
return sidecarPath;
|
|
1325
|
+
}
|
|
1326
|
+
/**
|
|
1327
|
+
* Bulk-write display_name sidecars for many folders at once. Used by the
|
|
1328
|
+
* import path to record original-casing overrides for every nested
|
|
1329
|
+
* directory it slugified. First-writer-wins: existing sidecars are NOT
|
|
1330
|
+
* overwritten, so a second import doesn't clobber a user's manual rename.
|
|
1331
|
+
*
|
|
1332
|
+
* Returns the list of sidecar paths actually written (for git staging).
|
|
1333
|
+
* Skipped paths (folder missing, slug already matches display, sidecar
|
|
1334
|
+
* already exists) are quietly omitted.
|
|
1335
|
+
*/
|
|
1336
|
+
recordFolderDisplays(entries) {
|
|
1337
|
+
const written = [];
|
|
1338
|
+
for (const entry of entries) {
|
|
1339
|
+
const normalized = validateFolderPath(entry.path);
|
|
1340
|
+
if (!normalized)
|
|
1341
|
+
continue;
|
|
1342
|
+
const abs = join(this.repoPath, normalized);
|
|
1343
|
+
if (!existsSync(abs))
|
|
1344
|
+
continue;
|
|
1345
|
+
const sidecarPath = join(abs, FOLDER_SIDECAR);
|
|
1346
|
+
if (existsSync(sidecarPath))
|
|
1347
|
+
continue; // first-writer-wins
|
|
1348
|
+
const display = entry.display_name.trim();
|
|
1349
|
+
if (!display)
|
|
1350
|
+
continue;
|
|
1351
|
+
writeFileSync(sidecarPath, JSON.stringify({ display_name: display }, null, 2) + '\n', 'utf-8');
|
|
1352
|
+
written.push(sidecarPath);
|
|
1353
|
+
}
|
|
1354
|
+
if (written.length > 0)
|
|
1355
|
+
this.invalidateIndex();
|
|
1356
|
+
return written;
|
|
1109
1357
|
}
|
|
1110
1358
|
/**
|
|
1111
1359
|
* Create a folder at the given path (mkdir -p semantics) and drop a
|
|
1112
1360
|
* .gitkeep so git tracks it even when empty. Validates the path. No-op if
|
|
1113
1361
|
* the folder already exists; .gitkeep is added if missing.
|
|
1114
1362
|
*/
|
|
1115
|
-
async createFolder(folderPath, commitMessage, user) {
|
|
1363
|
+
async createFolder(folderPath, commitMessage, user, options) {
|
|
1116
1364
|
const normalized = validateFolderPath(folderPath);
|
|
1117
1365
|
if (!normalized) {
|
|
1118
1366
|
throw new InvalidFolderPathError('Folder path cannot be empty (use the repo root directly)');
|
|
@@ -1123,10 +1371,16 @@ export class BaseGraphManager {
|
|
|
1123
1371
|
if (!existsSync(keep)) {
|
|
1124
1372
|
writeFileSync(keep, '');
|
|
1125
1373
|
}
|
|
1374
|
+
// Sidecar is written here (not after the commit) so it joins the same
|
|
1375
|
+
// commit as .gitkeep — folder creation is atomic from git's perspective.
|
|
1376
|
+
const sidecarPath = options?.displayName
|
|
1377
|
+
? this.writeFolderDisplayName(normalized, options.displayName)
|
|
1378
|
+
: null;
|
|
1126
1379
|
this.invalidateIndex();
|
|
1127
1380
|
if (this.gitService) {
|
|
1128
1381
|
const message = commitMessage || `Create folder: ${normalized}`;
|
|
1129
|
-
|
|
1382
|
+
const files = sidecarPath ? [keep, sidecarPath] : [keep];
|
|
1383
|
+
this.gitService.commitFiles(files, message, user ?? this.gitUser);
|
|
1130
1384
|
}
|
|
1131
1385
|
}
|
|
1132
1386
|
/**
|
|
@@ -1134,15 +1388,24 @@ export class BaseGraphManager {
|
|
|
1134
1388
|
* atomically via filesystem rename. Refuses if the target path is already
|
|
1135
1389
|
* occupied.
|
|
1136
1390
|
*/
|
|
1137
|
-
async renameFolder(fromPath, toPath, commitMessage, user) {
|
|
1391
|
+
async renameFolder(fromPath, toPath, commitMessage, user, options) {
|
|
1138
1392
|
const fromNormalized = validateFolderPath(fromPath);
|
|
1139
1393
|
const toNormalized = validateFolderPath(toPath);
|
|
1140
1394
|
if (!fromNormalized)
|
|
1141
1395
|
throw new InvalidFolderPathError('Source folder path required');
|
|
1142
1396
|
if (!toNormalized)
|
|
1143
1397
|
throw new InvalidFolderPathError('Target folder path required');
|
|
1144
|
-
|
|
1398
|
+
// Display-only update: same path, just rewrite the sidecar.
|
|
1399
|
+
if (fromNormalized === toNormalized) {
|
|
1400
|
+
if (options?.displayName === undefined)
|
|
1401
|
+
return;
|
|
1402
|
+
const sidecarPath = this.writeFolderDisplayName(fromNormalized, options.displayName);
|
|
1403
|
+
if (sidecarPath && this.gitService) {
|
|
1404
|
+
const message = commitMessage || `Update folder display name: ${fromNormalized}`;
|
|
1405
|
+
this.gitService.commitFiles([sidecarPath], message, user ?? this.gitUser);
|
|
1406
|
+
}
|
|
1145
1407
|
return;
|
|
1408
|
+
}
|
|
1146
1409
|
const fromAbs = join(this.repoPath, fromNormalized);
|
|
1147
1410
|
const toAbs = join(this.repoPath, toNormalized);
|
|
1148
1411
|
if (!existsSync(fromAbs)) {
|
|
@@ -1154,10 +1417,18 @@ export class BaseGraphManager {
|
|
|
1154
1417
|
mkdirSync(dirname(toAbs), { recursive: true });
|
|
1155
1418
|
const { renameSync } = await import('fs');
|
|
1156
1419
|
renameSync(fromAbs, toAbs);
|
|
1420
|
+
// The sidecar moved with the folder via FS rename. Update it now if the
|
|
1421
|
+
// caller passed a new display name (or `null` to clear). Idempotent — no
|
|
1422
|
+
// sidecar write happens when display matches the existing override.
|
|
1423
|
+
let sidecarPath = null;
|
|
1424
|
+
if (options?.displayName !== undefined) {
|
|
1425
|
+
sidecarPath = this.writeFolderDisplayName(toNormalized, options.displayName);
|
|
1426
|
+
}
|
|
1157
1427
|
this.invalidateIndex();
|
|
1158
1428
|
if (this.gitService) {
|
|
1159
1429
|
const message = commitMessage || `Rename folder: ${fromNormalized} → ${toNormalized}`;
|
|
1160
|
-
|
|
1430
|
+
const files = sidecarPath ? [fromAbs, toAbs, sidecarPath] : [fromAbs, toAbs];
|
|
1431
|
+
this.gitService.commitFiles(files, message, user ?? this.gitUser);
|
|
1161
1432
|
}
|
|
1162
1433
|
await this.pruneEmptyAncestors(dirname(fromAbs));
|
|
1163
1434
|
}
|