@gmickel/gno 0.26.0 → 0.27.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.
@@ -110,10 +110,15 @@ gno get gno://work/report.md --from 100 -l 20
110
110
  # With line numbers
111
111
  gno get gno://work/report.md --line-numbers
112
112
 
113
+ # JSON output with capabilities metadata
114
+ gno get gno://work/report.md --json
115
+
113
116
  # Multiple documents
114
117
  gno multi-get gno://work/doc1.md gno://work/doc2.md
115
118
  ```
116
119
 
120
+ **Editable vs read-only**: `gno get --json` returns a `capabilities` field showing whether a document is editable at its source. Markdown and plain text files are editable in place. Converted documents (PDF, DOCX, XLSX) are read-only -- to edit their content, create a new markdown note instead of overwriting the binary source.
121
+
117
122
  ## Search Then Get (common pipeline)
118
123
 
119
124
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gmickel/gno",
3
- "version": "0.26.0",
3
+ "version": "0.27.1",
4
4
  "description": "Local semantic search for your documents. Index Markdown, PDF, and Office files with hybrid BM25 + vector search.",
5
5
  "keywords": [
6
6
  "embeddings",
@@ -8,6 +8,10 @@
8
8
  import type { DocumentRow, StorePort, StoreResult } from "../../store/types";
9
9
  import type { ParsedRef } from "./ref-parser";
10
10
 
11
+ import {
12
+ getDocumentCapabilities,
13
+ type DocumentCapabilities,
14
+ } from "../../core/document-capabilities";
11
15
  import { parseRef } from "./ref-parser";
12
16
  import { initStore } from "./shared";
13
17
 
@@ -58,6 +62,7 @@ export interface GetResponse {
58
62
  converterVersion?: string;
59
63
  mirrorHash?: string;
60
64
  };
65
+ capabilities: DocumentCapabilities;
61
66
  }
62
67
 
63
68
  // ─────────────────────────────────────────────────────────────────────────────
@@ -203,6 +208,7 @@ function buildResponse(ctx: BuildResponseContext): GetResult {
203
208
  language: doc.languageHint ?? undefined,
204
209
  source: buildSourceMeta(doc, config),
205
210
  conversion: buildConversionMeta(doc),
211
+ capabilities: buildCapabilities(doc),
206
212
  },
207
213
  };
208
214
  }
@@ -233,6 +239,7 @@ function buildResponse(ctx: BuildResponseContext): GetResult {
233
239
  language: doc.languageHint ?? undefined,
234
240
  source: buildSourceMeta(doc, config),
235
241
  conversion: buildConversionMeta(doc),
242
+ capabilities: buildCapabilities(doc),
236
243
  },
237
244
  };
238
245
  }
@@ -247,6 +254,7 @@ interface DocRow {
247
254
  sourceMime: string;
248
255
  sourceExt: string;
249
256
  sourceSize: number;
257
+ sourceMtime?: string;
250
258
  sourceHash: string;
251
259
  }
252
260
 
@@ -262,11 +270,24 @@ function buildSourceMeta(
262
270
  relPath: doc.relPath,
263
271
  mime: doc.sourceMime,
264
272
  ext: doc.sourceExt,
273
+ modifiedAt: doc.sourceMtime ?? undefined,
265
274
  sizeBytes: doc.sourceSize,
266
275
  sourceHash: doc.sourceHash,
267
276
  };
268
277
  }
269
278
 
279
+ function buildCapabilities(doc: {
280
+ sourceExt: string;
281
+ sourceMime: string;
282
+ mirrorHash?: string | null;
283
+ }): DocumentCapabilities {
284
+ return getDocumentCapabilities({
285
+ sourceExt: doc.sourceExt,
286
+ sourceMime: doc.sourceMime,
287
+ contentAvailable: doc.mirrorHash !== null,
288
+ });
289
+ }
290
+
270
291
  interface ConversionDoc {
271
292
  converterId?: string | null;
272
293
  converterVersion?: string | null;
@@ -0,0 +1,113 @@
1
+ // node:path has no Bun equivalent
2
+ import { posix as pathPosix } from "node:path";
3
+
4
+ export type DocumentCapabilityMode = "editable" | "read_only";
5
+
6
+ export interface DocumentCapabilities {
7
+ editable: boolean;
8
+ tagsEditable: boolean;
9
+ tagsWriteback: boolean;
10
+ canCreateEditableCopy: boolean;
11
+ mode: DocumentCapabilityMode;
12
+ reason?: string;
13
+ }
14
+
15
+ const EDITABLE_EXTENSIONS = new Set([
16
+ ".md",
17
+ ".markdown",
18
+ ".mdx",
19
+ ".txt",
20
+ ".text",
21
+ ]);
22
+
23
+ function isTextLikeMime(mime: string): boolean {
24
+ return mime.startsWith("text/");
25
+ }
26
+
27
+ export function getDocumentCapabilities(input: {
28
+ sourceExt: string;
29
+ sourceMime: string;
30
+ contentAvailable: boolean;
31
+ }): DocumentCapabilities {
32
+ const ext = input.sourceExt.toLowerCase();
33
+ const editable =
34
+ EDITABLE_EXTENSIONS.has(ext) || isTextLikeMime(input.sourceMime);
35
+ const tagsWriteback = ext === ".md" || ext === ".markdown" || ext === ".mdx";
36
+
37
+ if (editable) {
38
+ return {
39
+ editable: true,
40
+ tagsEditable: true,
41
+ tagsWriteback,
42
+ canCreateEditableCopy: false,
43
+ mode: "editable",
44
+ };
45
+ }
46
+
47
+ return {
48
+ editable: false,
49
+ tagsEditable: true,
50
+ tagsWriteback: false,
51
+ canCreateEditableCopy: input.contentAvailable,
52
+ mode: "read_only",
53
+ reason:
54
+ "This document is derived from a source format that GNO cannot safely write back in place.",
55
+ };
56
+ }
57
+
58
+ export function deriveEditableCopyRelPath(
59
+ relPath: string,
60
+ existingRelPaths: Iterable<string> = []
61
+ ): string {
62
+ const parsed = pathPosix.parse(relPath);
63
+ const prefix = parsed.dir ? `${parsed.dir}/` : "";
64
+ const baseName = parsed.name || "copy";
65
+ const existing = new Set(existingRelPaths);
66
+
67
+ const baseCandidate =
68
+ parsed.ext.toLowerCase() === ".md" ||
69
+ parsed.ext.toLowerCase() === ".markdown" ||
70
+ parsed.ext.toLowerCase() === ".mdx"
71
+ ? `${prefix}${baseName}.copy.md`
72
+ : `${prefix}${baseName}.md`;
73
+
74
+ if (!existing.has(baseCandidate)) {
75
+ return baseCandidate;
76
+ }
77
+
78
+ let counter = 2;
79
+ while (true) {
80
+ const candidate = `${prefix}${baseName}.copy-${counter}.md`;
81
+ if (!existing.has(candidate)) {
82
+ return candidate;
83
+ }
84
+ counter += 1;
85
+ }
86
+ }
87
+
88
+ export function buildEditableCopyContent(input: {
89
+ title: string;
90
+ sourceDocid: string;
91
+ sourceUri: string;
92
+ sourceMime: string;
93
+ sourceExt: string;
94
+ content: string;
95
+ tags?: string[];
96
+ }): string {
97
+ const frontmatterLines = [
98
+ `title: ${JSON.stringify(input.title)}`,
99
+ `gno_source_docid: ${JSON.stringify(input.sourceDocid)}`,
100
+ `gno_source_uri: ${JSON.stringify(input.sourceUri)}`,
101
+ `gno_source_mime: ${JSON.stringify(input.sourceMime)}`,
102
+ `gno_source_ext: ${JSON.stringify(input.sourceExt)}`,
103
+ ];
104
+
105
+ if (input.tags && input.tags.length > 0) {
106
+ frontmatterLines.push("tags:");
107
+ for (const tag of input.tags) {
108
+ frontmatterLines.push(` - ${JSON.stringify(tag)}`);
109
+ }
110
+ }
111
+
112
+ return `---\n${frontmatterLines.join("\n")}\n---\n\n${input.content}`;
113
+ }
@@ -11,6 +11,10 @@ import type { ToolContext } from "../server";
11
11
 
12
12
  import { parseUri } from "../../app/constants";
13
13
  import { parseRef } from "../../cli/commands/ref-parser";
14
+ import {
15
+ getDocumentCapabilities,
16
+ type DocumentCapabilities,
17
+ } from "../../core/document-capabilities";
14
18
  import { runTool, type ToolResult } from "./index";
15
19
 
16
20
  interface GetInput {
@@ -42,6 +46,7 @@ interface GetResponse {
42
46
  converterVersion?: string;
43
47
  mirrorHash?: string;
44
48
  };
49
+ capabilities: DocumentCapabilities;
45
50
  }
46
51
 
47
52
  /**
@@ -213,6 +218,11 @@ export function handleGet(
213
218
  mirrorHash: doc.mirrorHash,
214
219
  }
215
220
  : undefined,
221
+ capabilities: getDocumentCapabilities({
222
+ sourceExt: doc.sourceExt,
223
+ sourceMime: doc.sourceMime,
224
+ contentAvailable: doc.mirrorHash !== null,
225
+ }),
216
226
  };
217
227
 
218
228
  return response;
@@ -23,6 +23,7 @@ import type {
23
23
  } from "./types";
24
24
 
25
25
  import { isGlobPattern, parseRef, splitRefs } from "../cli/commands/ref-parser";
26
+ import { getDocumentCapabilities } from "../core/document-capabilities";
26
27
  import { sdkError } from "./errors";
27
28
 
28
29
  const URI_PREFIX_PATTERN = /^gno:\/\/[^/]+\//;
@@ -53,6 +54,7 @@ function buildSourceMeta(
53
54
  mime: doc.sourceMime,
54
55
  ext: doc.sourceExt,
55
56
  sizeBytes: doc.sourceSize,
57
+ modifiedAt: doc.sourceMtime ?? undefined,
56
58
  sourceHash: doc.sourceHash,
57
59
  };
58
60
  }
@@ -122,6 +124,11 @@ export async function getDocumentByRef(
122
124
  language: doc.languageHint ?? undefined,
123
125
  source: buildSourceMeta(doc, config),
124
126
  conversion: buildConversionMeta(doc),
127
+ capabilities: getDocumentCapabilities({
128
+ sourceExt: doc.sourceExt,
129
+ sourceMime: doc.sourceMime,
130
+ contentAvailable: doc.mirrorHash !== null,
131
+ }),
125
132
  };
126
133
  }
127
134
 
@@ -144,6 +151,11 @@ export async function getDocumentByRef(
144
151
  language: doc.languageHint ?? undefined,
145
152
  source: buildSourceMeta(doc, config),
146
153
  conversion: buildConversionMeta(doc),
154
+ capabilities: getDocumentCapabilities({
155
+ sourceExt: doc.sourceExt,
156
+ sourceMime: doc.sourceMime,
157
+ contentAvailable: doc.mirrorHash !== null,
158
+ }),
147
159
  };
148
160
  }
149
161
 
@@ -0,0 +1,69 @@
1
+ export type DocumentEventOrigin = "watcher" | "save" | "create";
2
+
3
+ export interface DocumentEvent {
4
+ type: "document-changed";
5
+ uri: string;
6
+ collection: string;
7
+ relPath: string;
8
+ origin: DocumentEventOrigin;
9
+ changedAt: string;
10
+ }
11
+
12
+ const encoder = new TextEncoder();
13
+
14
+ export class DocumentEventBus {
15
+ readonly #controllers = new Set<
16
+ ReadableStreamDefaultController<Uint8Array>
17
+ >();
18
+
19
+ createResponse(): Response {
20
+ const controllers = this.#controllers;
21
+ let streamController: ReadableStreamDefaultController<Uint8Array> | null =
22
+ null;
23
+ const stream = new ReadableStream<Uint8Array>({
24
+ start(controller) {
25
+ streamController = controller;
26
+ controllers.add(controller);
27
+ controller.enqueue(encoder.encode(": connected\n\n"));
28
+ },
29
+ cancel() {
30
+ if (streamController) {
31
+ controllers.delete(streamController);
32
+ }
33
+ },
34
+ });
35
+
36
+ return new Response(stream, {
37
+ headers: {
38
+ "Content-Type": "text/event-stream",
39
+ "Cache-Control": "no-cache",
40
+ Connection: "keep-alive",
41
+ },
42
+ });
43
+ }
44
+
45
+ emit(event: DocumentEvent): void {
46
+ const payload = encoder.encode(
47
+ `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`
48
+ );
49
+
50
+ for (const controller of this.#controllers) {
51
+ try {
52
+ controller.enqueue(payload);
53
+ } catch {
54
+ this.#controllers.delete(controller);
55
+ }
56
+ }
57
+ }
58
+
59
+ close(): void {
60
+ for (const controller of this.#controllers) {
61
+ try {
62
+ controller.close();
63
+ } catch {
64
+ // Best-effort shutdown.
65
+ }
66
+ }
67
+ this.#controllers.clear();
68
+ }
69
+ }
@@ -2,9 +2,11 @@ import { useCallback, useEffect, useMemo, useState } from "react";
2
2
  import { createRoot } from "react-dom/client";
3
3
 
4
4
  import { HelpButton } from "./components/HelpButton";
5
+ import { QuickSwitcher, saveRecentDocument } from "./components/QuickSwitcher";
5
6
  import { ShortcutHelpModal } from "./components/ShortcutHelpModal";
6
- import { CaptureModalProvider } from "./hooks/useCaptureModal";
7
+ import { CaptureModalProvider, useCaptureModal } from "./hooks/useCaptureModal";
7
8
  import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
9
+ import { parseDocumentDeepLink } from "./lib/deep-links";
8
10
  import Ask from "./pages/Ask";
9
11
  import Browse from "./pages/Browse";
10
12
  import Collections from "./pages/Collections";
@@ -36,37 +38,48 @@ const routes: Record<Route, React.ComponentType<{ navigate: Navigate }>> = {
36
38
  "/graph": GraphView,
37
39
  };
38
40
 
39
- function App() {
40
- // Track full location (pathname + search) for proper query param handling
41
- const [location, setLocation] = useState<string>(
42
- window.location.pathname + window.location.search
43
- );
44
- const [shortcutHelpOpen, setShortcutHelpOpen] = useState(false);
41
+ interface AppContentProps {
42
+ location: string;
43
+ navigate: Navigate;
44
+ shortcutHelpOpen: boolean;
45
+ setShortcutHelpOpen: (open: boolean) => void;
46
+ }
47
+
48
+ function AppContent({
49
+ location,
50
+ navigate,
51
+ shortcutHelpOpen,
52
+ setShortcutHelpOpen,
53
+ }: AppContentProps) {
54
+ const { openCapture } = useCaptureModal();
55
+ const [quickSwitcherOpen, setQuickSwitcherOpen] = useState(false);
45
56
 
46
57
  useEffect(() => {
47
- const handlePopState = () =>
48
- setLocation(window.location.pathname + window.location.search);
49
- window.addEventListener("popstate", handlePopState);
50
- return () => window.removeEventListener("popstate", handlePopState);
51
- }, []);
58
+ const basePath = location.split("?")[0];
59
+ if (basePath !== "/doc" && basePath !== "/edit") {
60
+ return;
61
+ }
52
62
 
53
- const navigate = useCallback((to: string | number) => {
54
- if (typeof to === "number") {
55
- // Handle history.go(-1) style navigation
56
- window.history.go(to);
63
+ const search = location.includes("?")
64
+ ? `?${location.split("?")[1] ?? ""}`
65
+ : "";
66
+ const target = parseDocumentDeepLink(search);
67
+ if (!target.uri) {
57
68
  return;
58
69
  }
59
- window.history.pushState({}, "", to);
60
- setLocation(to);
61
- }, []);
62
70
 
63
- // Global keyboard shortcuts (single-key, GitHub/Gmail pattern)
71
+ saveRecentDocument({
72
+ uri: target.uri,
73
+ href: location,
74
+ label: decodeURIComponent(target.uri.split("/").pop() ?? target.uri),
75
+ });
76
+ }, [location]);
77
+
64
78
  const shortcuts = useMemo(
65
79
  () => [
66
80
  {
67
81
  key: "/",
68
82
  action: () => {
69
- // Focus search input on current page or navigate to search
70
83
  const searchInput = document.querySelector<HTMLInputElement>(
71
84
  'input[type="search"], input[placeholder*="Search"], input[id*="search"]'
72
85
  );
@@ -82,18 +95,22 @@ function App() {
82
95
  key: "?",
83
96
  action: () => setShortcutHelpOpen(true),
84
97
  },
98
+ {
99
+ key: "k",
100
+ meta: true,
101
+ action: () => setQuickSwitcherOpen(true),
102
+ },
85
103
  ],
86
- [navigate]
104
+ [navigate, setShortcutHelpOpen]
87
105
  );
88
106
 
89
107
  useKeyboardShortcuts(shortcuts);
90
108
 
91
- // Extract base path for routing (ignore query params)
92
109
  const basePath = location.split("?")[0] as Route;
93
110
  const Page = routes[basePath] || Dashboard;
94
111
 
95
112
  return (
96
- <CaptureModalProvider>
113
+ <>
97
114
  <div className="flex min-h-screen flex-col">
98
115
  <div className="flex-1">
99
116
  <Page key={location} navigate={navigate} />
@@ -147,10 +164,50 @@ function App() {
147
164
  </footer>
148
165
  </div>
149
166
  <HelpButton onClick={() => setShortcutHelpOpen(true)} />
167
+ <QuickSwitcher
168
+ navigate={(to) => navigate(to)}
169
+ onCreateNote={openCapture}
170
+ onOpenChange={setQuickSwitcherOpen}
171
+ open={quickSwitcherOpen}
172
+ />
150
173
  <ShortcutHelpModal
151
174
  onOpenChange={setShortcutHelpOpen}
152
175
  open={shortcutHelpOpen}
153
176
  />
177
+ </>
178
+ );
179
+ }
180
+
181
+ function App() {
182
+ const [location, setLocation] = useState<string>(
183
+ window.location.pathname + window.location.search
184
+ );
185
+ const [shortcutHelpOpen, setShortcutHelpOpen] = useState(false);
186
+
187
+ useEffect(() => {
188
+ const handlePopState = () =>
189
+ setLocation(window.location.pathname + window.location.search);
190
+ window.addEventListener("popstate", handlePopState);
191
+ return () => window.removeEventListener("popstate", handlePopState);
192
+ }, []);
193
+
194
+ const navigate = useCallback((to: string | number) => {
195
+ if (typeof to === "number") {
196
+ window.history.go(to);
197
+ return;
198
+ }
199
+ window.history.pushState({}, "", to);
200
+ setLocation(to);
201
+ }, []);
202
+
203
+ return (
204
+ <CaptureModalProvider>
205
+ <AppContent
206
+ location={location}
207
+ navigate={navigate}
208
+ setShortcutHelpOpen={setShortcutHelpOpen}
209
+ shortcutHelpOpen={shortcutHelpOpen}
210
+ />
154
211
  </CaptureModalProvider>
155
212
  );
156
213
  }