@gmickel/gno 0.25.2 → 0.27.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 +5 -3
- package/assets/skill/SKILL.md +5 -0
- package/assets/skill/cli-reference.md +8 -6
- package/package.json +1 -1
- package/src/cli/commands/get.ts +21 -0
- package/src/cli/commands/skill/install.ts +2 -2
- package/src/cli/commands/skill/paths.ts +26 -4
- package/src/cli/commands/skill/uninstall.ts +2 -2
- package/src/cli/program.ts +18 -12
- package/src/core/document-capabilities.ts +113 -0
- package/src/mcp/tools/get.ts +10 -0
- package/src/mcp/tools/index.ts +434 -110
- package/src/sdk/documents.ts +12 -0
- package/src/serve/doc-events.ts +69 -0
- package/src/serve/public/app.tsx +81 -24
- package/src/serve/public/components/CaptureModal.tsx +138 -3
- package/src/serve/public/components/QuickSwitcher.tsx +248 -0
- package/src/serve/public/components/ShortcutHelpModal.tsx +1 -0
- package/src/serve/public/components/ai-elements/code-block.tsx +74 -26
- package/src/serve/public/components/editor/CodeMirrorEditor.tsx +51 -0
- package/src/serve/public/components/ui/command.tsx +2 -2
- package/src/serve/public/hooks/use-doc-events.ts +34 -0
- package/src/serve/public/hooks/useCaptureModal.tsx +12 -3
- package/src/serve/public/hooks/useKeyboardShortcuts.ts +2 -2
- package/src/serve/public/lib/deep-links.ts +68 -0
- package/src/serve/public/lib/document-availability.ts +22 -0
- package/src/serve/public/lib/local-history.ts +44 -0
- package/src/serve/public/lib/wiki-link.ts +36 -0
- package/src/serve/public/pages/Browse.tsx +11 -0
- package/src/serve/public/pages/Dashboard.tsx +2 -2
- package/src/serve/public/pages/DocView.tsx +241 -18
- package/src/serve/public/pages/DocumentEditor.tsx +399 -9
- package/src/serve/public/pages/Search.tsx +20 -1
- package/src/serve/routes/api.ts +359 -28
- package/src/serve/server.ts +48 -1
- package/src/serve/watch-service.ts +149 -0
package/src/serve/routes/api.ts
CHANGED
|
@@ -13,10 +13,17 @@ import type {
|
|
|
13
13
|
SearchOptions,
|
|
14
14
|
} from "../../pipeline/types";
|
|
15
15
|
import type { SqliteAdapter } from "../../store/sqlite/adapter";
|
|
16
|
+
import type { DocumentEventBus } from "../doc-events";
|
|
16
17
|
import type { EmbedScheduler } from "../embed-scheduler";
|
|
18
|
+
import type { CollectionWatchService } from "../watch-service";
|
|
17
19
|
|
|
18
20
|
import { modelsPull } from "../../cli/commands/models/pull";
|
|
19
21
|
import { addCollection, removeCollection } from "../../collection";
|
|
22
|
+
import {
|
|
23
|
+
buildEditableCopyContent,
|
|
24
|
+
deriveEditableCopyRelPath,
|
|
25
|
+
getDocumentCapabilities,
|
|
26
|
+
} from "../../core/document-capabilities";
|
|
20
27
|
import { atomicWrite } from "../../core/file-ops";
|
|
21
28
|
import { normalizeStructuredQueryInput } from "../../core/structured-query";
|
|
22
29
|
import {
|
|
@@ -49,6 +56,8 @@ export interface ContextHolder {
|
|
|
49
56
|
current: ServerContext;
|
|
50
57
|
config: Config;
|
|
51
58
|
scheduler: EmbedScheduler | null;
|
|
59
|
+
eventBus: DocumentEventBus | null;
|
|
60
|
+
watchService: CollectionWatchService | null;
|
|
52
61
|
}
|
|
53
62
|
|
|
54
63
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -155,6 +164,15 @@ export interface UpdateDocRequestBody {
|
|
|
155
164
|
content?: string;
|
|
156
165
|
/** Tags to set (replaces existing tags) */
|
|
157
166
|
tags?: string[];
|
|
167
|
+
/** Expected source hash for optimistic concurrency */
|
|
168
|
+
expectedSourceHash?: string;
|
|
169
|
+
/** Expected source modified timestamp for optimistic concurrency */
|
|
170
|
+
expectedModifiedAt?: string;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface CreateEditableCopyRequestBody {
|
|
174
|
+
collection?: string;
|
|
175
|
+
relPath?: string;
|
|
158
176
|
}
|
|
159
177
|
|
|
160
178
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -180,6 +198,78 @@ function parseCommaSeparatedValues(input: string): string[] {
|
|
|
180
198
|
);
|
|
181
199
|
}
|
|
182
200
|
|
|
201
|
+
function hashContent(content: string): string {
|
|
202
|
+
const hasher = new Bun.CryptoHasher("sha256");
|
|
203
|
+
hasher.update(content);
|
|
204
|
+
return hasher.digest("hex");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
interface SourceMeta {
|
|
208
|
+
absPath?: string;
|
|
209
|
+
mime: string;
|
|
210
|
+
ext: string;
|
|
211
|
+
modifiedAt?: string;
|
|
212
|
+
sizeBytes?: number;
|
|
213
|
+
sourceHash?: string;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function getCollectionByName(
|
|
217
|
+
collections: Config["collections"],
|
|
218
|
+
collectionName: string
|
|
219
|
+
) {
|
|
220
|
+
return collections.find(
|
|
221
|
+
(c) => c.name.toLowerCase() === collectionName.toLowerCase()
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function resolveAbsoluteDocPath(
|
|
226
|
+
collections: Config["collections"],
|
|
227
|
+
doc: { collection: string; relPath: string }
|
|
228
|
+
): Promise<{
|
|
229
|
+
collection: Config["collections"][number];
|
|
230
|
+
fullPath: string;
|
|
231
|
+
} | null> {
|
|
232
|
+
const collection = getCollectionByName(collections, doc.collection);
|
|
233
|
+
if (!collection) {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const nodePath = await import("node:path"); // no bun equivalent
|
|
238
|
+
let safeRelPath: string;
|
|
239
|
+
try {
|
|
240
|
+
safeRelPath = validateRelPath(doc.relPath);
|
|
241
|
+
} catch {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
collection,
|
|
246
|
+
fullPath: nodePath.join(collection.path, safeRelPath),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function buildSourceMeta(
|
|
251
|
+
collections: Config["collections"],
|
|
252
|
+
doc: {
|
|
253
|
+
collection: string;
|
|
254
|
+
relPath: string;
|
|
255
|
+
sourceMime: string;
|
|
256
|
+
sourceExt: string;
|
|
257
|
+
sourceMtime?: string | null;
|
|
258
|
+
sourceSize?: number;
|
|
259
|
+
sourceHash?: string;
|
|
260
|
+
}
|
|
261
|
+
): Promise<SourceMeta> {
|
|
262
|
+
const resolved = await resolveAbsoluteDocPath(collections, doc);
|
|
263
|
+
return {
|
|
264
|
+
absPath: resolved?.fullPath,
|
|
265
|
+
mime: doc.sourceMime,
|
|
266
|
+
ext: doc.sourceExt,
|
|
267
|
+
modifiedAt: doc.sourceMtime ?? undefined,
|
|
268
|
+
sizeBytes: doc.sourceSize,
|
|
269
|
+
sourceHash: doc.sourceHash,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
183
273
|
function parseQueryModesInput(value: unknown): {
|
|
184
274
|
queryModes?: QueryModeInput[];
|
|
185
275
|
error?: Response;
|
|
@@ -691,6 +781,48 @@ export async function handleDocs(
|
|
|
691
781
|
});
|
|
692
782
|
}
|
|
693
783
|
|
|
784
|
+
/**
|
|
785
|
+
* GET /api/docs/autocomplete
|
|
786
|
+
* Query params: query, collection, limit
|
|
787
|
+
*/
|
|
788
|
+
export async function handleDocsAutocomplete(
|
|
789
|
+
store: SqliteAdapter,
|
|
790
|
+
url: URL
|
|
791
|
+
): Promise<Response> {
|
|
792
|
+
const query = (url.searchParams.get("query") ?? "").trim().toLowerCase();
|
|
793
|
+
const collection = url.searchParams.get("collection") || undefined;
|
|
794
|
+
const limit = Math.min(Number(url.searchParams.get("limit") ?? "8") || 8, 20);
|
|
795
|
+
|
|
796
|
+
const result = await store.listDocuments(collection);
|
|
797
|
+
if (!result.ok) {
|
|
798
|
+
return errorResponse("RUNTIME", result.error.message, 500);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const candidates = result.value
|
|
802
|
+
.filter((doc) => doc.active)
|
|
803
|
+
.map((doc) => ({
|
|
804
|
+
docid: doc.docid,
|
|
805
|
+
uri: doc.uri,
|
|
806
|
+
title:
|
|
807
|
+
doc.title ??
|
|
808
|
+
doc.relPath
|
|
809
|
+
.split("/")
|
|
810
|
+
.pop()
|
|
811
|
+
?.replace(/\.[^.]+$/, "") ??
|
|
812
|
+
doc.relPath,
|
|
813
|
+
collection: doc.collection,
|
|
814
|
+
}))
|
|
815
|
+
.filter((doc) => {
|
|
816
|
+
if (!query) return true;
|
|
817
|
+
const haystack = `${doc.title} ${doc.uri}`.toLowerCase();
|
|
818
|
+
return haystack.includes(query);
|
|
819
|
+
})
|
|
820
|
+
.sort((a, b) => a.title.localeCompare(b.title))
|
|
821
|
+
.slice(0, limit);
|
|
822
|
+
|
|
823
|
+
return jsonResponse({ docs: candidates });
|
|
824
|
+
}
|
|
825
|
+
|
|
694
826
|
/**
|
|
695
827
|
* GET /api/doc
|
|
696
828
|
* Query params: uri (required)
|
|
@@ -698,6 +830,7 @@ export async function handleDocs(
|
|
|
698
830
|
*/
|
|
699
831
|
export async function handleDoc(
|
|
700
832
|
store: SqliteAdapter,
|
|
833
|
+
config: Config,
|
|
701
834
|
url: URL
|
|
702
835
|
): Promise<Response> {
|
|
703
836
|
const uri = url.searchParams.get("uri");
|
|
@@ -730,21 +863,25 @@ export async function handleDoc(
|
|
|
730
863
|
tags = tagsResult.value.map((t) => t.tag);
|
|
731
864
|
}
|
|
732
865
|
|
|
866
|
+
const contentAvailable = content !== null;
|
|
867
|
+
const capabilities = getDocumentCapabilities({
|
|
868
|
+
sourceExt: doc.sourceExt,
|
|
869
|
+
sourceMime: doc.sourceMime,
|
|
870
|
+
contentAvailable,
|
|
871
|
+
});
|
|
872
|
+
const source = await buildSourceMeta(config.collections, doc);
|
|
873
|
+
|
|
733
874
|
return jsonResponse({
|
|
734
875
|
docid: doc.docid,
|
|
735
876
|
uri: doc.uri,
|
|
736
877
|
title: doc.title,
|
|
737
878
|
content,
|
|
738
|
-
contentAvailable
|
|
879
|
+
contentAvailable,
|
|
739
880
|
collection: doc.collection,
|
|
740
881
|
relPath: doc.relPath,
|
|
741
882
|
tags,
|
|
742
|
-
source
|
|
743
|
-
|
|
744
|
-
ext: doc.sourceExt,
|
|
745
|
-
modifiedAt: doc.sourceMtime,
|
|
746
|
-
sizeBytes: doc.sourceSize,
|
|
747
|
-
},
|
|
883
|
+
source,
|
|
884
|
+
capabilities,
|
|
748
885
|
});
|
|
749
886
|
}
|
|
750
887
|
|
|
@@ -862,6 +999,18 @@ export async function handleUpdateDoc(
|
|
|
862
999
|
if (hasContent && typeof body.content !== "string") {
|
|
863
1000
|
return errorResponse("VALIDATION", "content must be a string");
|
|
864
1001
|
}
|
|
1002
|
+
if (
|
|
1003
|
+
body.expectedSourceHash !== undefined &&
|
|
1004
|
+
typeof body.expectedSourceHash !== "string"
|
|
1005
|
+
) {
|
|
1006
|
+
return errorResponse("VALIDATION", "expectedSourceHash must be a string");
|
|
1007
|
+
}
|
|
1008
|
+
if (
|
|
1009
|
+
body.expectedModifiedAt !== undefined &&
|
|
1010
|
+
typeof body.expectedModifiedAt !== "string"
|
|
1011
|
+
) {
|
|
1012
|
+
return errorResponse("VALIDATION", "expectedModifiedAt must be a string");
|
|
1013
|
+
}
|
|
865
1014
|
|
|
866
1015
|
// Validate tags if provided
|
|
867
1016
|
let normalizedTags: string[] | undefined;
|
|
@@ -896,29 +1045,18 @@ export async function handleUpdateDoc(
|
|
|
896
1045
|
|
|
897
1046
|
const doc = docResult.value;
|
|
898
1047
|
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
(c) => c.name.toLowerCase() === collectionName
|
|
1048
|
+
const resolvedDocPath = await resolveAbsoluteDocPath(
|
|
1049
|
+
ctxHolder.config.collections,
|
|
1050
|
+
doc
|
|
903
1051
|
);
|
|
904
|
-
if (!
|
|
1052
|
+
if (!resolvedDocPath) {
|
|
905
1053
|
return errorResponse(
|
|
906
1054
|
"NOT_FOUND",
|
|
907
1055
|
`Collection not found: ${doc.collection}`,
|
|
908
1056
|
404
|
|
909
1057
|
);
|
|
910
1058
|
}
|
|
911
|
-
|
|
912
|
-
// Validate and resolve full path
|
|
913
|
-
// Critical: validate relPath from DB to prevent path traversal attacks
|
|
914
|
-
const nodePath = await import("node:path"); // no bun equivalent
|
|
915
|
-
let safeRelPath: string;
|
|
916
|
-
try {
|
|
917
|
-
safeRelPath = validateRelPath(doc.relPath);
|
|
918
|
-
} catch {
|
|
919
|
-
return errorResponse("VALIDATION", "Invalid document relPath in DB", 400);
|
|
920
|
-
}
|
|
921
|
-
const fullPath = nodePath.join(collection.path, safeRelPath);
|
|
1059
|
+
const { collection, fullPath } = resolvedDocPath;
|
|
922
1060
|
|
|
923
1061
|
// Verify file exists
|
|
924
1062
|
const file = Bun.file(fullPath);
|
|
@@ -926,9 +1064,49 @@ export async function handleUpdateDoc(
|
|
|
926
1064
|
return errorResponse("FILE_NOT_FOUND", "Source file no longer exists", 404);
|
|
927
1065
|
}
|
|
928
1066
|
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
1067
|
+
if (body.expectedSourceHash || body.expectedModifiedAt) {
|
|
1068
|
+
const currentBytes = await file.bytes();
|
|
1069
|
+
const currentSourceHash = hashContent(
|
|
1070
|
+
new TextDecoder().decode(currentBytes)
|
|
1071
|
+
);
|
|
1072
|
+
const { stat } = await import("node:fs/promises"); // no Bun structure stat parity
|
|
1073
|
+
const currentModifiedAt = (await stat(fullPath)).mtime.toISOString();
|
|
1074
|
+
|
|
1075
|
+
if (
|
|
1076
|
+
(body.expectedSourceHash &&
|
|
1077
|
+
body.expectedSourceHash !== currentSourceHash) ||
|
|
1078
|
+
(body.expectedModifiedAt && body.expectedModifiedAt !== currentModifiedAt)
|
|
1079
|
+
) {
|
|
1080
|
+
return jsonResponse(
|
|
1081
|
+
{
|
|
1082
|
+
error: {
|
|
1083
|
+
code: "CONFLICT",
|
|
1084
|
+
message: "Document changed on disk. Reload before saving.",
|
|
1085
|
+
},
|
|
1086
|
+
currentVersion: {
|
|
1087
|
+
sourceHash: currentSourceHash,
|
|
1088
|
+
modifiedAt: currentModifiedAt,
|
|
1089
|
+
},
|
|
1090
|
+
},
|
|
1091
|
+
409
|
|
1092
|
+
);
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
const capabilities = getDocumentCapabilities({
|
|
1097
|
+
sourceExt: doc.sourceExt,
|
|
1098
|
+
sourceMime: doc.sourceMime,
|
|
1099
|
+
contentAvailable: doc.mirrorHash !== null,
|
|
1100
|
+
});
|
|
1101
|
+
if (hasContent && !capabilities.editable) {
|
|
1102
|
+
return errorResponse(
|
|
1103
|
+
"READ_ONLY",
|
|
1104
|
+
capabilities.reason ??
|
|
1105
|
+
"This document cannot be edited in place. Create an editable markdown copy instead.",
|
|
1106
|
+
409
|
|
1107
|
+
);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
932
1110
|
let writeBack: "applied" | "skipped_unsupported" | undefined;
|
|
933
1111
|
|
|
934
1112
|
try {
|
|
@@ -941,7 +1119,7 @@ export async function handleUpdateDoc(
|
|
|
941
1119
|
|
|
942
1120
|
// Handle tag writeback for Markdown files
|
|
943
1121
|
if (hasTags && normalizedTags) {
|
|
944
|
-
if (
|
|
1122
|
+
if (capabilities.tagsWriteback) {
|
|
945
1123
|
// Read current content if we're only updating tags
|
|
946
1124
|
const source = contentToWrite ?? (await file.text());
|
|
947
1125
|
contentToWrite = updateFrontmatterTags(source, normalizedTags);
|
|
@@ -957,9 +1135,16 @@ export async function handleUpdateDoc(
|
|
|
957
1135
|
}
|
|
958
1136
|
}
|
|
959
1137
|
|
|
1138
|
+
let currentSourceHash = doc.sourceHash;
|
|
1139
|
+
let currentModifiedAt = doc.sourceMtime;
|
|
1140
|
+
|
|
960
1141
|
// Write file if we have content to write
|
|
961
1142
|
if (contentToWrite !== undefined) {
|
|
1143
|
+
ctxHolder.watchService?.suppress(fullPath);
|
|
962
1144
|
await atomicWrite(fullPath, contentToWrite);
|
|
1145
|
+
currentSourceHash = hashContent(contentToWrite);
|
|
1146
|
+
const { stat } = await import("node:fs/promises"); // no Bun structure stat parity
|
|
1147
|
+
currentModifiedAt = (await stat(fullPath)).mtime.toISOString();
|
|
963
1148
|
}
|
|
964
1149
|
|
|
965
1150
|
// Build proper file:// URI using node:url
|
|
@@ -978,6 +1163,14 @@ export async function handleUpdateDoc(
|
|
|
978
1163
|
);
|
|
979
1164
|
// Notify scheduler after sync completes
|
|
980
1165
|
ctxHolder.scheduler?.notifySyncComplete([doc.docid]);
|
|
1166
|
+
ctxHolder.eventBus?.emit({
|
|
1167
|
+
type: "document-changed",
|
|
1168
|
+
uri: doc.uri,
|
|
1169
|
+
collection: doc.collection,
|
|
1170
|
+
relPath: doc.relPath,
|
|
1171
|
+
origin: "save",
|
|
1172
|
+
changedAt: new Date().toISOString(),
|
|
1173
|
+
});
|
|
981
1174
|
return {
|
|
982
1175
|
collections: [result],
|
|
983
1176
|
totalDurationMs: result.durationMs,
|
|
@@ -998,6 +1191,10 @@ export async function handleUpdateDoc(
|
|
|
998
1191
|
path: fullPath,
|
|
999
1192
|
jobId,
|
|
1000
1193
|
writeBack,
|
|
1194
|
+
version: {
|
|
1195
|
+
sourceHash: currentSourceHash,
|
|
1196
|
+
modifiedAt: currentModifiedAt,
|
|
1197
|
+
},
|
|
1001
1198
|
});
|
|
1002
1199
|
} catch (e) {
|
|
1003
1200
|
return errorResponse(
|
|
@@ -1008,6 +1205,126 @@ export async function handleUpdateDoc(
|
|
|
1008
1205
|
}
|
|
1009
1206
|
}
|
|
1010
1207
|
|
|
1208
|
+
/**
|
|
1209
|
+
* POST /api/docs/:id/editable-copy
|
|
1210
|
+
* Create a markdown copy for a read-only/converted document.
|
|
1211
|
+
*/
|
|
1212
|
+
export async function handleCreateEditableCopy(
|
|
1213
|
+
ctxHolder: ContextHolder,
|
|
1214
|
+
store: SqliteAdapter,
|
|
1215
|
+
docId: string,
|
|
1216
|
+
req: Request
|
|
1217
|
+
): Promise<Response> {
|
|
1218
|
+
let body: CreateEditableCopyRequestBody = {};
|
|
1219
|
+
try {
|
|
1220
|
+
const text = await req.text();
|
|
1221
|
+
if (text) {
|
|
1222
|
+
body = JSON.parse(text) as CreateEditableCopyRequestBody;
|
|
1223
|
+
}
|
|
1224
|
+
} catch {
|
|
1225
|
+
return errorResponse("VALIDATION", "Invalid JSON body");
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
if (body.collection !== undefined && typeof body.collection !== "string") {
|
|
1229
|
+
return errorResponse("VALIDATION", "collection must be a string");
|
|
1230
|
+
}
|
|
1231
|
+
if (body.relPath !== undefined && typeof body.relPath !== "string") {
|
|
1232
|
+
return errorResponse("VALIDATION", "relPath must be a string");
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
const docResult = await store.getDocumentByDocid(docId);
|
|
1236
|
+
if (!docResult.ok) {
|
|
1237
|
+
return errorResponse("RUNTIME", docResult.error.message, 500);
|
|
1238
|
+
}
|
|
1239
|
+
if (!docResult.value) {
|
|
1240
|
+
return errorResponse("NOT_FOUND", "Document not found", 404);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
const doc = docResult.value;
|
|
1244
|
+
const contentAvailable = doc.mirrorHash !== null;
|
|
1245
|
+
const capabilities = getDocumentCapabilities({
|
|
1246
|
+
sourceExt: doc.sourceExt,
|
|
1247
|
+
sourceMime: doc.sourceMime,
|
|
1248
|
+
contentAvailable,
|
|
1249
|
+
});
|
|
1250
|
+
if (capabilities.editable) {
|
|
1251
|
+
return errorResponse(
|
|
1252
|
+
"VALIDATION",
|
|
1253
|
+
"Document is already editable in place; use the normal update route instead."
|
|
1254
|
+
);
|
|
1255
|
+
}
|
|
1256
|
+
if (!doc.mirrorHash) {
|
|
1257
|
+
return errorResponse(
|
|
1258
|
+
"RUNTIME",
|
|
1259
|
+
"Editable copy unavailable because converted content is missing.",
|
|
1260
|
+
409
|
|
1261
|
+
);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
const contentResult = await store.getContent(doc.mirrorHash);
|
|
1265
|
+
if (!contentResult.ok || contentResult.value === null) {
|
|
1266
|
+
return errorResponse(
|
|
1267
|
+
"RUNTIME",
|
|
1268
|
+
"Editable copy unavailable because converted content is missing.",
|
|
1269
|
+
409
|
|
1270
|
+
);
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
const tagsResult = await store.getTagsForDoc(doc.id);
|
|
1274
|
+
const tags = tagsResult.ok ? tagsResult.value.map((tag) => tag.tag) : [];
|
|
1275
|
+
|
|
1276
|
+
const targetCollectionName = body.collection ?? doc.collection;
|
|
1277
|
+
const targetCollection = getCollectionByName(
|
|
1278
|
+
ctxHolder.config.collections,
|
|
1279
|
+
targetCollectionName
|
|
1280
|
+
);
|
|
1281
|
+
if (!targetCollection) {
|
|
1282
|
+
return errorResponse(
|
|
1283
|
+
"NOT_FOUND",
|
|
1284
|
+
`Collection not found: ${targetCollectionName}`,
|
|
1285
|
+
404
|
|
1286
|
+
);
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
let relPath = body.relPath;
|
|
1290
|
+
if (!relPath) {
|
|
1291
|
+
const listResult = await store.listDocuments(targetCollection.name);
|
|
1292
|
+
const existingRelPaths = listResult.ok
|
|
1293
|
+
? listResult.value.map((entry) => entry.relPath)
|
|
1294
|
+
: [];
|
|
1295
|
+
relPath = deriveEditableCopyRelPath(doc.relPath, existingRelPaths);
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
const title =
|
|
1299
|
+
doc.title ??
|
|
1300
|
+
doc.relPath
|
|
1301
|
+
.split("/")
|
|
1302
|
+
.pop()
|
|
1303
|
+
?.replace(/\.[^.]+$/, "") ??
|
|
1304
|
+
"Copy";
|
|
1305
|
+
const content = buildEditableCopyContent({
|
|
1306
|
+
title,
|
|
1307
|
+
sourceDocid: doc.docid,
|
|
1308
|
+
sourceUri: doc.uri,
|
|
1309
|
+
sourceMime: doc.sourceMime,
|
|
1310
|
+
sourceExt: doc.sourceExt,
|
|
1311
|
+
content: contentResult.value,
|
|
1312
|
+
tags,
|
|
1313
|
+
});
|
|
1314
|
+
|
|
1315
|
+
const createReq = new Request("http://localhost/api/docs", {
|
|
1316
|
+
method: "POST",
|
|
1317
|
+
body: JSON.stringify({
|
|
1318
|
+
collection: targetCollection.name,
|
|
1319
|
+
relPath,
|
|
1320
|
+
content,
|
|
1321
|
+
tags,
|
|
1322
|
+
} satisfies CreateDocRequestBody),
|
|
1323
|
+
});
|
|
1324
|
+
|
|
1325
|
+
return handleCreateDoc(ctxHolder, store, createReq);
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1011
1328
|
/**
|
|
1012
1329
|
* POST /api/docs
|
|
1013
1330
|
* Create a new document in a collection.
|
|
@@ -1102,6 +1419,7 @@ export async function handleCreateDoc(
|
|
|
1102
1419
|
contentToWrite = updateFrontmatterTags(body.content, validatedTags);
|
|
1103
1420
|
}
|
|
1104
1421
|
|
|
1422
|
+
ctxHolder.watchService?.suppress(fullPath);
|
|
1105
1423
|
await atomicWrite(fullPath, contentToWrite);
|
|
1106
1424
|
|
|
1107
1425
|
// Build gno:// URI for the created document
|
|
@@ -1119,6 +1437,14 @@ export async function handleCreateDoc(
|
|
|
1119
1437
|
// The sync will create a proper docid, but we don't have it here yet
|
|
1120
1438
|
// Using normalizedRelPath as identifier since docid is generated during sync
|
|
1121
1439
|
ctxHolder.scheduler?.notifySyncComplete([normalizedRelPath]);
|
|
1440
|
+
ctxHolder.eventBus?.emit({
|
|
1441
|
+
type: "document-changed",
|
|
1442
|
+
uri: gnoUri,
|
|
1443
|
+
collection: collection.name,
|
|
1444
|
+
relPath: normalizedRelPath,
|
|
1445
|
+
origin: "create",
|
|
1446
|
+
changedAt: new Date().toISOString(),
|
|
1447
|
+
});
|
|
1122
1448
|
return {
|
|
1123
1449
|
collections: [result],
|
|
1124
1450
|
totalDurationMs: result.durationMs,
|
|
@@ -1947,6 +2273,7 @@ export function handleEmbedStatus(scheduler: EmbedScheduler | null): Response {
|
|
|
1947
2273
|
// oxlint-disable-next-line typescript-eslint/require-await -- handlers are async, kept for future use
|
|
1948
2274
|
export async function routeApi(
|
|
1949
2275
|
store: SqliteAdapter,
|
|
2276
|
+
config: Config,
|
|
1950
2277
|
req: Request,
|
|
1951
2278
|
url: URL
|
|
1952
2279
|
): Promise<Response | null> {
|
|
@@ -1999,8 +2326,12 @@ export async function routeApi(
|
|
|
1999
2326
|
return handleDocs(store, url);
|
|
2000
2327
|
}
|
|
2001
2328
|
|
|
2329
|
+
if (path === "/api/docs/autocomplete") {
|
|
2330
|
+
return handleDocsAutocomplete(store, url);
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2002
2333
|
if (path === "/api/doc") {
|
|
2003
|
-
return handleDoc(store, url);
|
|
2334
|
+
return handleDoc(store, config, url);
|
|
2004
2335
|
}
|
|
2005
2336
|
|
|
2006
2337
|
if (path === "/api/search" && req.method === "POST") {
|
package/src/serve/server.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { getConfigPaths, isInitialized, loadConfig } from "../config";
|
|
|
13
13
|
import { getActivePreset } from "../llm/registry";
|
|
14
14
|
import { SqliteAdapter } from "../store/sqlite/adapter";
|
|
15
15
|
import { createServerContext, disposeServerContext } from "./context";
|
|
16
|
+
import { DocumentEventBus } from "./doc-events";
|
|
16
17
|
import { createEmbedScheduler } from "./embed-scheduler";
|
|
17
18
|
// HTML import - Bun handles bundling TSX/CSS automatically via routes
|
|
18
19
|
import homepage from "./public/index.html";
|
|
@@ -21,10 +22,12 @@ import {
|
|
|
21
22
|
handleCapabilities,
|
|
22
23
|
handleCollections,
|
|
23
24
|
handleCreateCollection,
|
|
25
|
+
handleCreateEditableCopy,
|
|
24
26
|
handleCreateDoc,
|
|
25
27
|
handleDeactivateDoc,
|
|
26
28
|
handleDeleteCollection,
|
|
27
29
|
handleDoc,
|
|
30
|
+
handleDocsAutocomplete,
|
|
28
31
|
handleDocs,
|
|
29
32
|
handleEmbed,
|
|
30
33
|
handleEmbedStatus,
|
|
@@ -48,6 +51,7 @@ import {
|
|
|
48
51
|
handleDocSimilar,
|
|
49
52
|
} from "./routes/links";
|
|
50
53
|
import { forbiddenResponse, isRequestAllowed } from "./security";
|
|
54
|
+
import { CollectionWatchService } from "./watch-service";
|
|
51
55
|
|
|
52
56
|
export interface ServeOptions {
|
|
53
57
|
/** Port to listen on (default: 3000) */
|
|
@@ -168,6 +172,8 @@ export async function startServer(
|
|
|
168
172
|
current: ctx,
|
|
169
173
|
config, // Keep original config for reloading
|
|
170
174
|
scheduler: null, // Will be set below
|
|
175
|
+
eventBus: null,
|
|
176
|
+
watchService: null,
|
|
171
177
|
};
|
|
172
178
|
|
|
173
179
|
// Create embed scheduler with getters (survives context/preset reloads)
|
|
@@ -178,6 +184,16 @@ export async function startServer(
|
|
|
178
184
|
getModelUri: () => getActivePreset(ctxHolder.config).embed,
|
|
179
185
|
});
|
|
180
186
|
ctxHolder.scheduler = scheduler;
|
|
187
|
+
const eventBus = new DocumentEventBus();
|
|
188
|
+
ctxHolder.eventBus = eventBus;
|
|
189
|
+
const watchService = new CollectionWatchService({
|
|
190
|
+
collections: config.collections,
|
|
191
|
+
store,
|
|
192
|
+
scheduler,
|
|
193
|
+
eventBus,
|
|
194
|
+
});
|
|
195
|
+
watchService.start();
|
|
196
|
+
ctxHolder.watchService = watchService;
|
|
181
197
|
|
|
182
198
|
// Shutdown controller for clean lifecycle
|
|
183
199
|
const shutdownController = new AbortController();
|
|
@@ -185,6 +201,8 @@ export async function startServer(
|
|
|
185
201
|
// Graceful shutdown handler
|
|
186
202
|
const shutdown = async () => {
|
|
187
203
|
console.log("\nShutting down...");
|
|
204
|
+
watchService.dispose();
|
|
205
|
+
eventBus.close();
|
|
188
206
|
scheduler.dispose();
|
|
189
207
|
await disposeServerContext(ctxHolder.current);
|
|
190
208
|
await store.close();
|
|
@@ -263,6 +281,15 @@ export async function startServer(
|
|
|
263
281
|
);
|
|
264
282
|
},
|
|
265
283
|
},
|
|
284
|
+
"/api/docs/autocomplete": {
|
|
285
|
+
GET: async (req: Request) => {
|
|
286
|
+
const url = new URL(req.url);
|
|
287
|
+
return withSecurityHeaders(
|
|
288
|
+
await handleDocsAutocomplete(store, url),
|
|
289
|
+
isDev
|
|
290
|
+
);
|
|
291
|
+
},
|
|
292
|
+
},
|
|
266
293
|
"/api/docs/:id/deactivate": {
|
|
267
294
|
POST: async (req: Request) => {
|
|
268
295
|
if (!isRequestAllowed(req, port)) {
|
|
@@ -278,6 +305,20 @@ export async function startServer(
|
|
|
278
305
|
);
|
|
279
306
|
},
|
|
280
307
|
},
|
|
308
|
+
"/api/docs/:id/editable-copy": {
|
|
309
|
+
POST: async (req: Request) => {
|
|
310
|
+
if (!isRequestAllowed(req, port)) {
|
|
311
|
+
return withSecurityHeaders(forbiddenResponse(), isDev);
|
|
312
|
+
}
|
|
313
|
+
const url = new URL(req.url);
|
|
314
|
+
const parts = url.pathname.split("/");
|
|
315
|
+
const id = decodeURIComponent(parts[3] || "");
|
|
316
|
+
return withSecurityHeaders(
|
|
317
|
+
await handleCreateEditableCopy(ctxHolder, store, id, req),
|
|
318
|
+
isDev
|
|
319
|
+
);
|
|
320
|
+
},
|
|
321
|
+
},
|
|
281
322
|
"/api/docs/:id": {
|
|
282
323
|
PUT: async (req: Request) => {
|
|
283
324
|
if (!isRequestAllowed(req, port)) {
|
|
@@ -295,9 +336,15 @@ export async function startServer(
|
|
|
295
336
|
"/api/doc": {
|
|
296
337
|
GET: async (req: Request) => {
|
|
297
338
|
const url = new URL(req.url);
|
|
298
|
-
return withSecurityHeaders(
|
|
339
|
+
return withSecurityHeaders(
|
|
340
|
+
await handleDoc(store, ctxHolder.config, url),
|
|
341
|
+
isDev
|
|
342
|
+
);
|
|
299
343
|
},
|
|
300
344
|
},
|
|
345
|
+
"/api/events": {
|
|
346
|
+
GET: () => withSecurityHeaders(eventBus.createResponse(), isDev),
|
|
347
|
+
},
|
|
301
348
|
"/api/tags": {
|
|
302
349
|
GET: async (req: Request) => {
|
|
303
350
|
const url = new URL(req.url);
|