@tdxvolt/volt-drive-mcp 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,370 @@
1
+ /**
2
+ * Pure helpers for working with a drive's index.
3
+ *
4
+ * A drive stores its structure in two synced Yjs documents:
5
+ *
6
+ * - `__meta`: `idx` is a map of `documentId -> Y.Map` holding, per file,
7
+ * `json` ({ name: "<path>", s: <size> }), `__hash`, `__schema` and
8
+ * `__timestamp`. It is owned by the server; clients never write it directly.
9
+ * - `folders`: `folders` is a map of explicit folder path -> true.
10
+ *
11
+ * Nothing in this module talks to the network, which keeps it unit-testable.
12
+ */
13
+
14
+ import {
15
+ basename,
16
+ compareDrivePaths,
17
+ globToRegExp,
18
+ isWithin,
19
+ parentOf
20
+ } from "./paths.js";
21
+
22
+ /** Id of the synapse document that holds the folder list. */
23
+ export const FOLDERS_DOCUMENT_ID = "folders";
24
+ /** Name of the map inside the folders document. */
25
+ export const FOLDERS_BRANCH = "folders";
26
+ /** Name of the bookkeeping map inside the folders document. */
27
+ export const FOLDERS_STATE_BRANCH = "state";
28
+ /** Name of the index map inside the `__meta` document. */
29
+ export const IDX_BRANCH = "idx";
30
+ /** Id of the document holding the index. */
31
+ export const META_DOCUMENT_ID = "__meta";
32
+
33
+ /** `__schema` types. */
34
+ export const SCHEMA_BLOB = "blob";
35
+ export const SCHEMA_ARRAY = "array";
36
+
37
+ function asYMap(value) {
38
+ if (value && typeof value.get === "function" && typeof value.keys === "function") {
39
+ return value;
40
+ }
41
+ return null;
42
+ }
43
+
44
+ function readJson(docMap) {
45
+ if (!docMap) {
46
+ return null;
47
+ }
48
+ const json = docMap.get("json");
49
+ if (!json || typeof json !== "object" || !json.name) {
50
+ return null;
51
+ }
52
+ return { name: String(json.name), s: Number(json.s ?? 0) || 0 };
53
+ }
54
+
55
+ function readSchema(docMap) {
56
+ const schema = docMap.get("__schema");
57
+ return Array.isArray(schema)
58
+ ? schema.filter((item) => item && typeof item === "object")
59
+ : [];
60
+ }
61
+
62
+ function readSchemaType(schema) {
63
+ if (schema.length !== 1) {
64
+ return "text";
65
+ }
66
+ const type = schema[0]?.type;
67
+ if (type === SCHEMA_BLOB) {
68
+ return SCHEMA_BLOB;
69
+ }
70
+ if (type === SCHEMA_ARRAY) {
71
+ return SCHEMA_ARRAY;
72
+ }
73
+ return "text";
74
+ }
75
+
76
+ /**
77
+ * Derive the flat list of files from the `idx` map.
78
+ *
79
+ * @param {import("yjs").Map<unknown>} idxMap
80
+ * @returns {DriveEntry[]}
81
+ */
82
+ export function readDriveEntries(idxMap) {
83
+ const entries = [];
84
+ for (const documentId of idxMap.keys()) {
85
+ if (documentId === FOLDERS_DOCUMENT_ID) {
86
+ continue;
87
+ }
88
+ const docMap = asYMap(idxMap.get(documentId));
89
+ const json = readJson(docMap);
90
+ if (!json) {
91
+ continue;
92
+ }
93
+ const hash = docMap.get("__hash");
94
+ const timestamp = Number(docMap.get("__timestamp"));
95
+ const schema = readSchema(docMap);
96
+ const schemaType = readSchemaType(schema);
97
+ entries.push({
98
+ documentId,
99
+ path: json.name,
100
+ size: json.s,
101
+ // Both blob documents and the legacy byte-array encoding are binary.
102
+ binary: schemaType === SCHEMA_BLOB || schemaType === SCHEMA_ARRAY,
103
+ schemaType,
104
+ schema,
105
+ contentHash: typeof hash === "string" ? hash : "",
106
+ modified: Number.isFinite(timestamp) && timestamp > 0 ? timestamp : null
107
+ });
108
+ }
109
+ entries.sort((a, b) => compareDrivePaths(a.path, b.path));
110
+ return entries;
111
+ }
112
+
113
+ /**
114
+ * Explicit folder paths from the `folders` map.
115
+ * @param {import("yjs").Map<unknown>} foldersMap
116
+ * @returns {string[]}
117
+ */
118
+ export function readDriveFolders(foldersMap) {
119
+ const folders = [...foldersMap.keys()].filter(Boolean);
120
+ folders.sort(compareDrivePaths);
121
+ return folders;
122
+ }
123
+
124
+ /**
125
+ * The `metadata` of a `SetSynapseDocumentMetadata` request that leaves an
126
+ * existing file's schema as it is.
127
+ *
128
+ * The Volt rewrites a document's schema on every metadata call and rejects a
129
+ * request with none, so even a rename must restate it.
130
+ */
131
+ export function schemaToMetadata(entry) {
132
+ const schema = entry.schema?.length
133
+ ? entry.schema
134
+ : [{ name: "content", type: "text" }];
135
+ return schema.map((item) => {
136
+ const jsonSchema = item.jsonSchema;
137
+ const hasJsonSchema =
138
+ jsonSchema && typeof jsonSchema === "object" && Object.keys(jsonSchema).length > 0;
139
+ return {
140
+ name: String(item.name ?? "content"),
141
+ type: String(item.type ?? "text"),
142
+ ...(hasJsonSchema ? { json_schema: JSON.stringify(jsonSchema) } : {})
143
+ };
144
+ });
145
+ }
146
+
147
+ /** Find the file entry at exactly `path`. */
148
+ export function findEntry(entries, path) {
149
+ return entries.find((entry) => entry.path === path) ?? null;
150
+ }
151
+
152
+ /** True when anything (a file or a folder) lies underneath `folder`. */
153
+ export function hasChildren(entries, folders, folder) {
154
+ const prefix = `${folder}/`;
155
+ return (
156
+ entries.some((entry) => entry.path.startsWith(prefix)) ||
157
+ folders.some((path) => path.startsWith(prefix))
158
+ );
159
+ }
160
+
161
+ /**
162
+ * What lives at `path`: "directory" (the root, an explicit folder, or an
163
+ * implied folder that contains files), "file", or null when nothing does.
164
+ */
165
+ export function pathKind(entries, folders, path) {
166
+ if (!path) {
167
+ return "directory";
168
+ }
169
+ if (findEntry(entries, path)) {
170
+ return "file";
171
+ }
172
+ if (folders.includes(path) || hasChildren(entries, folders, path)) {
173
+ return "directory";
174
+ }
175
+ return null;
176
+ }
177
+
178
+ /**
179
+ * The first ancestor of `path` that is a file, or null. Used to refuse
180
+ * creating `a/b/c` when `a/b` is already a file.
181
+ */
182
+ export function findFileAncestor(entries, path) {
183
+ let slash = path.indexOf("/");
184
+ while (slash > 0) {
185
+ const ancestor = path.slice(0, slash);
186
+ if (findEntry(entries, ancestor)) {
187
+ return ancestor;
188
+ }
189
+ slash = path.indexOf("/", slash + 1);
190
+ }
191
+ return null;
192
+ }
193
+
194
+ /**
195
+ * List the immediate children of `folder` (or every descendant when
196
+ * `recursive`), folders first.
197
+ *
198
+ * @returns {ListedItem[]}
199
+ */
200
+ export function listDirectory(entries, folders, folder, { recursive = false } = {}) {
201
+ const items = new Map();
202
+
203
+ const addFolder = (path) => {
204
+ if (!items.has(path)) {
205
+ items.set(path, {
206
+ type: "directory",
207
+ name: basename(path),
208
+ path,
209
+ size: null,
210
+ modified: null
211
+ });
212
+ }
213
+ return items.get(path);
214
+ };
215
+
216
+ const touchFolderModified = (path, modified) => {
217
+ if (modified === null) {
218
+ return;
219
+ }
220
+ const item = items.get(path);
221
+ if (item && (item.modified === null || modified > item.modified)) {
222
+ item.modified = modified;
223
+ }
224
+ };
225
+
226
+ const prefix = folder ? `${folder}/` : "";
227
+
228
+ for (const entry of entries) {
229
+ if (folder && !entry.path.startsWith(prefix)) {
230
+ continue;
231
+ }
232
+ const rest = entry.path.slice(prefix.length);
233
+ const slash = rest.indexOf("/");
234
+
235
+ if (recursive) {
236
+ // Materialise every folder on the way down to this file.
237
+ let walked = folder;
238
+ const parts = rest.split("/");
239
+ for (let i = 0; i < parts.length - 1; i++) {
240
+ walked = walked ? `${walked}/${parts[i]}` : parts[i];
241
+ addFolder(walked);
242
+ touchFolderModified(walked, entry.modified);
243
+ }
244
+ items.set(entry.path, fileItem(entry));
245
+ } else if (slash < 0) {
246
+ items.set(entry.path, fileItem(entry));
247
+ } else {
248
+ const childFolder = prefix + rest.slice(0, slash);
249
+ addFolder(childFolder);
250
+ touchFolderModified(childFolder, entry.modified);
251
+ }
252
+ }
253
+
254
+ for (const path of folders) {
255
+ if (folder && !path.startsWith(prefix)) {
256
+ continue;
257
+ }
258
+ if (path === folder) {
259
+ continue;
260
+ }
261
+ const rest = path.slice(prefix.length);
262
+ if (recursive) {
263
+ let walked = folder;
264
+ for (const part of rest.split("/")) {
265
+ walked = walked ? `${walked}/${part}` : part;
266
+ addFolder(walked);
267
+ }
268
+ } else {
269
+ const slash = rest.indexOf("/");
270
+ addFolder(prefix + (slash < 0 ? rest : rest.slice(0, slash)));
271
+ }
272
+ }
273
+
274
+ return [...items.values()].sort((a, b) => {
275
+ if (a.type !== b.type) {
276
+ return a.type === "directory" ? -1 : 1;
277
+ }
278
+ return compareDrivePaths(a.path, b.path);
279
+ });
280
+ }
281
+
282
+ function fileItem(entry) {
283
+ return {
284
+ type: "file",
285
+ name: basename(entry.path),
286
+ path: entry.path,
287
+ size: entry.size,
288
+ modified: entry.modified,
289
+ ...(entry.binary ? { binary: true } : {})
290
+ };
291
+ }
292
+
293
+ /**
294
+ * Match paths by name. A query containing `*` or `?` is a glob matched against
295
+ * the whole path (or just the file name when it has no `/`); otherwise it is a
296
+ * case-insensitive substring of the path.
297
+ *
298
+ * @returns {DriveEntry[]} matching file entries under `folder`.
299
+ */
300
+ export function searchEntriesByPath(entries, query, folder = "") {
301
+ const candidates = entries.filter((entry) => isWithin(entry.path, folder));
302
+ if (!query) {
303
+ return candidates;
304
+ }
305
+
306
+ if (/[*?]/.test(query)) {
307
+ const regex = globToRegExp(query);
308
+ const nameOnly = !query.includes("/");
309
+ return candidates.filter((entry) =>
310
+ regex.test(nameOnly ? basename(entry.path) : entry.path)
311
+ );
312
+ }
313
+
314
+ const needle = query.toLowerCase();
315
+ return candidates.filter((entry) => entry.path.toLowerCase().includes(needle));
316
+ }
317
+
318
+ /**
319
+ * Plan the document metadata rewrites needed to move `source` to `target`.
320
+ * Works for a single file and for a whole folder (every file beneath it).
321
+ *
322
+ * @returns {{ rewrites: {entry: DriveEntry, newPath: string}[], folderRenames: {from: string, to: string}[] }}
323
+ */
324
+ export function planMove(entries, folders, source, target) {
325
+ const rewrites = [];
326
+ const file = findEntry(entries, source);
327
+ if (file) {
328
+ rewrites.push({ entry: file, newPath: target });
329
+ return { rewrites, folderRenames: [] };
330
+ }
331
+
332
+ for (const entry of entries) {
333
+ if (isWithin(entry.path, source)) {
334
+ const suffix = entry.path.slice(source.length);
335
+ rewrites.push({ entry, newPath: `${target}${suffix}` });
336
+ }
337
+ }
338
+
339
+ const folderRenames = [];
340
+ for (const path of folders) {
341
+ if (isWithin(path, source)) {
342
+ folderRenames.push({ from: path, to: `${target}${path.slice(source.length)}` });
343
+ }
344
+ }
345
+ return { rewrites, folderRenames };
346
+ }
347
+
348
+ /** Parent folder of `path`, exported for callers working with entries. */
349
+ export { parentOf };
350
+
351
+ /**
352
+ * @typedef {object} DriveEntry
353
+ * @property {string} documentId
354
+ * @property {string} path
355
+ * @property {number} size
356
+ * @property {boolean} binary
357
+ * @property {"text"|"blob"|"array"} schemaType
358
+ * @property {string} contentHash
359
+ * @property {number|null} modified - epoch milliseconds.
360
+ */
361
+
362
+ /**
363
+ * @typedef {object} ListedItem
364
+ * @property {"file"|"directory"} type
365
+ * @property {string} name
366
+ * @property {string} path
367
+ * @property {number|null} size
368
+ * @property {number|null} modified
369
+ * @property {boolean} [binary]
370
+ */