@gmickel/gno 0.34.0 → 0.35.0
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 +37 -26
- package/assets/screenshots/webui-collections.jpg +0 -0
- package/assets/screenshots/webui-graph.jpg +0 -0
- package/package.json +1 -1
- package/src/core/file-ops.ts +12 -1
- package/src/core/file-refactors.ts +180 -0
- package/src/core/note-creation.ts +137 -0
- package/src/core/note-presets.ts +183 -0
- package/src/core/sections.ts +62 -0
- package/src/core/validation.ts +4 -3
- package/src/mcp/tools/capture.ts +71 -12
- package/src/mcp/tools/index.ts +82 -1
- package/src/mcp/tools/workspace-write.ts +321 -0
- package/src/sdk/client.ts +341 -0
- package/src/sdk/types.ts +67 -0
- package/src/serve/CLAUDE.md +3 -1
- package/src/serve/browse-tree.ts +60 -12
- package/src/serve/public/app.tsx +1 -0
- package/src/serve/public/components/CaptureModal.tsx +135 -13
- package/src/serve/public/components/QuickSwitcher.tsx +228 -4
- package/src/serve/public/components/ShortcutHelpModal.tsx +54 -1
- package/src/serve/public/components/editor/MarkdownPreview.tsx +58 -26
- package/src/serve/public/hooks/useCaptureModal.tsx +31 -5
- package/src/serve/public/lib/workspace-actions.ts +226 -0
- package/src/serve/public/lib/workspace-events.ts +39 -0
- package/src/serve/public/pages/Browse.tsx +154 -3
- package/src/serve/public/pages/DocView.tsx +439 -0
- package/src/serve/public/pages/DocumentEditor.tsx +52 -0
- package/src/serve/routes/api.ts +712 -13
- package/src/serve/server.ts +74 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared section extraction and anchor helpers.
|
|
3
|
+
*
|
|
4
|
+
* Browser-safe.
|
|
5
|
+
*
|
|
6
|
+
* @module src/core/sections
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface DocumentSection {
|
|
10
|
+
anchor: string;
|
|
11
|
+
level: number;
|
|
12
|
+
line: number;
|
|
13
|
+
title: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const HEADING_REGEX = /^(#{1,6})\s+(.+?)\s*#*\s*$/u;
|
|
17
|
+
|
|
18
|
+
export function slugifySectionTitle(title: string): string {
|
|
19
|
+
return (
|
|
20
|
+
title
|
|
21
|
+
.normalize("NFC")
|
|
22
|
+
.toLowerCase()
|
|
23
|
+
.trim()
|
|
24
|
+
.replaceAll(/[^\p{L}\p{N}\s-]/gu, "")
|
|
25
|
+
.replaceAll(/\s+/g, "-")
|
|
26
|
+
.replaceAll(/-+/g, "-")
|
|
27
|
+
.replace(/^-|-$/g, "") || "section"
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function extractSections(content: string): DocumentSection[] {
|
|
32
|
+
const sections: DocumentSection[] = [];
|
|
33
|
+
const counts = new Map<string, number>();
|
|
34
|
+
const lines = content.split("\n");
|
|
35
|
+
|
|
36
|
+
for (const [index, line] of lines.entries()) {
|
|
37
|
+
const match = HEADING_REGEX.exec(line);
|
|
38
|
+
if (!match) {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const level = match[1]?.length ?? 0;
|
|
43
|
+
const title = match[2]?.trim() ?? "";
|
|
44
|
+
if (!title) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const baseAnchor = slugifySectionTitle(title);
|
|
49
|
+
const count = (counts.get(baseAnchor) ?? 0) + 1;
|
|
50
|
+
counts.set(baseAnchor, count);
|
|
51
|
+
const anchor = count === 1 ? baseAnchor : `${baseAnchor}-${count}`;
|
|
52
|
+
|
|
53
|
+
sections.push({
|
|
54
|
+
anchor,
|
|
55
|
+
level,
|
|
56
|
+
line: index + 1,
|
|
57
|
+
title,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return sections;
|
|
62
|
+
}
|
package/src/core/validation.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { realpath } from "node:fs/promises";
|
|
|
9
9
|
// node:os for homedir (no Bun os utils)
|
|
10
10
|
import { homedir } from "node:os";
|
|
11
11
|
// node:path for path utils (no Bun path utils)
|
|
12
|
-
import { isAbsolute, join,
|
|
12
|
+
import { isAbsolute, join, posix as pathPosix } from "node:path";
|
|
13
13
|
|
|
14
14
|
import { toAbsolutePath } from "../config/paths";
|
|
15
15
|
|
|
@@ -48,8 +48,9 @@ export function validateRelPath(relPath: string): string {
|
|
|
48
48
|
throw new Error("relPath contains invalid characters");
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
const
|
|
52
|
-
const
|
|
51
|
+
const normalizedInput = relPath.replaceAll("\\", "/");
|
|
52
|
+
const normalized = pathPosix.normalize(normalizedInput);
|
|
53
|
+
const segments = normalized.split("/");
|
|
53
54
|
if (segments.includes("..")) {
|
|
54
55
|
throw new Error("relPath cannot escape collection root");
|
|
55
56
|
}
|
package/src/mcp/tools/capture.ts
CHANGED
|
@@ -15,6 +15,15 @@ import { buildUri } from "../../app/constants";
|
|
|
15
15
|
import { MCP_ERRORS } from "../../core/errors";
|
|
16
16
|
import { withWriteLock } from "../../core/file-lock";
|
|
17
17
|
import { atomicWrite } from "../../core/file-ops";
|
|
18
|
+
import {
|
|
19
|
+
resolveNoteCreatePlan,
|
|
20
|
+
type NoteCollisionPolicy,
|
|
21
|
+
} from "../../core/note-creation";
|
|
22
|
+
import {
|
|
23
|
+
getNotePreset,
|
|
24
|
+
resolveNotePreset,
|
|
25
|
+
type NotePresetId,
|
|
26
|
+
} from "../../core/note-presets";
|
|
18
27
|
import { normalizeTag, validateTag } from "../../core/tags";
|
|
19
28
|
import {
|
|
20
29
|
normalizeCollectionName,
|
|
@@ -27,10 +36,13 @@ import { runTool, type ToolResult } from "./index";
|
|
|
27
36
|
|
|
28
37
|
interface CaptureInput {
|
|
29
38
|
collection: string;
|
|
30
|
-
content
|
|
39
|
+
content?: string;
|
|
31
40
|
title?: string;
|
|
32
41
|
path?: string;
|
|
33
42
|
overwrite?: boolean;
|
|
43
|
+
folderPath?: string;
|
|
44
|
+
collisionPolicy?: NoteCollisionPolicy;
|
|
45
|
+
presetId?: NotePresetId;
|
|
34
46
|
tags?: string[];
|
|
35
47
|
}
|
|
36
48
|
|
|
@@ -123,17 +135,46 @@ export function handleCapture(
|
|
|
123
135
|
);
|
|
124
136
|
}
|
|
125
137
|
|
|
138
|
+
if (args.presetId && !getNotePreset(args.presetId)) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
`${MCP_ERRORS.INVALID_INPUT.code}: Unknown presetId: ${args.presetId}`
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const existingDocs = await ctx.store.listDocuments(collectionName);
|
|
145
|
+
if (!existingDocs.ok) {
|
|
146
|
+
throw new Error(existingDocs.error.message);
|
|
147
|
+
}
|
|
148
|
+
|
|
126
149
|
let relPath: string;
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
150
|
+
try {
|
|
151
|
+
const plan = resolveNoteCreatePlan(
|
|
152
|
+
{
|
|
153
|
+
collection: collection.name,
|
|
154
|
+
relPath: args.path
|
|
155
|
+
? ensureMarkdownExtension(validateRelPath(args.path))
|
|
156
|
+
: undefined,
|
|
157
|
+
title:
|
|
158
|
+
args.title?.trim() ||
|
|
159
|
+
generateFilename(args.title, args.content ?? "").replace(
|
|
160
|
+
/\.md$/u,
|
|
161
|
+
""
|
|
162
|
+
),
|
|
163
|
+
folderPath: args.folderPath,
|
|
164
|
+
collisionPolicy: args.collisionPolicy,
|
|
165
|
+
},
|
|
166
|
+
existingDocs.value.map((doc) => doc.relPath)
|
|
167
|
+
);
|
|
168
|
+
if (plan.openedExisting) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
`${MCP_ERRORS.CONFLICT.code}: Existing note resolution is not supported through gno_capture yet`
|
|
171
|
+
);
|
|
134
172
|
}
|
|
135
|
-
|
|
136
|
-
|
|
173
|
+
relPath = plan.relPath;
|
|
174
|
+
} catch (error) {
|
|
175
|
+
const message =
|
|
176
|
+
error instanceof Error ? error.message : String(error);
|
|
177
|
+
throw new Error(`${MCP_ERRORS.INVALID_PATH.code}: ${message}`);
|
|
137
178
|
}
|
|
138
179
|
|
|
139
180
|
assertNotSensitive(relPath);
|
|
@@ -164,12 +205,30 @@ export function handleCapture(
|
|
|
164
205
|
}
|
|
165
206
|
|
|
166
207
|
// Update frontmatter with tags for Markdown files
|
|
167
|
-
|
|
208
|
+
const resolvedPreset = resolveNotePreset({
|
|
209
|
+
presetId: args.presetId,
|
|
210
|
+
title:
|
|
211
|
+
args.title?.trim() ||
|
|
212
|
+
relPath
|
|
213
|
+
.split("/")
|
|
214
|
+
.pop()
|
|
215
|
+
?.replace(/\.[^.]+$/u, "") ||
|
|
216
|
+
"Untitled",
|
|
217
|
+
tags: normalizedTags,
|
|
218
|
+
body: args.content,
|
|
219
|
+
});
|
|
220
|
+
let contentToWrite =
|
|
221
|
+
resolvedPreset?.content ??
|
|
222
|
+
args.content ??
|
|
223
|
+
`# ${args.title?.trim() || "Untitled"}\n`;
|
|
168
224
|
const isMarkdown =
|
|
169
225
|
relPath.endsWith(".md") || relPath.endsWith(".markdown");
|
|
170
226
|
if (isMarkdown && normalizedTags.length > 0) {
|
|
171
227
|
// Use shared utility that handles all frontmatter edge cases
|
|
172
|
-
contentToWrite = updateFrontmatterTags(
|
|
228
|
+
contentToWrite = updateFrontmatterTags(
|
|
229
|
+
contentToWrite,
|
|
230
|
+
normalizedTags
|
|
231
|
+
);
|
|
173
232
|
}
|
|
174
233
|
|
|
175
234
|
await mkdir(dirname(absPath), { recursive: true });
|
package/src/mcp/tools/index.ts
CHANGED
|
@@ -32,6 +32,12 @@ import { handleSearch } from "./search";
|
|
|
32
32
|
import { handleStatus } from "./status";
|
|
33
33
|
import { handleSync } from "./sync";
|
|
34
34
|
import { handleVsearch } from "./vsearch";
|
|
35
|
+
import {
|
|
36
|
+
handleCreateFolder,
|
|
37
|
+
handleDuplicateNote,
|
|
38
|
+
handleMoveNote,
|
|
39
|
+
handleRenameNote,
|
|
40
|
+
} from "./workspace-write";
|
|
35
41
|
|
|
36
42
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
37
43
|
// Shared Helpers
|
|
@@ -121,7 +127,12 @@ const captureInputSchema = z.object({
|
|
|
121
127
|
.string()
|
|
122
128
|
.min(1, "Collection cannot be empty")
|
|
123
129
|
.describe("Target collection name (must already exist)"),
|
|
124
|
-
content: z
|
|
130
|
+
content: z
|
|
131
|
+
.string()
|
|
132
|
+
.optional()
|
|
133
|
+
.describe(
|
|
134
|
+
"Document content (markdown or plain text). Optional when presetId provides a scaffold."
|
|
135
|
+
),
|
|
125
136
|
title: z
|
|
126
137
|
.string()
|
|
127
138
|
.optional()
|
|
@@ -132,6 +143,25 @@ const captureInputSchema = z.object({
|
|
|
132
143
|
.describe(
|
|
133
144
|
"Relative path within collection (e.g. 'notes/meeting.md'). Auto-generated from title if omitted"
|
|
134
145
|
),
|
|
146
|
+
folderPath: z
|
|
147
|
+
.string()
|
|
148
|
+
.optional()
|
|
149
|
+
.describe("Optional folder path within the collection"),
|
|
150
|
+
collisionPolicy: z
|
|
151
|
+
.enum(["error", "open_existing", "create_with_suffix"])
|
|
152
|
+
.optional()
|
|
153
|
+
.describe("How to handle name collisions"),
|
|
154
|
+
presetId: z
|
|
155
|
+
.enum([
|
|
156
|
+
"blank",
|
|
157
|
+
"project-note",
|
|
158
|
+
"research-note",
|
|
159
|
+
"decision-note",
|
|
160
|
+
"prompt-pattern",
|
|
161
|
+
"source-summary",
|
|
162
|
+
])
|
|
163
|
+
.optional()
|
|
164
|
+
.describe("Optional note preset scaffold"),
|
|
135
165
|
overwrite: z
|
|
136
166
|
.boolean()
|
|
137
167
|
.default(false)
|
|
@@ -200,6 +230,29 @@ const removeCollectionInputSchema = z.object({
|
|
|
200
230
|
.describe("Collection name to remove"),
|
|
201
231
|
});
|
|
202
232
|
|
|
233
|
+
const createFolderInputSchema = z.object({
|
|
234
|
+
collection: z.string().min(1, "Collection cannot be empty"),
|
|
235
|
+
name: z.string().min(1, "Folder name cannot be empty"),
|
|
236
|
+
parentPath: z.string().optional(),
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const renameNoteInputSchema = z.object({
|
|
240
|
+
ref: z.string().min(1, "ref cannot be empty"),
|
|
241
|
+
name: z.string().min(1, "name cannot be empty"),
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const moveNoteInputSchema = z.object({
|
|
245
|
+
ref: z.string().min(1, "ref cannot be empty"),
|
|
246
|
+
folderPath: z.string().min(1, "folderPath cannot be empty"),
|
|
247
|
+
name: z.string().optional(),
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const duplicateNoteInputSchema = z.object({
|
|
251
|
+
ref: z.string().min(1, "ref cannot be empty"),
|
|
252
|
+
folderPath: z.string().optional(),
|
|
253
|
+
name: z.string().optional(),
|
|
254
|
+
});
|
|
255
|
+
|
|
203
256
|
const vsearchInputSchema = z.object({
|
|
204
257
|
query: z
|
|
205
258
|
.string()
|
|
@@ -764,6 +817,34 @@ export function registerTools(server: McpServer, ctx: ToolContext): void {
|
|
|
764
817
|
removeCollectionInputSchema.shape,
|
|
765
818
|
(args) => handleRemoveCollection(args, ctx)
|
|
766
819
|
);
|
|
820
|
+
|
|
821
|
+
server.tool(
|
|
822
|
+
"gno_create_folder",
|
|
823
|
+
"Create a folder inside an existing collection.",
|
|
824
|
+
createFolderInputSchema.shape,
|
|
825
|
+
(args) => handleCreateFolder(args, ctx)
|
|
826
|
+
);
|
|
827
|
+
|
|
828
|
+
server.tool(
|
|
829
|
+
"gno_rename_note",
|
|
830
|
+
"Rename an editable note in place.",
|
|
831
|
+
renameNoteInputSchema.shape,
|
|
832
|
+
(args) => handleRenameNote(args, ctx)
|
|
833
|
+
);
|
|
834
|
+
|
|
835
|
+
server.tool(
|
|
836
|
+
"gno_move_note",
|
|
837
|
+
"Move an editable note to another folder in the same collection.",
|
|
838
|
+
moveNoteInputSchema.shape,
|
|
839
|
+
(args) => handleMoveNote(args, ctx)
|
|
840
|
+
);
|
|
841
|
+
|
|
842
|
+
server.tool(
|
|
843
|
+
"gno_duplicate_note",
|
|
844
|
+
"Duplicate an editable note into the current or another folder.",
|
|
845
|
+
duplicateNoteInputSchema.shape,
|
|
846
|
+
(args) => handleDuplicateNote(args, ctx)
|
|
847
|
+
);
|
|
767
848
|
}
|
|
768
849
|
|
|
769
850
|
server.tool(
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP workspace write tools for note/file operations.
|
|
3
|
+
*
|
|
4
|
+
* @module src/mcp/tools/workspace-write
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// node:fs/promises for mkdir (no Bun equivalent for structure ops)
|
|
8
|
+
import { mkdir } from "node:fs/promises";
|
|
9
|
+
// node:path for dirname/join (no Bun path utils)
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
|
|
12
|
+
import type { Collection } from "../../config/types";
|
|
13
|
+
import type { ToolContext } from "../server";
|
|
14
|
+
|
|
15
|
+
import { getDocumentCapabilities } from "../../core/document-capabilities";
|
|
16
|
+
import { MCP_ERRORS } from "../../core/errors";
|
|
17
|
+
import { withWriteLock } from "../../core/file-lock";
|
|
18
|
+
import {
|
|
19
|
+
copyFilePath,
|
|
20
|
+
createFolderPath,
|
|
21
|
+
renameFilePath,
|
|
22
|
+
} from "../../core/file-ops";
|
|
23
|
+
import {
|
|
24
|
+
buildRefactorWarnings,
|
|
25
|
+
planCreateFolder,
|
|
26
|
+
planDuplicateRefactor,
|
|
27
|
+
planMoveRefactor,
|
|
28
|
+
planRenameRefactor,
|
|
29
|
+
} from "../../core/file-refactors";
|
|
30
|
+
import { defaultSyncService } from "../../ingestion";
|
|
31
|
+
import { runTool, type ToolResult } from "./index";
|
|
32
|
+
|
|
33
|
+
interface CreateFolderInput {
|
|
34
|
+
collection: string;
|
|
35
|
+
name: string;
|
|
36
|
+
parentPath?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface RenameNoteInput {
|
|
40
|
+
ref: string;
|
|
41
|
+
name: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface MoveNoteInput {
|
|
45
|
+
ref: string;
|
|
46
|
+
folderPath: string;
|
|
47
|
+
name?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface DuplicateNoteInput {
|
|
51
|
+
ref: string;
|
|
52
|
+
folderPath?: string;
|
|
53
|
+
name?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function resolveCollection(ctx: ToolContext, name: string): Collection {
|
|
57
|
+
const normalized = name.trim().toLowerCase();
|
|
58
|
+
const collection = ctx.collections.find((entry) => entry.name === normalized);
|
|
59
|
+
if (!collection) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`${MCP_ERRORS.NOT_FOUND.code}: Collection not found: ${name}`
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
return collection;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function resolveDocByRef(ctx: ToolContext, ref: string) {
|
|
68
|
+
const trimmed = ref.trim();
|
|
69
|
+
if (!trimmed) {
|
|
70
|
+
throw new Error(`${MCP_ERRORS.INVALID_INPUT.code}: ref cannot be empty`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (trimmed.startsWith("#")) {
|
|
74
|
+
const result = await ctx.store.getDocumentByDocid(trimmed);
|
|
75
|
+
if (!result.ok) {
|
|
76
|
+
throw new Error(result.error.message);
|
|
77
|
+
}
|
|
78
|
+
if (!result.value) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`${MCP_ERRORS.NOT_FOUND.code}: Document not found: ${ref}`
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return result.value;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (trimmed.startsWith("gno://")) {
|
|
87
|
+
const result = await ctx.store.getDocumentByUri(trimmed);
|
|
88
|
+
if (!result.ok) {
|
|
89
|
+
throw new Error(result.error.message);
|
|
90
|
+
}
|
|
91
|
+
if (!result.value) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`${MCP_ERRORS.NOT_FOUND.code}: Document not found: ${ref}`
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
return result.value;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const slash = trimmed.indexOf("/");
|
|
100
|
+
if (slash === -1) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`${MCP_ERRORS.INVALID_INPUT.code}: ref must be #docid, gno:// URI, or collection/path`
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const collection = trimmed.slice(0, slash).toLowerCase();
|
|
107
|
+
const relPath = trimmed.slice(slash + 1);
|
|
108
|
+
const result = await ctx.store.getDocument(collection, relPath);
|
|
109
|
+
if (!result.ok) {
|
|
110
|
+
throw new Error(result.error.message);
|
|
111
|
+
}
|
|
112
|
+
if (!result.value) {
|
|
113
|
+
throw new Error(`${MCP_ERRORS.NOT_FOUND.code}: Document not found: ${ref}`);
|
|
114
|
+
}
|
|
115
|
+
return result.value;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function getRefactorSnapshot(ctx: ToolContext, documentId: number) {
|
|
119
|
+
const [linksResult, backlinksResult] = await Promise.all([
|
|
120
|
+
ctx.store.getLinksForDoc(documentId),
|
|
121
|
+
ctx.store.getBacklinksForDoc(documentId),
|
|
122
|
+
]);
|
|
123
|
+
if (!linksResult.ok) {
|
|
124
|
+
throw new Error(linksResult.error.message);
|
|
125
|
+
}
|
|
126
|
+
if (!backlinksResult.ok) {
|
|
127
|
+
throw new Error(backlinksResult.error.message);
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
backlinks: backlinksResult.value.length,
|
|
131
|
+
wikiLinks: linksResult.value.filter((entry) => entry.linkType === "wiki")
|
|
132
|
+
.length,
|
|
133
|
+
markdownLinks: linksResult.value.filter(
|
|
134
|
+
(entry) => entry.linkType === "markdown"
|
|
135
|
+
).length,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function ensureEditable(doc: {
|
|
140
|
+
sourceExt: string;
|
|
141
|
+
sourceMime: string;
|
|
142
|
+
mirrorHash: string | null;
|
|
143
|
+
}) {
|
|
144
|
+
const capabilities = getDocumentCapabilities({
|
|
145
|
+
sourceExt: doc.sourceExt,
|
|
146
|
+
sourceMime: doc.sourceMime,
|
|
147
|
+
contentAvailable: doc.mirrorHash !== null,
|
|
148
|
+
});
|
|
149
|
+
if (!capabilities.editable) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`${MCP_ERRORS.CONFLICT.code}: ${
|
|
152
|
+
capabilities.reason ?? "Document is read-only in place."
|
|
153
|
+
}`
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function handleCreateFolder(
|
|
159
|
+
args: CreateFolderInput,
|
|
160
|
+
ctx: ToolContext
|
|
161
|
+
): Promise<ToolResult> {
|
|
162
|
+
return runTool(
|
|
163
|
+
ctx,
|
|
164
|
+
"gno_create_folder",
|
|
165
|
+
async () => {
|
|
166
|
+
if (!ctx.enableWrite) {
|
|
167
|
+
throw new Error("Write tools disabled. Start MCP with --enable-write.");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return withWriteLock(ctx.writeLockPath, async () => {
|
|
171
|
+
const collection = resolveCollection(ctx, args.collection);
|
|
172
|
+
const folderPath = planCreateFolder({
|
|
173
|
+
parentPath: args.parentPath,
|
|
174
|
+
name: args.name,
|
|
175
|
+
});
|
|
176
|
+
const fullPath = join(collection.path, folderPath);
|
|
177
|
+
await createFolderPath(fullPath);
|
|
178
|
+
return {
|
|
179
|
+
collection: collection.name,
|
|
180
|
+
folderPath,
|
|
181
|
+
path: fullPath,
|
|
182
|
+
};
|
|
183
|
+
});
|
|
184
|
+
},
|
|
185
|
+
(data) => `Created folder ${data.folderPath} in ${data.collection}`
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function handleRenameNote(
|
|
190
|
+
args: RenameNoteInput,
|
|
191
|
+
ctx: ToolContext
|
|
192
|
+
): Promise<ToolResult> {
|
|
193
|
+
return runTool(
|
|
194
|
+
ctx,
|
|
195
|
+
"gno_rename_note",
|
|
196
|
+
async () => {
|
|
197
|
+
if (!ctx.enableWrite) {
|
|
198
|
+
throw new Error("Write tools disabled. Start MCP with --enable-write.");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return withWriteLock(ctx.writeLockPath, async () => {
|
|
202
|
+
const doc = await resolveDocByRef(ctx, args.ref);
|
|
203
|
+
ensureEditable(doc);
|
|
204
|
+
const collection = resolveCollection(ctx, doc.collection);
|
|
205
|
+
const plan = planRenameRefactor({
|
|
206
|
+
collection: collection.name,
|
|
207
|
+
currentRelPath: doc.relPath,
|
|
208
|
+
nextName: args.name,
|
|
209
|
+
});
|
|
210
|
+
const currentPath = join(collection.path, doc.relPath);
|
|
211
|
+
const nextPath = join(collection.path, plan.nextRelPath);
|
|
212
|
+
await renameFilePath(currentPath, nextPath);
|
|
213
|
+
await defaultSyncService.syncCollection(collection, ctx.store, {
|
|
214
|
+
runUpdateCmd: false,
|
|
215
|
+
});
|
|
216
|
+
return {
|
|
217
|
+
uri: plan.nextUri,
|
|
218
|
+
relPath: plan.nextRelPath,
|
|
219
|
+
warnings: buildRefactorWarnings(
|
|
220
|
+
await getRefactorSnapshot(ctx, doc.id),
|
|
221
|
+
{ filenameChanged: true }
|
|
222
|
+
).warnings,
|
|
223
|
+
};
|
|
224
|
+
});
|
|
225
|
+
},
|
|
226
|
+
(data) => `Renamed note to ${data.relPath}`
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function handleMoveNote(
|
|
231
|
+
args: MoveNoteInput,
|
|
232
|
+
ctx: ToolContext
|
|
233
|
+
): Promise<ToolResult> {
|
|
234
|
+
return runTool(
|
|
235
|
+
ctx,
|
|
236
|
+
"gno_move_note",
|
|
237
|
+
async () => {
|
|
238
|
+
if (!ctx.enableWrite) {
|
|
239
|
+
throw new Error("Write tools disabled. Start MCP with --enable-write.");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return withWriteLock(ctx.writeLockPath, async () => {
|
|
243
|
+
const doc = await resolveDocByRef(ctx, args.ref);
|
|
244
|
+
ensureEditable(doc);
|
|
245
|
+
const collection = resolveCollection(ctx, doc.collection);
|
|
246
|
+
const plan = planMoveRefactor({
|
|
247
|
+
collection: collection.name,
|
|
248
|
+
currentRelPath: doc.relPath,
|
|
249
|
+
folderPath: args.folderPath,
|
|
250
|
+
nextName: args.name,
|
|
251
|
+
});
|
|
252
|
+
const currentPath = join(collection.path, doc.relPath);
|
|
253
|
+
const nextPath = join(collection.path, plan.nextRelPath);
|
|
254
|
+
await mkdir(dirname(nextPath), { recursive: true });
|
|
255
|
+
await renameFilePath(currentPath, nextPath);
|
|
256
|
+
await defaultSyncService.syncCollection(collection, ctx.store, {
|
|
257
|
+
runUpdateCmd: false,
|
|
258
|
+
});
|
|
259
|
+
return {
|
|
260
|
+
uri: plan.nextUri,
|
|
261
|
+
relPath: plan.nextRelPath,
|
|
262
|
+
warnings: buildRefactorWarnings(
|
|
263
|
+
await getRefactorSnapshot(ctx, doc.id),
|
|
264
|
+
{
|
|
265
|
+
folderChanged: true,
|
|
266
|
+
filenameChanged: Boolean(args.name),
|
|
267
|
+
}
|
|
268
|
+
).warnings,
|
|
269
|
+
};
|
|
270
|
+
});
|
|
271
|
+
},
|
|
272
|
+
(data) => `Moved note to ${data.relPath}`
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function handleDuplicateNote(
|
|
277
|
+
args: DuplicateNoteInput,
|
|
278
|
+
ctx: ToolContext
|
|
279
|
+
): Promise<ToolResult> {
|
|
280
|
+
return runTool(
|
|
281
|
+
ctx,
|
|
282
|
+
"gno_duplicate_note",
|
|
283
|
+
async () => {
|
|
284
|
+
if (!ctx.enableWrite) {
|
|
285
|
+
throw new Error("Write tools disabled. Start MCP with --enable-write.");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return withWriteLock(ctx.writeLockPath, async () => {
|
|
289
|
+
const doc = await resolveDocByRef(ctx, args.ref);
|
|
290
|
+
ensureEditable(doc);
|
|
291
|
+
const collection = resolveCollection(ctx, doc.collection);
|
|
292
|
+
const docsResult = await ctx.store.listDocuments(collection.name);
|
|
293
|
+
if (!docsResult.ok) {
|
|
294
|
+
throw new Error(docsResult.error.message);
|
|
295
|
+
}
|
|
296
|
+
const plan = planDuplicateRefactor({
|
|
297
|
+
collection: collection.name,
|
|
298
|
+
currentRelPath: doc.relPath,
|
|
299
|
+
folderPath: args.folderPath,
|
|
300
|
+
nextName: args.name,
|
|
301
|
+
existingRelPaths: docsResult.value.map((entry) => entry.relPath),
|
|
302
|
+
});
|
|
303
|
+
const currentPath = join(collection.path, doc.relPath);
|
|
304
|
+
const nextPath = join(collection.path, plan.nextRelPath);
|
|
305
|
+
await mkdir(dirname(nextPath), { recursive: true });
|
|
306
|
+
await copyFilePath(currentPath, nextPath);
|
|
307
|
+
await defaultSyncService.syncCollection(collection, ctx.store, {
|
|
308
|
+
runUpdateCmd: false,
|
|
309
|
+
});
|
|
310
|
+
return {
|
|
311
|
+
uri: plan.nextUri,
|
|
312
|
+
relPath: plan.nextRelPath,
|
|
313
|
+
warnings: buildRefactorWarnings(
|
|
314
|
+
await getRefactorSnapshot(ctx, doc.id)
|
|
315
|
+
).warnings,
|
|
316
|
+
};
|
|
317
|
+
});
|
|
318
|
+
},
|
|
319
|
+
(data) => `Duplicated note to ${data.relPath}`
|
|
320
|
+
);
|
|
321
|
+
}
|