@gmickel/gno 0.34.1 → 0.36.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 +8 -3
- package/src/serve/public/components/BrowseDetailPane.tsx +18 -1
- package/src/serve/public/components/CaptureModal.tsx +135 -13
- package/src/serve/public/components/QuickSwitcher.tsx +408 -46
- package/src/serve/public/components/ShortcutHelpModal.tsx +54 -1
- package/src/serve/public/components/editor/MarkdownPreview.tsx +58 -26
- package/src/serve/public/components/ui/command.tsx +19 -9
- package/src/serve/public/components/ui/dialog.tsx +1 -1
- package/src/serve/public/globals.built.css +2 -2
- package/src/serve/public/globals.css +47 -0
- package/src/serve/public/hooks/useCaptureModal.tsx +31 -5
- package/src/serve/public/lib/browse.ts +1 -0
- 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 +472 -10
- package/src/serve/public/pages/DocumentEditor.tsx +52 -0
- package/src/serve/routes/api.ts +712 -13
- package/src/serve/server.ts +74 -0
- package/src/store/sqlite/adapter.ts +19 -19
package/README.md
CHANGED
|
@@ -427,11 +427,14 @@ Open `http://localhost:3000` to:
|
|
|
427
427
|
- **Search**: BM25, vector, or hybrid modes with visual results
|
|
428
428
|
- **Browse**: Cross-collection tree workspace with folder detail panes and per-tab browse context
|
|
429
429
|
- **Edit**: Create, edit, and delete documents with live preview
|
|
430
|
+
- **Create in place**: New notes in the current folder/collection with presets and command-palette flows
|
|
430
431
|
- **Ask**: AI-powered Q&A with citations
|
|
431
432
|
- **Manage Collections**: Add, remove, and re-index collections
|
|
432
433
|
- **Connect agents**: Install core Skill/MCP integrations from the app
|
|
433
434
|
- **Manage files safely**: Rename, reveal, or move editable files to Trash with explicit index-vs-disk semantics
|
|
435
|
+
- **Refactor files safely**: Move, duplicate, and organize editable notes with reference warnings
|
|
434
436
|
- **Switch presets**: Change models live without restart
|
|
437
|
+
- **Command palette**: Jump, create, refactor, and section-navigate from one keyboard-first surface
|
|
435
438
|
|
|
436
439
|
### Search
|
|
437
440
|
|
|
@@ -445,19 +448,20 @@ Three retrieval modes: BM25 (keyword), Vector (semantic), or Hybrid (best of bot
|
|
|
445
448
|
|
|
446
449
|
Full-featured markdown editor with:
|
|
447
450
|
|
|
448
|
-
| Feature | Description
|
|
449
|
-
| :---------------------- |
|
|
450
|
-
| **Split View** | Side-by-side editor and live preview
|
|
451
|
-
| **Auto-save** | 2-second debounced saves
|
|
452
|
-
| **Syntax Highlighting** | CodeMirror 6 with markdown support
|
|
453
|
-
| **Keyboard Shortcuts** | ⌘S save, ⌘B bold, ⌘I italic, ⌘K link
|
|
454
|
-
| **Quick Capture** | ⌘N creates new note from anywhere
|
|
451
|
+
| Feature | Description |
|
|
452
|
+
| :---------------------- | :------------------------------------------- |
|
|
453
|
+
| **Split View** | Side-by-side editor and live preview |
|
|
454
|
+
| **Auto-save** | 2-second debounced saves |
|
|
455
|
+
| **Syntax Highlighting** | CodeMirror 6 with markdown support |
|
|
456
|
+
| **Keyboard Shortcuts** | ⌘S save, ⌘B bold, ⌘I italic, ⌘K link |
|
|
457
|
+
| **Quick Capture** | ⌘N creates new note from anywhere |
|
|
458
|
+
| **Presets** | Structured note scaffolds and insert actions |
|
|
455
459
|
|
|
456
460
|
### Document Viewer
|
|
457
461
|
|
|
458
462
|

|
|
459
463
|
|
|
460
|
-
View documents with full context: outgoing links, backlinks, and AI-powered related notes sidebar.
|
|
464
|
+
View documents with full context: outgoing links, backlinks, section outline, and AI-powered related notes sidebar.
|
|
461
465
|
|
|
462
466
|
### Browse Workspace
|
|
463
467
|
|
|
@@ -467,6 +471,7 @@ Navigate your notes like a real workspace, not just a flat list:
|
|
|
467
471
|
|
|
468
472
|
- Cross-collection tree sidebar
|
|
469
473
|
- Folder detail panes
|
|
474
|
+
- Create note and create folder from current browse context
|
|
470
475
|
- Pinned collections and per-tab browse state
|
|
471
476
|
- Direct jump from folder structure into notes
|
|
472
477
|
|
|
@@ -516,24 +521,30 @@ curl -X POST http://localhost:3000/api/ask \
|
|
|
516
521
|
curl http://localhost:3000/api/status
|
|
517
522
|
```
|
|
518
523
|
|
|
519
|
-
| Endpoint
|
|
520
|
-
|
|
|
521
|
-
| `/api/query`
|
|
522
|
-
| `/api/search`
|
|
523
|
-
| `/api/ask`
|
|
524
|
-
| `/api/docs`
|
|
525
|
-
| `/api/docs`
|
|
526
|
-
| `/api/docs/:id`
|
|
527
|
-
| `/api/docs/:id/
|
|
528
|
-
| `/api/
|
|
529
|
-
| `/api/
|
|
530
|
-
| `/api/
|
|
531
|
-
| `/api/
|
|
532
|
-
| `/api/
|
|
533
|
-
| `/api/
|
|
534
|
-
| `/api/
|
|
535
|
-
| `/api/
|
|
536
|
-
| `/api/
|
|
524
|
+
| Endpoint | Method | Description |
|
|
525
|
+
| :---------------------------- | :----- | :-------------------------- |
|
|
526
|
+
| `/api/query` | POST | Hybrid search (recommended) |
|
|
527
|
+
| `/api/search` | POST | BM25 keyword search |
|
|
528
|
+
| `/api/ask` | POST | AI-powered Q&A |
|
|
529
|
+
| `/api/docs` | GET | List documents |
|
|
530
|
+
| `/api/docs` | POST | Create document |
|
|
531
|
+
| `/api/docs/:id` | PUT | Update document content |
|
|
532
|
+
| `/api/docs/:id/move` | POST | Move editable document |
|
|
533
|
+
| `/api/docs/:id/duplicate` | POST | Duplicate editable document |
|
|
534
|
+
| `/api/docs/:id/refactor-plan` | POST | Preview file-op warnings |
|
|
535
|
+
| `/api/docs/:id/deactivate` | POST | Remove from index |
|
|
536
|
+
| `/api/doc` | GET | Get document content |
|
|
537
|
+
| `/api/doc/:id/sections` | GET | Get document sections |
|
|
538
|
+
| `/api/collections` | POST | Add collection |
|
|
539
|
+
| `/api/collections/:name` | DELETE | Remove collection |
|
|
540
|
+
| `/api/folders` | POST | Create folder |
|
|
541
|
+
| `/api/sync` | POST | Trigger re-index |
|
|
542
|
+
| `/api/status` | GET | Index statistics |
|
|
543
|
+
| `/api/note-presets` | GET | List note presets |
|
|
544
|
+
| `/api/presets` | GET | List model presets |
|
|
545
|
+
| `/api/presets` | POST | Switch preset |
|
|
546
|
+
| `/api/models/pull` | POST | Download models |
|
|
547
|
+
| `/api/models/status` | GET | Download progress |
|
|
537
548
|
|
|
538
549
|
No authentication. No rate limits. Build custom tools, automate workflows, integrate with any language.
|
|
539
550
|
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
package/src/core/file-ops.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
// node:fs/promises for rename/unlink (no Bun equivalent for structure ops)
|
|
8
|
-
import { mkdir, rename, unlink } from "node:fs/promises";
|
|
8
|
+
import { copyFile, mkdir, rename, unlink } from "node:fs/promises";
|
|
9
9
|
// node:os platform/homedir/tmpdir: no Bun equivalent
|
|
10
10
|
import { homedir, platform as getPlatform, tmpdir } from "node:os";
|
|
11
11
|
// node:path dirname/join/parse: no Bun equivalent
|
|
@@ -48,6 +48,17 @@ export async function renameFilePath(
|
|
|
48
48
|
await rename(currentPath, nextPath);
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
export async function copyFilePath(
|
|
52
|
+
currentPath: string,
|
|
53
|
+
nextPath: string
|
|
54
|
+
): Promise<void> {
|
|
55
|
+
await copyFile(currentPath, nextPath);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function createFolderPath(path: string): Promise<void> {
|
|
59
|
+
await mkdir(path, { recursive: true });
|
|
60
|
+
}
|
|
61
|
+
|
|
51
62
|
type TrashFileDeps = {
|
|
52
63
|
homeDir?: string;
|
|
53
64
|
platform?: ReturnType<typeof getPlatform>;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared file refactor planning helpers.
|
|
3
|
+
*
|
|
4
|
+
* Browser-safe path planning, with warning generation based on known link data.
|
|
5
|
+
*
|
|
6
|
+
* @module src/core/file-refactors
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// node:path has no Bun equivalent
|
|
10
|
+
import { posix as pathPosix } from "node:path";
|
|
11
|
+
|
|
12
|
+
import { validateRelPath } from "./validation";
|
|
13
|
+
|
|
14
|
+
export interface RefactorWarningSummary {
|
|
15
|
+
warnings: string[];
|
|
16
|
+
backlinkCount: number;
|
|
17
|
+
wikiLinkCount: number;
|
|
18
|
+
markdownLinkCount: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface RefactorLinkSnapshot {
|
|
22
|
+
backlinks: number;
|
|
23
|
+
wikiLinks: number;
|
|
24
|
+
markdownLinks: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface RenamePlan {
|
|
28
|
+
nextRelPath: string;
|
|
29
|
+
nextUri: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface MovePlan {
|
|
33
|
+
nextRelPath: string;
|
|
34
|
+
nextUri: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface DuplicatePlan {
|
|
38
|
+
nextRelPath: string;
|
|
39
|
+
nextUri: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function buildRefactorWarnings(
|
|
43
|
+
snapshot: RefactorLinkSnapshot,
|
|
44
|
+
options: {
|
|
45
|
+
filenameChanged?: boolean;
|
|
46
|
+
folderChanged?: boolean;
|
|
47
|
+
} = {}
|
|
48
|
+
): RefactorWarningSummary {
|
|
49
|
+
const warnings: string[] = [];
|
|
50
|
+
|
|
51
|
+
if (snapshot.backlinks > 0) {
|
|
52
|
+
warnings.push(
|
|
53
|
+
`${snapshot.backlinks} backlink${snapshot.backlinks === 1 ? "" : "s"} may need review after this refactor.`
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
if (options.filenameChanged && snapshot.wikiLinks > 0) {
|
|
57
|
+
warnings.push(
|
|
58
|
+
`${snapshot.wikiLinks} wiki link${snapshot.wikiLinks === 1 ? "" : "s"} may depend on the current title/path identity.`
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
if (
|
|
62
|
+
(options.filenameChanged || options.folderChanged) &&
|
|
63
|
+
snapshot.markdownLinks > 0
|
|
64
|
+
) {
|
|
65
|
+
warnings.push(
|
|
66
|
+
`${snapshot.markdownLinks} markdown link${snapshot.markdownLinks === 1 ? "" : "s"} may require path rewrite or manual review.`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
warnings,
|
|
72
|
+
backlinkCount: snapshot.backlinks,
|
|
73
|
+
wikiLinkCount: snapshot.wikiLinks,
|
|
74
|
+
markdownLinkCount: snapshot.markdownLinks,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function nextAvailableRelPath(relPath: string, existing: Set<string>): string {
|
|
79
|
+
const parsed = pathPosix.parse(relPath);
|
|
80
|
+
const dir = parsed.dir ? `${parsed.dir}/` : "";
|
|
81
|
+
const base = parsed.name || "copy";
|
|
82
|
+
const ext = parsed.ext || ".md";
|
|
83
|
+
|
|
84
|
+
let counter = 2;
|
|
85
|
+
while (true) {
|
|
86
|
+
const candidate = `${dir}${base}-${counter}${ext}`;
|
|
87
|
+
if (!existing.has(candidate)) {
|
|
88
|
+
return candidate;
|
|
89
|
+
}
|
|
90
|
+
counter += 1;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function planRenameRefactor(input: {
|
|
95
|
+
collection: string;
|
|
96
|
+
currentRelPath: string;
|
|
97
|
+
nextName: string;
|
|
98
|
+
}): RenamePlan {
|
|
99
|
+
const current = validateRelPath(input.currentRelPath);
|
|
100
|
+
const directory = pathPosix.dirname(current);
|
|
101
|
+
const currentExt = pathPosix.extname(current);
|
|
102
|
+
const nextFilename = pathPosix.extname(input.nextName)
|
|
103
|
+
? input.nextName
|
|
104
|
+
: `${input.nextName}${currentExt}`;
|
|
105
|
+
const nextRelPath =
|
|
106
|
+
directory === "."
|
|
107
|
+
? validateRelPath(nextFilename)
|
|
108
|
+
: validateRelPath(`${directory}/${nextFilename}`);
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
nextRelPath,
|
|
112
|
+
nextUri: `gno://${input.collection}/${nextRelPath}`,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function planMoveRefactor(input: {
|
|
117
|
+
collection: string;
|
|
118
|
+
currentRelPath: string;
|
|
119
|
+
folderPath: string;
|
|
120
|
+
nextName?: string;
|
|
121
|
+
}): MovePlan {
|
|
122
|
+
const current = validateRelPath(input.currentRelPath);
|
|
123
|
+
const safeFolder = validateRelPath(input.folderPath).replace(
|
|
124
|
+
/^\.\/|\/+$/g,
|
|
125
|
+
""
|
|
126
|
+
);
|
|
127
|
+
const filename = input.nextName?.trim() || pathPosix.basename(current);
|
|
128
|
+
const nextRelPath = safeFolder
|
|
129
|
+
? validateRelPath(`${safeFolder}/${filename}`)
|
|
130
|
+
: validateRelPath(filename);
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
nextRelPath,
|
|
134
|
+
nextUri: `gno://${input.collection}/${nextRelPath}`,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function planDuplicateRefactor(input: {
|
|
139
|
+
collection: string;
|
|
140
|
+
currentRelPath: string;
|
|
141
|
+
folderPath?: string;
|
|
142
|
+
nextName?: string;
|
|
143
|
+
existingRelPaths: Iterable<string>;
|
|
144
|
+
}): DuplicatePlan {
|
|
145
|
+
const current = validateRelPath(input.currentRelPath);
|
|
146
|
+
const existing = new Set(input.existingRelPaths);
|
|
147
|
+
const targetFolder = input.folderPath
|
|
148
|
+
? validateRelPath(input.folderPath).replace(/^\.\/|\/+$/g, "")
|
|
149
|
+
: pathPosix.dirname(current) === "."
|
|
150
|
+
? ""
|
|
151
|
+
: pathPosix.dirname(current);
|
|
152
|
+
const baseName = input.nextName?.trim() || pathPosix.basename(current);
|
|
153
|
+
const initialRelPath = targetFolder
|
|
154
|
+
? validateRelPath(`${targetFolder}/${baseName}`)
|
|
155
|
+
: validateRelPath(baseName);
|
|
156
|
+
const nextRelPath = existing.has(initialRelPath)
|
|
157
|
+
? nextAvailableRelPath(initialRelPath, existing)
|
|
158
|
+
: initialRelPath;
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
nextRelPath,
|
|
162
|
+
nextUri: `gno://${input.collection}/${nextRelPath}`,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function planCreateFolder(input: {
|
|
167
|
+
parentPath?: string;
|
|
168
|
+
name: string;
|
|
169
|
+
}): string {
|
|
170
|
+
const safeName = input.name.trim().replaceAll(/[\\/]+/g, "");
|
|
171
|
+
if (!safeName) {
|
|
172
|
+
throw new Error("Folder name cannot be empty");
|
|
173
|
+
}
|
|
174
|
+
const safeParent = input.parentPath
|
|
175
|
+
? validateRelPath(input.parentPath).replace(/^\.\/|\/+$/g, "")
|
|
176
|
+
: "";
|
|
177
|
+
return safeParent
|
|
178
|
+
? validateRelPath(`${safeParent}/${safeName}`)
|
|
179
|
+
: validateRelPath(safeName);
|
|
180
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared note creation path resolution and collision handling.
|
|
3
|
+
*
|
|
4
|
+
* Browser-safe: no Bun APIs.
|
|
5
|
+
*
|
|
6
|
+
* @module src/core/note-creation
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// node:path has no Bun equivalent
|
|
10
|
+
import { posix as pathPosix } from "node:path";
|
|
11
|
+
|
|
12
|
+
import { validateRelPath } from "./validation";
|
|
13
|
+
|
|
14
|
+
export type NoteCollisionPolicy =
|
|
15
|
+
| "error"
|
|
16
|
+
| "open_existing"
|
|
17
|
+
| "create_with_suffix";
|
|
18
|
+
|
|
19
|
+
export interface ResolveNoteCreateInput {
|
|
20
|
+
collection: string;
|
|
21
|
+
title?: string;
|
|
22
|
+
relPath?: string;
|
|
23
|
+
folderPath?: string;
|
|
24
|
+
collisionPolicy?: NoteCollisionPolicy;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface NoteCreatePlan {
|
|
28
|
+
collection: string;
|
|
29
|
+
folderPath: string;
|
|
30
|
+
relPath: string;
|
|
31
|
+
filename: string;
|
|
32
|
+
collisionPolicy: NoteCollisionPolicy;
|
|
33
|
+
openedExisting: boolean;
|
|
34
|
+
createdWithSuffix: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function sanitizeNoteFilename(title: string): string {
|
|
38
|
+
return title
|
|
39
|
+
.normalize("NFC")
|
|
40
|
+
.toLowerCase()
|
|
41
|
+
.trim()
|
|
42
|
+
.replaceAll(/[^\w\s-]/g, "")
|
|
43
|
+
.replaceAll(/\s+/g, "-")
|
|
44
|
+
.replaceAll(/-+/g, "-")
|
|
45
|
+
.replace(/^-|-$/g, "");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function ensureMarkdownFilename(filename: string): string {
|
|
49
|
+
return pathPosix.extname(filename) ? filename : `${filename}.md`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function nextAvailableRelPath(relPath: string, existing: Set<string>): string {
|
|
53
|
+
const parsed = pathPosix.parse(relPath);
|
|
54
|
+
const ext = parsed.ext || ".md";
|
|
55
|
+
const dir = parsed.dir ? `${parsed.dir}/` : "";
|
|
56
|
+
const base = parsed.name || "untitled";
|
|
57
|
+
|
|
58
|
+
let counter = 2;
|
|
59
|
+
while (true) {
|
|
60
|
+
const candidate = `${dir}${base}-${counter}${ext}`;
|
|
61
|
+
if (!existing.has(candidate)) {
|
|
62
|
+
return candidate;
|
|
63
|
+
}
|
|
64
|
+
counter += 1;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function resolveNoteCreatePlan(
|
|
69
|
+
input: ResolveNoteCreateInput,
|
|
70
|
+
existingRelPaths: Iterable<string>
|
|
71
|
+
): NoteCreatePlan {
|
|
72
|
+
const collisionPolicy = input.collisionPolicy ?? "error";
|
|
73
|
+
const existing = new Set(existingRelPaths);
|
|
74
|
+
const safeFolderPath = input.folderPath
|
|
75
|
+
? validateRelPath(input.folderPath).replace(/^\.\/|\/+$/g, "")
|
|
76
|
+
: "";
|
|
77
|
+
|
|
78
|
+
let baseRelPath: string;
|
|
79
|
+
if (input.relPath?.trim()) {
|
|
80
|
+
baseRelPath = validateRelPath(input.relPath.trim());
|
|
81
|
+
} else {
|
|
82
|
+
const baseTitle = input.title?.trim() || "untitled";
|
|
83
|
+
const filename = ensureMarkdownFilename(
|
|
84
|
+
sanitizeNoteFilename(baseTitle) || "untitled"
|
|
85
|
+
);
|
|
86
|
+
baseRelPath = safeFolderPath ? `${safeFolderPath}/${filename}` : filename;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const relPath = safeFolderPath
|
|
90
|
+
? baseRelPath.startsWith(`${safeFolderPath}/`) ||
|
|
91
|
+
baseRelPath === safeFolderPath
|
|
92
|
+
? baseRelPath
|
|
93
|
+
: `${safeFolderPath}/${pathPosix.basename(baseRelPath)}`
|
|
94
|
+
: baseRelPath;
|
|
95
|
+
const normalizedRelPath = validateRelPath(relPath);
|
|
96
|
+
|
|
97
|
+
if (!existing.has(normalizedRelPath)) {
|
|
98
|
+
return {
|
|
99
|
+
collection: input.collection,
|
|
100
|
+
folderPath: safeFolderPath,
|
|
101
|
+
relPath: normalizedRelPath,
|
|
102
|
+
filename: pathPosix.basename(normalizedRelPath),
|
|
103
|
+
collisionPolicy,
|
|
104
|
+
openedExisting: false,
|
|
105
|
+
createdWithSuffix: false,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (collisionPolicy === "open_existing") {
|
|
110
|
+
return {
|
|
111
|
+
collection: input.collection,
|
|
112
|
+
folderPath: safeFolderPath,
|
|
113
|
+
relPath: normalizedRelPath,
|
|
114
|
+
filename: pathPosix.basename(normalizedRelPath),
|
|
115
|
+
collisionPolicy,
|
|
116
|
+
openedExisting: true,
|
|
117
|
+
createdWithSuffix: false,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (collisionPolicy === "create_with_suffix") {
|
|
122
|
+
const nextRelPath = nextAvailableRelPath(normalizedRelPath, existing);
|
|
123
|
+
return {
|
|
124
|
+
collection: input.collection,
|
|
125
|
+
folderPath: safeFolderPath,
|
|
126
|
+
relPath: nextRelPath,
|
|
127
|
+
filename: pathPosix.basename(nextRelPath),
|
|
128
|
+
collisionPolicy,
|
|
129
|
+
openedExisting: false,
|
|
130
|
+
createdWithSuffix: true,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
throw new Error(
|
|
135
|
+
"File already exists. Use open_existing or create_with_suffix."
|
|
136
|
+
);
|
|
137
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared note preset definitions and scaffold helpers.
|
|
3
|
+
*
|
|
4
|
+
* Browser-safe: no Bun APIs.
|
|
5
|
+
*
|
|
6
|
+
* @module src/core/note-presets
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { normalizeTag } from "./tags";
|
|
10
|
+
|
|
11
|
+
export type NotePresetId =
|
|
12
|
+
| "blank"
|
|
13
|
+
| "project-note"
|
|
14
|
+
| "research-note"
|
|
15
|
+
| "decision-note"
|
|
16
|
+
| "prompt-pattern"
|
|
17
|
+
| "source-summary";
|
|
18
|
+
|
|
19
|
+
export interface NotePresetDefinition {
|
|
20
|
+
id: NotePresetId;
|
|
21
|
+
label: string;
|
|
22
|
+
description: string;
|
|
23
|
+
defaultTags?: string[];
|
|
24
|
+
frontmatter?: Record<string, string | string[]>;
|
|
25
|
+
body: (title: string) => string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ResolvedNotePreset {
|
|
29
|
+
preset: NotePresetDefinition;
|
|
30
|
+
title: string;
|
|
31
|
+
tags: string[];
|
|
32
|
+
frontmatter: Record<string, string | string[]>;
|
|
33
|
+
body: string;
|
|
34
|
+
content: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function serializeFrontmatterValue(value: string | string[]): string[] {
|
|
38
|
+
if (Array.isArray(value)) {
|
|
39
|
+
return value.length === 0
|
|
40
|
+
? []
|
|
41
|
+
: value.map((entry) => ` - ${JSON.stringify(entry)}`);
|
|
42
|
+
}
|
|
43
|
+
return [JSON.stringify(value)];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function serializeFrontmatter(
|
|
47
|
+
data: Record<string, string | string[]>
|
|
48
|
+
): string {
|
|
49
|
+
const entries = Object.entries(data);
|
|
50
|
+
if (entries.length === 0) {
|
|
51
|
+
return "";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const lines = ["---"];
|
|
55
|
+
for (const [key, value] of entries) {
|
|
56
|
+
if (Array.isArray(value)) {
|
|
57
|
+
if (value.length === 0) {
|
|
58
|
+
lines.push(`${key}: []`);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
lines.push(`${key}:`);
|
|
62
|
+
} else {
|
|
63
|
+
lines.push(`${key}: ${serializeFrontmatterValue(value)[0]}`);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
for (const line of serializeFrontmatterValue(value)) {
|
|
67
|
+
lines.push(line);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
lines.push("---", "");
|
|
71
|
+
return lines.join("\n");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const NOTE_PRESETS: NotePresetDefinition[] = [
|
|
75
|
+
{
|
|
76
|
+
id: "blank",
|
|
77
|
+
label: "Blank",
|
|
78
|
+
description: "Empty note with only a title heading.",
|
|
79
|
+
body: (title) => `# ${title}\n`,
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
id: "project-note",
|
|
83
|
+
label: "Project Note",
|
|
84
|
+
description: "Goal, scope, open questions, and next steps.",
|
|
85
|
+
defaultTags: ["project"],
|
|
86
|
+
frontmatter: {
|
|
87
|
+
category: "project",
|
|
88
|
+
status: "active",
|
|
89
|
+
},
|
|
90
|
+
body: (title) =>
|
|
91
|
+
`# ${title}\n\n## Goal\n\n## Scope\n\n## Open Questions\n\n## Next Steps\n`,
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
id: "research-note",
|
|
95
|
+
label: "Research Note",
|
|
96
|
+
description: "Summary, key ideas, evidence, and follow-up questions.",
|
|
97
|
+
defaultTags: ["research"],
|
|
98
|
+
frontmatter: {
|
|
99
|
+
category: "research",
|
|
100
|
+
},
|
|
101
|
+
body: (title) =>
|
|
102
|
+
`# ${title}\n\n## Summary\n\n## Key Ideas\n\n## Evidence\n\n## Follow-up Questions\n`,
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
id: "decision-note",
|
|
106
|
+
label: "Decision Note",
|
|
107
|
+
description: "Context, decision, rationale, and consequences.",
|
|
108
|
+
defaultTags: ["decision"],
|
|
109
|
+
frontmatter: {
|
|
110
|
+
category: "decision",
|
|
111
|
+
status: "proposed",
|
|
112
|
+
},
|
|
113
|
+
body: (title) =>
|
|
114
|
+
`# ${title}\n\n## Context\n\n## Decision\n\n## Rationale\n\n## Consequences\n`,
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
id: "prompt-pattern",
|
|
118
|
+
label: "Prompt / Pattern",
|
|
119
|
+
description: "Capture reusable prompts, constraints, and examples.",
|
|
120
|
+
defaultTags: ["prompt", "pattern"],
|
|
121
|
+
frontmatter: {
|
|
122
|
+
category: "pattern",
|
|
123
|
+
},
|
|
124
|
+
body: (title) =>
|
|
125
|
+
`# ${title}\n\n## Use Case\n\n## Prompt\n\n## Constraints\n\n## Example\n`,
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
id: "source-summary",
|
|
129
|
+
label: "Source Summary",
|
|
130
|
+
description: "Summarize an article, paper, or external source cleanly.",
|
|
131
|
+
defaultTags: ["source", "summary"],
|
|
132
|
+
frontmatter: {
|
|
133
|
+
category: "source-summary",
|
|
134
|
+
sources: [],
|
|
135
|
+
},
|
|
136
|
+
body: (title) =>
|
|
137
|
+
`# ${title}\n\n## Summary\n\n## Important Claims\n\n## Evidence / Quotes\n\n## Takeaways\n`,
|
|
138
|
+
},
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
export function getNotePreset(
|
|
142
|
+
presetId?: string | null
|
|
143
|
+
): NotePresetDefinition | null {
|
|
144
|
+
if (!presetId) {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
return NOTE_PRESETS.find((preset) => preset.id === presetId) ?? null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function resolveNotePreset(input: {
|
|
151
|
+
presetId?: string | null;
|
|
152
|
+
title: string;
|
|
153
|
+
tags?: string[];
|
|
154
|
+
frontmatter?: Record<string, string | string[]>;
|
|
155
|
+
body?: string;
|
|
156
|
+
}): ResolvedNotePreset | null {
|
|
157
|
+
const preset = getNotePreset(input.presetId);
|
|
158
|
+
if (!preset) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const title = input.title.trim() || "Untitled";
|
|
163
|
+
const presetTags = (preset.defaultTags ?? []).map(normalizeTag);
|
|
164
|
+
const tags = [
|
|
165
|
+
...new Set([...(input.tags ?? []).map(normalizeTag), ...presetTags]),
|
|
166
|
+
];
|
|
167
|
+
const frontmatter = {
|
|
168
|
+
...preset.frontmatter,
|
|
169
|
+
...input.frontmatter,
|
|
170
|
+
...(tags.length > 0 ? { tags } : {}),
|
|
171
|
+
};
|
|
172
|
+
const body = input.body?.trim().length ? input.body : preset.body(title);
|
|
173
|
+
const frontmatterBlock = serializeFrontmatter(frontmatter);
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
preset,
|
|
177
|
+
title,
|
|
178
|
+
tags,
|
|
179
|
+
frontmatter,
|
|
180
|
+
body,
|
|
181
|
+
content: `${frontmatterBlock}${body}`.trimEnd() + "\n",
|
|
182
|
+
};
|
|
183
|
+
}
|