@gmickel/gno 0.28.2 → 0.29.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 +10 -2
- package/package.json +1 -1
- package/src/app/constants.ts +4 -2
- package/src/cli/commands/mcp/install.ts +4 -4
- package/src/cli/commands/mcp/status.ts +7 -7
- package/src/cli/commands/skill/install.ts +5 -5
- package/src/cli/program.ts +2 -2
- package/src/collection/add.ts +10 -0
- package/src/collection/types.ts +1 -0
- package/src/config/types.ts +12 -2
- package/src/core/depth-policy.ts +1 -1
- package/src/core/file-ops.ts +38 -0
- package/src/llm/registry.ts +20 -4
- package/src/serve/AGENTS.md +16 -16
- package/src/serve/CLAUDE.md +16 -16
- package/src/serve/config-sync.ts +32 -1
- package/src/serve/connectors.ts +243 -0
- package/src/serve/context.ts +9 -0
- package/src/serve/doc-events.ts +31 -1
- package/src/serve/embed-scheduler.ts +12 -0
- package/src/serve/import-preview.ts +173 -0
- package/src/serve/public/app.tsx +101 -7
- package/src/serve/public/components/AIModelSelector.tsx +383 -145
- package/src/serve/public/components/AddCollectionDialog.tsx +123 -7
- package/src/serve/public/components/BootstrapStatus.tsx +133 -0
- package/src/serve/public/components/CaptureModal.tsx +5 -2
- package/src/serve/public/components/CollectionsEmptyState.tsx +63 -0
- package/src/serve/public/components/FirstRunWizard.tsx +622 -0
- package/src/serve/public/components/HealthCenter.tsx +128 -0
- package/src/serve/public/components/IndexingProgress.tsx +21 -2
- package/src/serve/public/components/QuickSwitcher.tsx +62 -36
- package/src/serve/public/components/TagInput.tsx +5 -1
- package/src/serve/public/components/WikiLinkAutocomplete.tsx +15 -6
- package/src/serve/public/components/WorkspaceTabs.tsx +60 -0
- package/src/serve/public/hooks/use-doc-events.ts +48 -4
- package/src/serve/public/lib/local-history.ts +40 -7
- package/src/serve/public/lib/navigation-state.ts +156 -0
- package/src/serve/public/lib/workspace-tabs.ts +235 -0
- package/src/serve/public/pages/Ask.tsx +11 -1
- package/src/serve/public/pages/Browse.tsx +73 -0
- package/src/serve/public/pages/Collections.tsx +29 -13
- package/src/serve/public/pages/Connectors.tsx +178 -0
- package/src/serve/public/pages/Dashboard.tsx +493 -67
- package/src/serve/public/pages/DocView.tsx +192 -34
- package/src/serve/public/pages/DocumentEditor.tsx +127 -5
- package/src/serve/public/pages/Search.tsx +12 -1
- package/src/serve/routes/api.ts +532 -62
- package/src/serve/server.ts +79 -2
- package/src/serve/status-model.ts +149 -0
- package/src/serve/status.ts +706 -0
- package/src/serve/watch-service.ts +73 -8
- package/src/types/electrobun-shell.d.ts +43 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import type { McpScope, McpTarget } from "../cli/commands/mcp/paths";
|
|
2
|
+
import type { SkillScope, SkillTarget } from "../cli/commands/skill/paths";
|
|
3
|
+
|
|
4
|
+
import { installMcpToTarget } from "../cli/commands/mcp/install";
|
|
5
|
+
import {
|
|
6
|
+
buildMcpServerEntry,
|
|
7
|
+
getTargetDisplayName,
|
|
8
|
+
} from "../cli/commands/mcp/paths";
|
|
9
|
+
import { checkMcpTargetStatus } from "../cli/commands/mcp/status";
|
|
10
|
+
import { installSkillToTarget } from "../cli/commands/skill/install";
|
|
11
|
+
import { resolveSkillPaths } from "../cli/commands/skill/paths";
|
|
12
|
+
|
|
13
|
+
export interface ConnectorStatus {
|
|
14
|
+
id: string;
|
|
15
|
+
appName: string;
|
|
16
|
+
installKind: "skill" | "mcp";
|
|
17
|
+
target: string;
|
|
18
|
+
scope: "user" | "project";
|
|
19
|
+
installed: boolean;
|
|
20
|
+
path: string;
|
|
21
|
+
summary: string;
|
|
22
|
+
nextAction: string;
|
|
23
|
+
mode: {
|
|
24
|
+
label: string;
|
|
25
|
+
detail: string;
|
|
26
|
+
};
|
|
27
|
+
error?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface SkillConnectorDefinition {
|
|
31
|
+
id: string;
|
|
32
|
+
appName: string;
|
|
33
|
+
installKind: "skill";
|
|
34
|
+
target: SkillTarget;
|
|
35
|
+
scope: SkillScope;
|
|
36
|
+
mode: ConnectorStatus["mode"];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface McpConnectorDefinition {
|
|
40
|
+
id: string;
|
|
41
|
+
appName: string;
|
|
42
|
+
installKind: "mcp";
|
|
43
|
+
target: McpTarget;
|
|
44
|
+
scope: McpScope;
|
|
45
|
+
mode: ConnectorStatus["mode"];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type ConnectorDefinition = SkillConnectorDefinition | McpConnectorDefinition;
|
|
49
|
+
|
|
50
|
+
const CONNECTOR_DEFINITIONS: ConnectorDefinition[] = [
|
|
51
|
+
{
|
|
52
|
+
id: "claude-code-skill",
|
|
53
|
+
appName: "Claude Code",
|
|
54
|
+
installKind: "skill",
|
|
55
|
+
target: "claude",
|
|
56
|
+
scope: "user",
|
|
57
|
+
mode: {
|
|
58
|
+
label: "Read/search via skill",
|
|
59
|
+
detail:
|
|
60
|
+
"Recommended default. The agent can search and retrieve with GNO without editing client JSON.",
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: "claude-desktop-mcp",
|
|
65
|
+
appName: "Claude Desktop",
|
|
66
|
+
installKind: "mcp",
|
|
67
|
+
target: "claude-desktop",
|
|
68
|
+
scope: "user",
|
|
69
|
+
mode: {
|
|
70
|
+
label: "Read/search via MCP",
|
|
71
|
+
detail:
|
|
72
|
+
"Recommended default. Write-capable MCP can stay an advanced CLI step.",
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: "cursor-mcp",
|
|
77
|
+
appName: "Cursor",
|
|
78
|
+
installKind: "mcp",
|
|
79
|
+
target: "cursor",
|
|
80
|
+
scope: "user",
|
|
81
|
+
mode: {
|
|
82
|
+
label: "Read/search via MCP",
|
|
83
|
+
detail: "Recommended default for editor-side agent access.",
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
id: "codex-skill",
|
|
88
|
+
appName: "Codex",
|
|
89
|
+
installKind: "skill",
|
|
90
|
+
target: "codex",
|
|
91
|
+
scope: "user",
|
|
92
|
+
mode: {
|
|
93
|
+
label: "Read/search via skill",
|
|
94
|
+
detail:
|
|
95
|
+
"Fastest setup for Codex CLI. MCP remains available separately if needed later.",
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
id: "opencode-skill",
|
|
100
|
+
appName: "OpenCode",
|
|
101
|
+
installKind: "skill",
|
|
102
|
+
target: "opencode",
|
|
103
|
+
scope: "user",
|
|
104
|
+
mode: {
|
|
105
|
+
label: "Read/search via skill",
|
|
106
|
+
detail: "Recommended default. Uses the existing skill install path.",
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
id: "openclaw-skill",
|
|
111
|
+
appName: "OpenClaw",
|
|
112
|
+
installKind: "skill",
|
|
113
|
+
target: "openclaw",
|
|
114
|
+
scope: "user",
|
|
115
|
+
mode: {
|
|
116
|
+
label: "Read/search via skill",
|
|
117
|
+
detail:
|
|
118
|
+
"Recommended default for local agent access without manual file edits.",
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
] as const;
|
|
122
|
+
|
|
123
|
+
export async function getConnectorStatuses(overrides?: {
|
|
124
|
+
cwd?: string;
|
|
125
|
+
homeDir?: string;
|
|
126
|
+
}): Promise<ConnectorStatus[]> {
|
|
127
|
+
const statuses = await Promise.all(
|
|
128
|
+
CONNECTOR_DEFINITIONS.map(async (definition) => {
|
|
129
|
+
if (definition.installKind === "skill") {
|
|
130
|
+
const paths = resolveSkillPaths({
|
|
131
|
+
scope: definition.scope,
|
|
132
|
+
target: definition.target,
|
|
133
|
+
...overrides,
|
|
134
|
+
});
|
|
135
|
+
const skillMdPath = `${paths.gnoDir}/SKILL.md`;
|
|
136
|
+
const installed = await Bun.file(skillMdPath).exists();
|
|
137
|
+
return {
|
|
138
|
+
id: definition.id,
|
|
139
|
+
appName: definition.appName,
|
|
140
|
+
installKind: definition.installKind,
|
|
141
|
+
target: definition.target,
|
|
142
|
+
scope: definition.scope,
|
|
143
|
+
installed,
|
|
144
|
+
path: paths.gnoDir,
|
|
145
|
+
summary: installed
|
|
146
|
+
? `${definition.appName} skill is installed.`
|
|
147
|
+
: `${definition.appName} skill is not installed yet.`,
|
|
148
|
+
nextAction: installed
|
|
149
|
+
? "Restart the agent to reload the skill."
|
|
150
|
+
: "Install the skill from the app.",
|
|
151
|
+
mode: definition.mode,
|
|
152
|
+
} satisfies ConnectorStatus;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const status = await checkMcpTargetStatus(
|
|
156
|
+
definition.target,
|
|
157
|
+
definition.scope,
|
|
158
|
+
overrides ?? {}
|
|
159
|
+
);
|
|
160
|
+
return {
|
|
161
|
+
id: definition.id,
|
|
162
|
+
appName: definition.appName,
|
|
163
|
+
installKind: definition.installKind,
|
|
164
|
+
target: definition.target,
|
|
165
|
+
scope: definition.scope,
|
|
166
|
+
installed: status.configured,
|
|
167
|
+
path: status.configPath,
|
|
168
|
+
summary: status.configured
|
|
169
|
+
? `${definition.appName} MCP is configured.`
|
|
170
|
+
: `${definition.appName} MCP is not configured yet.`,
|
|
171
|
+
nextAction: status.configured
|
|
172
|
+
? `Restart ${definition.appName} to reload the server.`
|
|
173
|
+
: "Install the MCP connector from the app.",
|
|
174
|
+
mode: definition.mode,
|
|
175
|
+
error: status.error,
|
|
176
|
+
} satisfies ConnectorStatus;
|
|
177
|
+
})
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
return statuses;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function installConnector(
|
|
184
|
+
id: string,
|
|
185
|
+
options?: {
|
|
186
|
+
reinstall?: boolean;
|
|
187
|
+
},
|
|
188
|
+
overrides?: { cwd?: string; homeDir?: string }
|
|
189
|
+
): Promise<ConnectorStatus> {
|
|
190
|
+
const definition = CONNECTOR_DEFINITIONS.find((entry) => entry.id === id);
|
|
191
|
+
if (!definition) {
|
|
192
|
+
throw new Error(`Unknown connector: ${id}`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const currentStatuses = await getConnectorStatuses(overrides);
|
|
196
|
+
const currentStatus = currentStatuses.find((entry) => entry.id === id);
|
|
197
|
+
if (currentStatus?.installed && !options?.reinstall) {
|
|
198
|
+
throw new Error(
|
|
199
|
+
`${currentStatus.appName} is already installed. Reinstall explicitly to overwrite the existing GNO entry.`
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (definition.installKind === "skill") {
|
|
204
|
+
await installSkillToTarget(
|
|
205
|
+
definition.scope,
|
|
206
|
+
definition.target,
|
|
207
|
+
options?.reinstall ?? false,
|
|
208
|
+
overrides
|
|
209
|
+
);
|
|
210
|
+
} else {
|
|
211
|
+
await installMcpToTarget(
|
|
212
|
+
definition.target,
|
|
213
|
+
definition.scope,
|
|
214
|
+
buildMcpServerEntry({ enableWrite: false }),
|
|
215
|
+
{
|
|
216
|
+
force: options?.reinstall ?? false,
|
|
217
|
+
dryRun: false,
|
|
218
|
+
...overrides,
|
|
219
|
+
}
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const [status] = await getConnectorStatuses(overrides).then((all) =>
|
|
224
|
+
all.filter((entry) => entry.id === id)
|
|
225
|
+
);
|
|
226
|
+
if (!status) {
|
|
227
|
+
throw new Error(`Failed to reload connector: ${id}`);
|
|
228
|
+
}
|
|
229
|
+
return status;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function getConnectorDisplayName(id: string): string {
|
|
233
|
+
const definition = CONNECTOR_DEFINITIONS.find((entry) => entry.id === id);
|
|
234
|
+
if (!definition) {
|
|
235
|
+
return id;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (definition.installKind === "mcp") {
|
|
239
|
+
return `${definition.appName} (${getTargetDisplayName(definition.target)})`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return definition.appName;
|
|
243
|
+
}
|
package/src/serve/context.ts
CHANGED
|
@@ -15,6 +15,9 @@ import type {
|
|
|
15
15
|
RerankPort,
|
|
16
16
|
} from "../llm/types";
|
|
17
17
|
import type { SqliteAdapter } from "../store/sqlite/adapter";
|
|
18
|
+
import type { DocumentEventBus } from "./doc-events";
|
|
19
|
+
import type { EmbedScheduler } from "./embed-scheduler";
|
|
20
|
+
import type { CollectionWatchService } from "./watch-service";
|
|
18
21
|
|
|
19
22
|
import { LlmAdapter } from "../llm/nodeLlamaCpp/adapter";
|
|
20
23
|
import { resolveDownloadPolicy } from "../llm/policy";
|
|
@@ -72,6 +75,9 @@ export interface ServerContext {
|
|
|
72
75
|
hybrid: boolean;
|
|
73
76
|
answer: boolean;
|
|
74
77
|
};
|
|
78
|
+
scheduler?: EmbedScheduler | null;
|
|
79
|
+
eventBus?: DocumentEventBus | null;
|
|
80
|
+
watchService?: CollectionWatchService | null;
|
|
75
81
|
}
|
|
76
82
|
|
|
77
83
|
/**
|
|
@@ -190,6 +196,9 @@ export async function createServerContext(
|
|
|
190
196
|
answerPort,
|
|
191
197
|
rerankPort,
|
|
192
198
|
capabilities,
|
|
199
|
+
scheduler: null,
|
|
200
|
+
eventBus: null,
|
|
201
|
+
watchService: null,
|
|
193
202
|
};
|
|
194
203
|
}
|
|
195
204
|
|
package/src/serve/doc-events.ts
CHANGED
|
@@ -9,7 +9,14 @@ export interface DocumentEvent {
|
|
|
9
9
|
changedAt: string;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
export interface DocumentEventBusState {
|
|
13
|
+
connectedClients: number;
|
|
14
|
+
retryMs: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
12
17
|
const encoder = new TextEncoder();
|
|
18
|
+
const EVENT_RETRY_MS = 2_000;
|
|
19
|
+
const KEEPALIVE_MS = 15_000;
|
|
13
20
|
|
|
14
21
|
export class DocumentEventBus {
|
|
15
22
|
readonly #controllers = new Set<
|
|
@@ -20,13 +27,29 @@ export class DocumentEventBus {
|
|
|
20
27
|
const controllers = this.#controllers;
|
|
21
28
|
let streamController: ReadableStreamDefaultController<Uint8Array> | null =
|
|
22
29
|
null;
|
|
30
|
+
let keepaliveTimer: ReturnType<typeof setInterval> | null = null;
|
|
23
31
|
const stream = new ReadableStream<Uint8Array>({
|
|
24
32
|
start(controller) {
|
|
25
33
|
streamController = controller;
|
|
26
34
|
controllers.add(controller);
|
|
27
|
-
controller.enqueue(
|
|
35
|
+
controller.enqueue(
|
|
36
|
+
encoder.encode(`retry: ${EVENT_RETRY_MS}\n: connected\n\n`)
|
|
37
|
+
);
|
|
38
|
+
keepaliveTimer = setInterval(() => {
|
|
39
|
+
try {
|
|
40
|
+
controller.enqueue(encoder.encode(": keepalive\n\n"));
|
|
41
|
+
} catch {
|
|
42
|
+
if (keepaliveTimer) {
|
|
43
|
+
clearInterval(keepaliveTimer);
|
|
44
|
+
}
|
|
45
|
+
controllers.delete(controller);
|
|
46
|
+
}
|
|
47
|
+
}, KEEPALIVE_MS);
|
|
28
48
|
},
|
|
29
49
|
cancel() {
|
|
50
|
+
if (keepaliveTimer) {
|
|
51
|
+
clearInterval(keepaliveTimer);
|
|
52
|
+
}
|
|
30
53
|
if (streamController) {
|
|
31
54
|
controllers.delete(streamController);
|
|
32
55
|
}
|
|
@@ -66,4 +89,11 @@ export class DocumentEventBus {
|
|
|
66
89
|
}
|
|
67
90
|
this.#controllers.clear();
|
|
68
91
|
}
|
|
92
|
+
|
|
93
|
+
getState(): DocumentEventBusState {
|
|
94
|
+
return {
|
|
95
|
+
connectedClients: this.#controllers.size,
|
|
96
|
+
retryMs: EVENT_RETRY_MS,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
69
99
|
}
|
|
@@ -29,6 +29,8 @@ export interface EmbedSchedulerState {
|
|
|
29
29
|
pendingDocCount: number;
|
|
30
30
|
running: boolean;
|
|
31
31
|
nextRunAt?: number;
|
|
32
|
+
lastRunAt?: number;
|
|
33
|
+
lastResult?: EmbedResult;
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
export interface EmbedResult {
|
|
@@ -79,6 +81,8 @@ export function createEmbedScheduler(deps: EmbedSchedulerDeps): EmbedScheduler {
|
|
|
79
81
|
let firstPendingAt: number | null = null;
|
|
80
82
|
let nextRunAt: number | null = null; // Accurate timer due time
|
|
81
83
|
let disposed = false;
|
|
84
|
+
let lastRunAt: number | null = null;
|
|
85
|
+
let lastResult: EmbedResult | null = null;
|
|
82
86
|
|
|
83
87
|
const stats = createVectorStatsPort(db);
|
|
84
88
|
|
|
@@ -179,6 +183,8 @@ export function createEmbedScheduler(deps: EmbedSchedulerDeps): EmbedScheduler {
|
|
|
179
183
|
let result: EmbedResult;
|
|
180
184
|
try {
|
|
181
185
|
result = await runEmbed();
|
|
186
|
+
lastRunAt = Date.now();
|
|
187
|
+
lastResult = result;
|
|
182
188
|
} finally {
|
|
183
189
|
running = false;
|
|
184
190
|
}
|
|
@@ -247,6 +253,12 @@ export function createEmbedScheduler(deps: EmbedSchedulerDeps): EmbedScheduler {
|
|
|
247
253
|
if (nextRunAt !== null) {
|
|
248
254
|
state.nextRunAt = nextRunAt;
|
|
249
255
|
}
|
|
256
|
+
if (lastRunAt !== null) {
|
|
257
|
+
state.lastRunAt = lastRunAt;
|
|
258
|
+
}
|
|
259
|
+
if (lastResult) {
|
|
260
|
+
state.lastResult = lastResult;
|
|
261
|
+
}
|
|
250
262
|
|
|
251
263
|
return state;
|
|
252
264
|
},
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// node:fs/promises readdir: no Bun recursive directory API with this shape
|
|
2
|
+
import { readdir } from "node:fs/promises";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import type { Config } from "../config/types";
|
|
6
|
+
|
|
7
|
+
export interface ImportPreview {
|
|
8
|
+
path: string;
|
|
9
|
+
suggestedName: string;
|
|
10
|
+
folderType: "obsidian-vault" | "notes-folder" | "mixed-docs" | "binary-heavy";
|
|
11
|
+
counts: {
|
|
12
|
+
markdown: number;
|
|
13
|
+
text: number;
|
|
14
|
+
pdf: number;
|
|
15
|
+
office: number;
|
|
16
|
+
other: number;
|
|
17
|
+
folders: number;
|
|
18
|
+
scannedFiles: number;
|
|
19
|
+
truncated: boolean;
|
|
20
|
+
};
|
|
21
|
+
signals: string[];
|
|
22
|
+
guidance: string[];
|
|
23
|
+
conflicts: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const PREVIEW_FILE_LIMIT = 400;
|
|
27
|
+
|
|
28
|
+
function extname(path: string): string {
|
|
29
|
+
const idx = path.lastIndexOf(".");
|
|
30
|
+
return idx >= 0 ? path.slice(idx).toLowerCase() : "";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function analyzeImportPath(
|
|
34
|
+
config: Config,
|
|
35
|
+
path: string,
|
|
36
|
+
name?: string
|
|
37
|
+
): Promise<ImportPreview> {
|
|
38
|
+
const suggestedName = (name || basename(path)).toLowerCase();
|
|
39
|
+
const counts = {
|
|
40
|
+
markdown: 0,
|
|
41
|
+
text: 0,
|
|
42
|
+
pdf: 0,
|
|
43
|
+
office: 0,
|
|
44
|
+
other: 0,
|
|
45
|
+
folders: 0,
|
|
46
|
+
scannedFiles: 0,
|
|
47
|
+
truncated: false,
|
|
48
|
+
};
|
|
49
|
+
const signals = new Set<string>();
|
|
50
|
+
const guidance: string[] = [];
|
|
51
|
+
const conflicts: string[] = [];
|
|
52
|
+
const queue = [path];
|
|
53
|
+
|
|
54
|
+
if (
|
|
55
|
+
config.collections.some((collection) => collection.name === suggestedName)
|
|
56
|
+
) {
|
|
57
|
+
conflicts.push(`Collection name "${suggestedName}" already exists.`);
|
|
58
|
+
}
|
|
59
|
+
const samePath = config.collections.find(
|
|
60
|
+
(collection) => collection.path === path
|
|
61
|
+
);
|
|
62
|
+
if (samePath) {
|
|
63
|
+
conflicts.push(`This folder is already indexed as "${samePath.name}".`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
while (queue.length > 0 && counts.scannedFiles < PREVIEW_FILE_LIMIT) {
|
|
67
|
+
const current = queue.shift();
|
|
68
|
+
if (!current) {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let entries;
|
|
73
|
+
try {
|
|
74
|
+
entries = await readdir(current, { withFileTypes: true });
|
|
75
|
+
} catch {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (const entry of entries) {
|
|
80
|
+
const entryName = String(entry.name);
|
|
81
|
+
if (counts.scannedFiles >= PREVIEW_FILE_LIMIT) {
|
|
82
|
+
counts.truncated = true;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (entry.isDirectory()) {
|
|
87
|
+
counts.folders += 1;
|
|
88
|
+
if (entryName === ".obsidian") {
|
|
89
|
+
signals.add("Obsidian config detected");
|
|
90
|
+
}
|
|
91
|
+
if (entryName === ".git") {
|
|
92
|
+
signals.add("Git repo detected");
|
|
93
|
+
}
|
|
94
|
+
if (!entryName.startsWith(".")) {
|
|
95
|
+
queue.push(join(current, entryName));
|
|
96
|
+
}
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
counts.scannedFiles += 1;
|
|
101
|
+
const ext = extname(entryName);
|
|
102
|
+
if (ext === ".md") {
|
|
103
|
+
counts.markdown += 1;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (ext === ".txt") {
|
|
107
|
+
counts.text += 1;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (ext === ".pdf") {
|
|
111
|
+
counts.pdf += 1;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if ([".docx", ".pptx", ".xlsx"].includes(ext)) {
|
|
115
|
+
counts.office += 1;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
counts.other += 1;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const noteLikeCount = counts.markdown + counts.text;
|
|
123
|
+
const binaryCount = counts.pdf + counts.office;
|
|
124
|
+
|
|
125
|
+
let folderType: ImportPreview["folderType"] = "mixed-docs";
|
|
126
|
+
if (signals.has("Obsidian config detected")) {
|
|
127
|
+
folderType = "obsidian-vault";
|
|
128
|
+
} else if (
|
|
129
|
+
noteLikeCount > 0 &&
|
|
130
|
+
binaryCount === 0 &&
|
|
131
|
+
counts.other < noteLikeCount
|
|
132
|
+
) {
|
|
133
|
+
folderType = "notes-folder";
|
|
134
|
+
} else if (binaryCount > noteLikeCount) {
|
|
135
|
+
folderType = "binary-heavy";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (folderType === "obsidian-vault") {
|
|
139
|
+
guidance.push(
|
|
140
|
+
"GNO will index your vault files and wiki links, but it does not replace the Obsidian plugin ecosystem."
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
if (binaryCount > 0) {
|
|
144
|
+
guidance.push(
|
|
145
|
+
"PDF, DOCX, PPTX, and XLSX files are imported as searchable read-only source material."
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
if (counts.other > noteLikeCount && counts.other > 10) {
|
|
149
|
+
guidance.push(
|
|
150
|
+
"This folder has a lot of unsupported or non-document files. Consider narrowing the pattern or excludes before indexing."
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
if (counts.truncated) {
|
|
154
|
+
guidance.push(
|
|
155
|
+
"Preview is sampled from the first few hundred files, not a full recursive inventory."
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
if (guidance.length === 0) {
|
|
159
|
+
guidance.push(
|
|
160
|
+
"This folder looks straightforward to import with the current defaults."
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
path,
|
|
166
|
+
suggestedName,
|
|
167
|
+
folderType,
|
|
168
|
+
counts,
|
|
169
|
+
signals: [...signals],
|
|
170
|
+
guidance,
|
|
171
|
+
conflicts,
|
|
172
|
+
};
|
|
173
|
+
}
|