@geminilight/mindos 0.6.30 → 0.6.32
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_zh.md +10 -4
- package/app/app/api/ask/route.ts +12 -7
- package/app/app/api/export/route.ts +105 -0
- package/app/app/globals.css +2 -2
- package/app/app/trash/page.tsx +7 -0
- package/app/app/view/[...path]/ViewPageClient.tsx +234 -2
- package/app/components/ExportModal.tsx +220 -0
- package/app/components/FileTree.tsx +22 -2
- package/app/components/HomeContent.tsx +91 -20
- package/app/components/MarkdownView.tsx +45 -10
- package/app/components/Sidebar.tsx +10 -1
- package/app/components/TrashPageClient.tsx +263 -0
- package/app/components/ask/ToolCallBlock.tsx +102 -18
- package/app/components/changes/ChangesContentPage.tsx +58 -14
- package/app/components/explore/ExploreContent.tsx +4 -7
- package/app/components/explore/UseCaseCard.tsx +18 -1
- package/app/components/explore/use-cases.generated.ts +76 -0
- package/app/components/explore/use-cases.yaml +185 -0
- package/app/components/panels/DiscoverPanel.tsx +1 -1
- package/app/components/renderers/workflow-yaml/StepEditor.tsx +98 -91
- package/app/components/renderers/workflow-yaml/WorkflowEditor.tsx +72 -72
- package/app/components/renderers/workflow-yaml/WorkflowRunner.tsx +175 -119
- package/app/components/renderers/workflow-yaml/WorkflowYamlRenderer.tsx +61 -61
- package/app/components/renderers/workflow-yaml/execution.ts +64 -12
- package/app/components/renderers/workflow-yaml/selectors.tsx +65 -13
- package/app/components/settings/AiTab.tsx +191 -174
- package/app/components/settings/AppearanceTab.tsx +168 -77
- package/app/components/settings/KnowledgeTab.tsx +131 -136
- package/app/components/settings/McpTab.tsx +11 -11
- package/app/components/settings/Primitives.tsx +60 -0
- package/app/components/settings/SettingsContent.tsx +15 -8
- package/app/components/settings/SyncTab.tsx +12 -12
- package/app/components/settings/UninstallTab.tsx +8 -18
- package/app/components/settings/UpdateTab.tsx +82 -82
- package/app/components/settings/types.ts +17 -8
- package/app/lib/acp/session.ts +12 -3
- package/app/lib/actions.ts +57 -3
- package/app/lib/agent/stream-consumer.ts +18 -0
- package/app/lib/agent/tools.ts +56 -9
- package/app/lib/core/export.ts +116 -0
- package/app/lib/core/trash.ts +241 -0
- package/app/lib/fs.ts +47 -0
- package/app/lib/hooks/usePinnedFiles.ts +90 -0
- package/app/lib/i18n/generated/explore-i18n.generated.ts +138 -0
- package/app/lib/i18n/index.ts +3 -0
- package/app/lib/i18n/modules/knowledge.ts +120 -6
- package/app/lib/i18n/modules/onboarding.ts +2 -134
- package/app/lib/i18n/modules/settings.ts +12 -0
- package/app/package.json +8 -2
- package/app/scripts/generate-explore.ts +145 -0
- package/package.json +1 -1
- package/app/components/explore/use-cases.ts +0 -58
package/app/lib/actions.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import fs from 'fs';
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import { createFile, deleteFile, deleteDirectory, convertToSpace, renameFile, renameSpace, getMindRoot, invalidateCache, collectAllFiles } from '@/lib/fs';
|
|
6
|
+
import { moveToTrash, restoreFromTrash, restoreAsCopy, permanentlyDelete, listTrash, emptyTrash, purgeExpired, type TrashMeta } from '@/lib/core/trash';
|
|
6
7
|
import { createSpaceFilesystem, generateReadmeTemplate } from '@/lib/core/create-space';
|
|
7
8
|
import { INSTRUCTION_TEMPLATE, cleanDirName } from '@/lib/core/space-scaffold';
|
|
8
9
|
import { revalidatePath } from 'next/cache';
|
|
@@ -25,7 +26,8 @@ export async function createFileAction(dirPath: string, fileName: string): Promi
|
|
|
25
26
|
|
|
26
27
|
export async function deleteFileAction(filePath: string): Promise<{ success: boolean; error?: string }> {
|
|
27
28
|
try {
|
|
28
|
-
|
|
29
|
+
moveToTrash(getMindRoot(), filePath);
|
|
30
|
+
invalidateCache();
|
|
29
31
|
revalidatePath('/', 'layout');
|
|
30
32
|
return { success: true };
|
|
31
33
|
} catch (err) {
|
|
@@ -59,7 +61,8 @@ export async function deleteFolderAction(
|
|
|
59
61
|
dirPath: string,
|
|
60
62
|
): Promise<{ success: boolean; error?: string }> {
|
|
61
63
|
try {
|
|
62
|
-
|
|
64
|
+
moveToTrash(getMindRoot(), dirPath);
|
|
65
|
+
invalidateCache();
|
|
63
66
|
revalidatePath('/', 'layout');
|
|
64
67
|
return { success: true };
|
|
65
68
|
} catch (err) {
|
|
@@ -84,7 +87,8 @@ export async function deleteSpaceAction(
|
|
|
84
87
|
spacePath: string,
|
|
85
88
|
): Promise<{ success: boolean; error?: string }> {
|
|
86
89
|
try {
|
|
87
|
-
|
|
90
|
+
moveToTrash(getMindRoot(), spacePath);
|
|
91
|
+
invalidateCache();
|
|
88
92
|
revalidatePath('/', 'layout');
|
|
89
93
|
return { success: true };
|
|
90
94
|
} catch (err) {
|
|
@@ -189,3 +193,53 @@ export async function cleanupExamplesAction(): Promise<{ success: boolean; delet
|
|
|
189
193
|
return { success: false, deleted: 0, error: err instanceof Error ? err.message : 'Failed to cleanup' };
|
|
190
194
|
}
|
|
191
195
|
}
|
|
196
|
+
|
|
197
|
+
// ─── Trash Actions ────────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
export async function listTrashAction(): Promise<TrashMeta[]> {
|
|
200
|
+
// Purge expired items on each list call (lazy cleanup)
|
|
201
|
+
purgeExpired(getMindRoot());
|
|
202
|
+
return listTrash(getMindRoot());
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function restoreFromTrashAction(
|
|
206
|
+
trashId: string,
|
|
207
|
+
mode: 'restore' | 'overwrite' | 'copy' = 'restore',
|
|
208
|
+
): Promise<{ success: boolean; restoredPath?: string; error?: string; conflict?: boolean }> {
|
|
209
|
+
try {
|
|
210
|
+
const root = getMindRoot();
|
|
211
|
+
let result: { restoredPath: string };
|
|
212
|
+
if (mode === 'copy') {
|
|
213
|
+
result = restoreAsCopy(root, trashId);
|
|
214
|
+
} else {
|
|
215
|
+
result = restoreFromTrash(root, trashId, mode === 'overwrite');
|
|
216
|
+
}
|
|
217
|
+
invalidateCache();
|
|
218
|
+
revalidatePath('/', 'layout');
|
|
219
|
+
return { success: true, restoredPath: result.restoredPath };
|
|
220
|
+
} catch (err: unknown) {
|
|
221
|
+
const e = err as Error & { code?: string };
|
|
222
|
+
if (e.code === 'RESTORE_CONFLICT') {
|
|
223
|
+
return { success: false, error: e.message, conflict: true };
|
|
224
|
+
}
|
|
225
|
+
return { success: false, error: e.message ?? 'Failed to restore' };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export async function permanentlyDeleteAction(trashId: string): Promise<{ success: boolean; error?: string }> {
|
|
230
|
+
try {
|
|
231
|
+
permanentlyDelete(getMindRoot(), trashId);
|
|
232
|
+
return { success: true };
|
|
233
|
+
} catch (err) {
|
|
234
|
+
return { success: false, error: err instanceof Error ? err.message : 'Failed to delete' };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export async function emptyTrashAction(): Promise<{ success: boolean; count?: number; error?: string }> {
|
|
239
|
+
try {
|
|
240
|
+
const count = emptyTrash(getMindRoot());
|
|
241
|
+
return { success: true, count };
|
|
242
|
+
} catch (err) {
|
|
243
|
+
return { success: false, error: err instanceof Error ? err.message : 'Failed to empty trash' };
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -16,6 +16,20 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import type { Message, MessagePart, ToolCallPart, TextPart, ReasoningPart } from '@/lib/types';
|
|
18
18
|
|
|
19
|
+
/** Tools that modify files — trigger files-changed notification on completion */
|
|
20
|
+
const FILE_MUTATING_TOOLS = new Set([
|
|
21
|
+
'write_file', 'create_file', 'batch_create_files',
|
|
22
|
+
'update_section', 'insert_after_heading', 'delete_file',
|
|
23
|
+
'rename_file', 'create_space',
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
/** Notify the app that files were changed by the AI agent */
|
|
27
|
+
function notifyFilesChanged() {
|
|
28
|
+
if (typeof window !== 'undefined') {
|
|
29
|
+
window.dispatchEvent(new Event('mindos:files-changed'));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
19
33
|
export async function consumeUIMessageStream(
|
|
20
34
|
body: ReadableStream<Uint8Array>,
|
|
21
35
|
onUpdate: (message: Message) => void,
|
|
@@ -158,6 +172,10 @@ export async function consumeUIMessageStream(
|
|
|
158
172
|
tc.output = output ?? '';
|
|
159
173
|
tc.state = (event.isError ? 'error' : 'done');
|
|
160
174
|
changed = true;
|
|
175
|
+
// Notify when a file-modifying tool completes successfully
|
|
176
|
+
if (!event.isError && FILE_MUTATING_TOOLS.has(tc.toolName)) {
|
|
177
|
+
notifyFilesChanged();
|
|
178
|
+
}
|
|
161
179
|
}
|
|
162
180
|
break;
|
|
163
181
|
}
|
package/app/lib/agent/tools.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { readSkillContentByName, scanSkillDirs } from '@/lib/pi-integration/skil
|
|
|
11
11
|
import { callMcporterTool, createMcporterAgentTools, listMcporterServers, listMcporterTools } from '@/lib/pi-integration/mcporter';
|
|
12
12
|
import { a2aTools } from '@/lib/a2a/a2a-tools';
|
|
13
13
|
import { acpTools } from '@/lib/acp/acp-tools';
|
|
14
|
+
import { buildLineDiff, collapseDiffContext } from '@/components/changes/line-diff';
|
|
14
15
|
|
|
15
16
|
// Max chars per file to avoid token overflow (~100k chars ≈ ~25k tokens)
|
|
16
17
|
const MAX_FILE_CHARS = 20_000;
|
|
@@ -39,6 +40,38 @@ function textResult(text: string): AgentToolResult<Record<string, never>> {
|
|
|
39
40
|
return { content: [{ type: 'text', text }], details: {} as Record<string, never> };
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
/** Build a compact diff summary for tool output. Max 30 diff lines to avoid bloating agent context. */
|
|
44
|
+
function buildDiffSummary(before: string, after: string): string {
|
|
45
|
+
if (before === after) return '';
|
|
46
|
+
// Skip diff for very large files — LCS is O(n*m), would block agent
|
|
47
|
+
const beforeLines = before.split('\n').length;
|
|
48
|
+
const afterLines = after.split('\n').length;
|
|
49
|
+
if (beforeLines > 2000 || afterLines > 2000) {
|
|
50
|
+
const added = Math.max(0, afterLines - beforeLines);
|
|
51
|
+
const removed = Math.max(0, beforeLines - afterLines);
|
|
52
|
+
return `(~+${added} ~−${removed}, ${afterLines} lines total)\n\n--- changes ---\n (diff skipped — file too large)`;
|
|
53
|
+
}
|
|
54
|
+
const raw = buildLineDiff(before, after);
|
|
55
|
+
const inserts = raw.filter(r => r.type === 'insert').length;
|
|
56
|
+
const deletes = raw.filter(r => r.type === 'delete').length;
|
|
57
|
+
const stats = `+${inserts} −${deletes}`;
|
|
58
|
+
const collapsed = collapseDiffContext(raw, 2);
|
|
59
|
+
const MAX_DIFF_LINES = 30;
|
|
60
|
+
const lines: string[] = [];
|
|
61
|
+
for (const row of collapsed) {
|
|
62
|
+
if (lines.length >= MAX_DIFF_LINES) { lines.push('... (diff truncated)'); break; }
|
|
63
|
+
if (row.type === 'gap') { lines.push(` ... ${row.count} lines unchanged ...`); continue; }
|
|
64
|
+
const prefix = row.type === 'insert' ? '+' : row.type === 'delete' ? '-' : ' ';
|
|
65
|
+
lines.push(`${prefix} ${row.text}`);
|
|
66
|
+
}
|
|
67
|
+
return `(${stats})\n\n--- changes ---\n${lines.join('\n')}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Safe read — returns empty string if file doesn't exist */
|
|
71
|
+
function safeReadContent(filePath: string): string {
|
|
72
|
+
try { return getFileContent(filePath); } catch { return ''; }
|
|
73
|
+
}
|
|
74
|
+
|
|
42
75
|
/** Safe execute wrapper — catches all errors, returns error text (never throws) */
|
|
43
76
|
function safeExecute<T>(
|
|
44
77
|
fn: (toolCallId: string, params: T, signal?: AbortSignal) => Promise<AgentToolResult<any>>,
|
|
@@ -527,8 +560,10 @@ export const knowledgeBaseTools: AgentTool<any>[] = [
|
|
|
527
560
|
description: 'Overwrite the entire content of an existing file. Use read_file first to see current content. Prefer update_section or insert_after_heading for partial edits.',
|
|
528
561
|
parameters: WriteFileParams,
|
|
529
562
|
execute: safeExecute(async (_id, params: Static<typeof WriteFileParams>) => {
|
|
563
|
+
const before = safeReadContent(params.path);
|
|
530
564
|
saveFileContent(params.path, params.content);
|
|
531
|
-
|
|
565
|
+
const diff = buildDiffSummary(before, params.content);
|
|
566
|
+
return textResult(`File written: ${params.path}${diff ? ' ' + diff : ''}`);
|
|
532
567
|
}),
|
|
533
568
|
},
|
|
534
569
|
|
|
@@ -538,8 +573,10 @@ export const knowledgeBaseTools: AgentTool<any>[] = [
|
|
|
538
573
|
description: 'Create a new file. Only .md and .csv files are allowed. Parent directories are created automatically. Does NOT create Space scaffolding (INSTRUCTION.md/README.md). Use create_space to create a Space.',
|
|
539
574
|
parameters: CreateFileParams,
|
|
540
575
|
execute: safeExecute(async (_id, params: Static<typeof CreateFileParams>) => {
|
|
541
|
-
|
|
542
|
-
|
|
576
|
+
const content = params.content ?? '';
|
|
577
|
+
createFile(params.path, content);
|
|
578
|
+
const lineCount = content.split('\n').length;
|
|
579
|
+
return textResult(`File created: ${params.path} (+${lineCount})\n\n--- changes ---\n${content.split('\n').slice(0, 30).map(l => '+ ' + l).join('\n')}${lineCount > 30 ? '\n... (truncated)' : ''}`);
|
|
543
580
|
}),
|
|
544
581
|
},
|
|
545
582
|
|
|
@@ -571,8 +608,11 @@ export const knowledgeBaseTools: AgentTool<any>[] = [
|
|
|
571
608
|
description: 'Append text to the end of an existing file. A blank line separator is added automatically.',
|
|
572
609
|
parameters: AppendParams,
|
|
573
610
|
execute: safeExecute(async (_id, params: Static<typeof AppendParams>) => {
|
|
611
|
+
const before = safeReadContent(params.path);
|
|
574
612
|
appendToFile(params.path, params.content);
|
|
575
|
-
|
|
613
|
+
const after = safeReadContent(params.path);
|
|
614
|
+
const diff = buildDiffSummary(before, after);
|
|
615
|
+
return textResult(`Content appended to: ${params.path}${diff ? ' ' + diff : ''}`);
|
|
576
616
|
}),
|
|
577
617
|
},
|
|
578
618
|
|
|
@@ -582,8 +622,11 @@ export const knowledgeBaseTools: AgentTool<any>[] = [
|
|
|
582
622
|
description: 'Insert content right after a Markdown heading. Useful for adding items under a specific section. If heading matches fail, use edit_lines instead.',
|
|
583
623
|
parameters: InsertHeadingParams,
|
|
584
624
|
execute: safeExecute(async (_id, params: Static<typeof InsertHeadingParams>) => {
|
|
625
|
+
const before = safeReadContent(params.path);
|
|
585
626
|
insertAfterHeading(params.path, params.heading, params.content);
|
|
586
|
-
|
|
627
|
+
const after = safeReadContent(params.path);
|
|
628
|
+
const diff = buildDiffSummary(before, after);
|
|
629
|
+
return textResult(`Content inserted after heading "${params.heading}" in ${params.path}${diff ? ' ' + diff : ''}`);
|
|
587
630
|
}),
|
|
588
631
|
},
|
|
589
632
|
|
|
@@ -593,8 +636,11 @@ export const knowledgeBaseTools: AgentTool<any>[] = [
|
|
|
593
636
|
description: 'Replace the content of a Markdown section identified by its heading. The section spans from the heading to the next heading of equal or higher level. If heading matches fail, use edit_lines instead.',
|
|
594
637
|
parameters: UpdateSectionParams,
|
|
595
638
|
execute: safeExecute(async (_id, params: Static<typeof UpdateSectionParams>) => {
|
|
639
|
+
const before = safeReadContent(params.path);
|
|
596
640
|
updateSection(params.path, params.heading, params.content);
|
|
597
|
-
|
|
641
|
+
const after = safeReadContent(params.path);
|
|
642
|
+
const diff = buildDiffSummary(before, after);
|
|
643
|
+
return textResult(`Section "${params.heading}" updated in ${params.path}${diff ? ' ' + diff : ''}`);
|
|
598
644
|
}),
|
|
599
645
|
},
|
|
600
646
|
|
|
@@ -607,12 +653,13 @@ export const knowledgeBaseTools: AgentTool<any>[] = [
|
|
|
607
653
|
const { path: fp, start_line, end_line, content } = params;
|
|
608
654
|
const start = Math.max(0, start_line - 1);
|
|
609
655
|
const end = Math.max(0, end_line - 1);
|
|
610
|
-
|
|
656
|
+
const before = safeReadContent(fp);
|
|
611
657
|
const mindRoot = getMindRoot();
|
|
612
|
-
// Import the core function dynamically or it should be added to lib/fs.ts
|
|
613
658
|
const { updateLines } = await import('@/lib/core');
|
|
614
659
|
updateLines(mindRoot, fp, start, end, content.split('\n'));
|
|
615
|
-
|
|
660
|
+
const after = safeReadContent(fp);
|
|
661
|
+
const diff = buildDiffSummary(before, after);
|
|
662
|
+
return textResult(`Lines ${start_line}-${end_line} replaced in ${fp}${diff ? ' ' + diff : ''}`);
|
|
616
663
|
}),
|
|
617
664
|
},
|
|
618
665
|
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { remark } from 'remark';
|
|
4
|
+
import remarkGfm from 'remark-gfm';
|
|
5
|
+
import remarkHtml from 'remark-html';
|
|
6
|
+
|
|
7
|
+
/** Convert wiki-links [[target]] → relative HTML links */
|
|
8
|
+
export function convertWikiLinks(content: string, _currentPath: string): string {
|
|
9
|
+
return content.replace(/\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]/g, (_match, target: string, display?: string) => {
|
|
10
|
+
const label = display ?? target;
|
|
11
|
+
const href = target.replace(/\s+/g, '%20');
|
|
12
|
+
return `<a href="${href}.html">${label}</a>`;
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Convert markdown to a complete standalone HTML document */
|
|
17
|
+
export async function markdownToHTML(content: string, title: string, currentPath = ''): Promise<string> {
|
|
18
|
+
// Convert wiki-links first
|
|
19
|
+
const processed = convertWikiLinks(content, currentPath);
|
|
20
|
+
|
|
21
|
+
const result = await remark()
|
|
22
|
+
.use(remarkGfm)
|
|
23
|
+
.use(remarkHtml, { sanitize: false })
|
|
24
|
+
.process(processed);
|
|
25
|
+
|
|
26
|
+
const body = String(result);
|
|
27
|
+
|
|
28
|
+
return `<!DOCTYPE html>
|
|
29
|
+
<html lang="en">
|
|
30
|
+
<head>
|
|
31
|
+
<meta charset="UTF-8">
|
|
32
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
33
|
+
<title>${escapeHtml(title)}</title>
|
|
34
|
+
<style>
|
|
35
|
+
:root { color-scheme: light dark; }
|
|
36
|
+
body {
|
|
37
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
38
|
+
max-width: 720px;
|
|
39
|
+
margin: 2rem auto;
|
|
40
|
+
padding: 0 1.5rem;
|
|
41
|
+
line-height: 1.7;
|
|
42
|
+
color: #1a1a1a;
|
|
43
|
+
background: #fff;
|
|
44
|
+
}
|
|
45
|
+
@media (prefers-color-scheme: dark) {
|
|
46
|
+
body { color: #e0e0e0; background: #1a1a1a; }
|
|
47
|
+
a { color: #6eb5ff; }
|
|
48
|
+
code { background: #2a2a2a; }
|
|
49
|
+
pre { background: #2a2a2a; }
|
|
50
|
+
blockquote { border-color: #444; }
|
|
51
|
+
table, th, td { border-color: #444; }
|
|
52
|
+
}
|
|
53
|
+
h1 { font-size: 1.8rem; font-weight: 700; margin: 2rem 0 1rem; border-bottom: 1px solid #eee; padding-bottom: 0.3rem; }
|
|
54
|
+
h2 { font-size: 1.4rem; font-weight: 600; margin: 1.5rem 0 0.8rem; }
|
|
55
|
+
h3 { font-size: 1.15rem; font-weight: 600; margin: 1.2rem 0 0.5rem; }
|
|
56
|
+
a { color: #0066cc; text-decoration: none; }
|
|
57
|
+
a:hover { text-decoration: underline; }
|
|
58
|
+
code { font-family: 'SF Mono', Menlo, monospace; font-size: 0.875em; background: #f5f5f5; padding: 0.15em 0.4em; border-radius: 3px; }
|
|
59
|
+
pre { background: #f5f5f5; padding: 1rem; border-radius: 6px; overflow-x: auto; }
|
|
60
|
+
pre code { background: none; padding: 0; }
|
|
61
|
+
blockquote { margin: 1rem 0; padding: 0.5rem 1rem; border-left: 3px solid #ddd; color: #666; }
|
|
62
|
+
table { border-collapse: collapse; width: 100%; margin: 1rem 0; }
|
|
63
|
+
th, td { border: 1px solid #ddd; padding: 0.5rem 0.75rem; text-align: left; }
|
|
64
|
+
th { background: #f8f8f8; font-weight: 600; }
|
|
65
|
+
img { max-width: 100%; height: auto; border-radius: 4px; }
|
|
66
|
+
hr { border: none; border-top: 1px solid #eee; margin: 2rem 0; }
|
|
67
|
+
ul, ol { padding-left: 1.5rem; }
|
|
68
|
+
li { margin: 0.25rem 0; }
|
|
69
|
+
.task-list-item { list-style: none; margin-left: -1.5rem; }
|
|
70
|
+
.task-list-item input { margin-right: 0.5rem; }
|
|
71
|
+
@media print {
|
|
72
|
+
body { max-width: 100%; margin: 0; padding: 1rem; }
|
|
73
|
+
a { color: inherit; text-decoration: underline; }
|
|
74
|
+
}
|
|
75
|
+
</style>
|
|
76
|
+
</head>
|
|
77
|
+
<body>
|
|
78
|
+
${body}
|
|
79
|
+
<footer style="margin-top:3rem;padding-top:1rem;border-top:1px solid #eee;font-size:0.75rem;color:#999;">
|
|
80
|
+
Exported from MindOS
|
|
81
|
+
</footer>
|
|
82
|
+
</body>
|
|
83
|
+
</html>`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function escapeHtml(str: string): string {
|
|
87
|
+
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Collect all exportable files in a directory tree */
|
|
91
|
+
export function collectExportFiles(mindRoot: string, dirPath: string): { relativePath: string; content: string }[] {
|
|
92
|
+
const fullDir = path.join(mindRoot, dirPath);
|
|
93
|
+
if (!fs.existsSync(fullDir) || !fs.statSync(fullDir).isDirectory()) return [];
|
|
94
|
+
|
|
95
|
+
const results: { relativePath: string; content: string }[] = [];
|
|
96
|
+
const SKIP = new Set(['INSTRUCTION.md', '.DS_Store']);
|
|
97
|
+
|
|
98
|
+
function walk(dir: string, prefix: string) {
|
|
99
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
100
|
+
for (const entry of entries) {
|
|
101
|
+
if (entry.name.startsWith('.') || SKIP.has(entry.name)) continue;
|
|
102
|
+
const fullPath = path.join(dir, entry.name);
|
|
103
|
+
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
104
|
+
if (entry.isDirectory()) {
|
|
105
|
+
walk(fullPath, relPath);
|
|
106
|
+
} else if (entry.name.endsWith('.md') || entry.name.endsWith('.csv')) {
|
|
107
|
+
try {
|
|
108
|
+
results.push({ relativePath: relPath, content: fs.readFileSync(fullPath, 'utf-8') });
|
|
109
|
+
} catch { /* skip unreadable */ }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
walk(fullDir, '');
|
|
115
|
+
return results;
|
|
116
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
export interface TrashMeta {
|
|
7
|
+
id: string;
|
|
8
|
+
originalPath: string;
|
|
9
|
+
deletedAt: string;
|
|
10
|
+
expiresAt: string;
|
|
11
|
+
fileName: string;
|
|
12
|
+
isDirectory: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const TRASH_DIR = '.trash';
|
|
16
|
+
const META_DIR = '.trash-meta';
|
|
17
|
+
const EXPIRY_DAYS = 30;
|
|
18
|
+
|
|
19
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
function siblingDir(mindRoot: string, name: string): string {
|
|
22
|
+
return path.join(path.dirname(mindRoot), name);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function trashRoot(mindRoot: string): string {
|
|
26
|
+
return siblingDir(mindRoot, TRASH_DIR);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function metaRoot(mindRoot: string): string {
|
|
30
|
+
return siblingDir(mindRoot, META_DIR);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function ensureDirs(mindRoot: string): void {
|
|
34
|
+
fs.mkdirSync(trashRoot(mindRoot), { recursive: true });
|
|
35
|
+
fs.mkdirSync(metaRoot(mindRoot), { recursive: true });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Sanitize filename for use as trash ID — strip all unsafe characters */
|
|
39
|
+
function generateId(fileName: string): string {
|
|
40
|
+
const safe = fileName.replace(/[^a-zA-Z0-9._\-\u4e00-\u9fff]/g, '_');
|
|
41
|
+
return `${Date.now()}_${safe}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function writeMeta(mindRoot: string, meta: TrashMeta): void {
|
|
45
|
+
const metaPath = path.join(metaRoot(mindRoot), `${meta.id}.json`);
|
|
46
|
+
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), 'utf-8');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function readMeta(mindRoot: string, id: string): TrashMeta | null {
|
|
50
|
+
const metaPath = path.join(metaRoot(mindRoot), `${id}.json`);
|
|
51
|
+
if (!fs.existsSync(metaPath)) return null;
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function deleteMeta(mindRoot: string, id: string): void {
|
|
60
|
+
const metaPath = path.join(metaRoot(mindRoot), `${id}.json`);
|
|
61
|
+
try { fs.unlinkSync(metaPath); } catch { /* already gone */ }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Move a file or directory safely, handling cross-filesystem (EXDEV) errors.
|
|
66
|
+
* Tries atomic rename first, falls back to copy+delete.
|
|
67
|
+
*/
|
|
68
|
+
function safeMove(src: string, dest: string, isDir: boolean): void {
|
|
69
|
+
if (isDir) {
|
|
70
|
+
// Directories always use copy+delete (rename doesn't work for dirs across fs)
|
|
71
|
+
fs.cpSync(src, dest, { recursive: true });
|
|
72
|
+
fs.rmSync(src, { recursive: true, force: true });
|
|
73
|
+
} else {
|
|
74
|
+
try {
|
|
75
|
+
fs.renameSync(src, dest);
|
|
76
|
+
} catch (err: unknown) {
|
|
77
|
+
const e = err as NodeJS.ErrnoException;
|
|
78
|
+
if (e.code === 'EXDEV') {
|
|
79
|
+
// Cross-device: fallback to copy+delete
|
|
80
|
+
fs.copyFileSync(src, dest);
|
|
81
|
+
fs.unlinkSync(src);
|
|
82
|
+
} else {
|
|
83
|
+
throw err;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ─── Core Operations ─────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
export function moveToTrash(mindRoot: string, filePath: string): TrashMeta {
|
|
92
|
+
ensureDirs(mindRoot);
|
|
93
|
+
const src = path.join(mindRoot, filePath);
|
|
94
|
+
if (!fs.existsSync(src)) throw new Error(`File not found: ${filePath}`);
|
|
95
|
+
|
|
96
|
+
const isDir = fs.statSync(src).isDirectory();
|
|
97
|
+
const fileName = path.basename(filePath);
|
|
98
|
+
const id = generateId(fileName);
|
|
99
|
+
const dest = path.join(trashRoot(mindRoot), id);
|
|
100
|
+
|
|
101
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
102
|
+
safeMove(src, dest, isDir);
|
|
103
|
+
|
|
104
|
+
const now = new Date();
|
|
105
|
+
const meta: TrashMeta = {
|
|
106
|
+
id,
|
|
107
|
+
originalPath: filePath,
|
|
108
|
+
deletedAt: now.toISOString(),
|
|
109
|
+
expiresAt: new Date(now.getTime() + EXPIRY_DAYS * 86400000).toISOString(),
|
|
110
|
+
fileName,
|
|
111
|
+
isDirectory: isDir,
|
|
112
|
+
};
|
|
113
|
+
writeMeta(mindRoot, meta);
|
|
114
|
+
return meta;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function restoreFromTrash(mindRoot: string, trashId: string, overwrite = false): { restoredPath: string } {
|
|
118
|
+
const meta = readMeta(mindRoot, trashId);
|
|
119
|
+
if (!meta) throw new Error('Item not found in trash');
|
|
120
|
+
|
|
121
|
+
const trashPath = path.join(trashRoot(mindRoot), trashId);
|
|
122
|
+
if (!fs.existsSync(trashPath)) throw new Error('Trash file missing from disk');
|
|
123
|
+
|
|
124
|
+
const dest = path.join(mindRoot, meta.originalPath);
|
|
125
|
+
|
|
126
|
+
// Check for conflicts
|
|
127
|
+
if (fs.existsSync(dest) && !overwrite) {
|
|
128
|
+
throw Object.assign(new Error('Restore conflict: file exists at original location'), { code: 'RESTORE_CONFLICT' });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Ensure parent directory exists
|
|
132
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
133
|
+
|
|
134
|
+
if (meta.isDirectory) {
|
|
135
|
+
if (overwrite && fs.existsSync(dest)) {
|
|
136
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
if (overwrite && fs.existsSync(dest)) {
|
|
140
|
+
fs.unlinkSync(dest);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
safeMove(trashPath, dest, meta.isDirectory);
|
|
145
|
+
deleteMeta(mindRoot, trashId);
|
|
146
|
+
return { restoredPath: meta.originalPath };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function restoreAsCopy(mindRoot: string, trashId: string): { restoredPath: string } {
|
|
150
|
+
const meta = readMeta(mindRoot, trashId);
|
|
151
|
+
if (!meta) throw new Error('Item not found in trash');
|
|
152
|
+
|
|
153
|
+
const trashPath = path.join(trashRoot(mindRoot), trashId);
|
|
154
|
+
if (!fs.existsSync(trashPath)) throw new Error('Trash file missing from disk');
|
|
155
|
+
|
|
156
|
+
// Generate a unique copy name
|
|
157
|
+
const dir = path.dirname(meta.originalPath);
|
|
158
|
+
const ext = path.extname(meta.fileName);
|
|
159
|
+
const base = path.basename(meta.fileName, ext);
|
|
160
|
+
let copyPath = path.join(dir, `${base} (copy)${ext}`);
|
|
161
|
+
let counter = 2;
|
|
162
|
+
while (fs.existsSync(path.join(mindRoot, copyPath))) {
|
|
163
|
+
copyPath = path.join(dir, `${base} (copy ${counter})${ext}`);
|
|
164
|
+
counter++;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const dest = path.join(mindRoot, copyPath);
|
|
168
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
169
|
+
safeMove(trashPath, dest, meta.isDirectory);
|
|
170
|
+
|
|
171
|
+
deleteMeta(mindRoot, trashId);
|
|
172
|
+
return { restoredPath: copyPath };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function permanentlyDelete(mindRoot: string, trashId: string): void {
|
|
176
|
+
const trashPath = path.join(trashRoot(mindRoot), trashId);
|
|
177
|
+
try {
|
|
178
|
+
if (fs.existsSync(trashPath)) {
|
|
179
|
+
const stat = fs.statSync(trashPath);
|
|
180
|
+
if (stat.isDirectory()) {
|
|
181
|
+
fs.rmSync(trashPath, { recursive: true, force: true });
|
|
182
|
+
} else {
|
|
183
|
+
fs.unlinkSync(trashPath);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
} catch { /* file already gone */ }
|
|
187
|
+
deleteMeta(mindRoot, trashId);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function listTrash(mindRoot: string): TrashMeta[] {
|
|
191
|
+
ensureDirs(mindRoot);
|
|
192
|
+
const metaDir = metaRoot(mindRoot);
|
|
193
|
+
let files: string[];
|
|
194
|
+
try {
|
|
195
|
+
files = fs.readdirSync(metaDir).filter(f => f.endsWith('.json'));
|
|
196
|
+
} catch {
|
|
197
|
+
return [];
|
|
198
|
+
}
|
|
199
|
+
const items: TrashMeta[] = [];
|
|
200
|
+
|
|
201
|
+
for (const file of files) {
|
|
202
|
+
try {
|
|
203
|
+
const meta = JSON.parse(fs.readFileSync(path.join(metaDir, file), 'utf-8')) as TrashMeta;
|
|
204
|
+
// Verify the trash file still exists on disk
|
|
205
|
+
const trashPath = path.join(trashRoot(mindRoot), meta.id);
|
|
206
|
+
if (fs.existsSync(trashPath)) {
|
|
207
|
+
items.push(meta);
|
|
208
|
+
} else {
|
|
209
|
+
// Clean up orphaned metadata
|
|
210
|
+
try { fs.unlinkSync(path.join(metaDir, file)); } catch { /* race ok */ }
|
|
211
|
+
}
|
|
212
|
+
} catch {
|
|
213
|
+
// Skip corrupt metadata files
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Sort by deletion time, newest first
|
|
218
|
+
items.sort((a, b) => new Date(b.deletedAt).getTime() - new Date(a.deletedAt).getTime());
|
|
219
|
+
return items;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function emptyTrash(mindRoot: string): number {
|
|
223
|
+
const items = listTrash(mindRoot);
|
|
224
|
+
for (const item of items) {
|
|
225
|
+
permanentlyDelete(mindRoot, item.id);
|
|
226
|
+
}
|
|
227
|
+
return items.length;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function purgeExpired(mindRoot: string): number {
|
|
231
|
+
const items = listTrash(mindRoot);
|
|
232
|
+
const now = Date.now();
|
|
233
|
+
let count = 0;
|
|
234
|
+
for (const item of items) {
|
|
235
|
+
if (new Date(item.expiresAt).getTime() <= now) {
|
|
236
|
+
permanentlyDelete(mindRoot, item.id);
|
|
237
|
+
count++;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return count;
|
|
241
|
+
}
|
package/app/lib/fs.ts
CHANGED
|
@@ -601,6 +601,53 @@ export type { BacklinkEntry } from './core/types';
|
|
|
601
601
|
export type { MindSpaceSummary } from './core';
|
|
602
602
|
export type { ContentChangeEvent, ContentChangeInput, ContentChangeSummary, ContentChangeSource } from './core';
|
|
603
603
|
|
|
604
|
+
// ─── Public API: Trash (delegated to @mindos/core/trash) ────────────────────
|
|
605
|
+
|
|
606
|
+
import {
|
|
607
|
+
moveToTrash as coreMoveToTrash,
|
|
608
|
+
restoreFromTrash as coreRestoreFromTrash,
|
|
609
|
+
restoreAsCopy as coreRestoreAsCopy,
|
|
610
|
+
permanentlyDelete as corePermanentlyDelete,
|
|
611
|
+
listTrash as coreListTrash,
|
|
612
|
+
emptyTrash as coreEmptyTrash,
|
|
613
|
+
purgeExpired as corePurgeExpired,
|
|
614
|
+
} from './core/trash';
|
|
615
|
+
export type { TrashMeta } from './core/trash';
|
|
616
|
+
|
|
617
|
+
export function moveToTrashFile(filePath: string) {
|
|
618
|
+
const result = coreMoveToTrash(getMindRoot(), filePath);
|
|
619
|
+
invalidateCache();
|
|
620
|
+
return result;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
export function restoreFromTrash(trashId: string, overwrite = false) {
|
|
624
|
+
const result = coreRestoreFromTrash(getMindRoot(), trashId, overwrite);
|
|
625
|
+
invalidateCache();
|
|
626
|
+
return result;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
export function restoreAsCopy(trashId: string) {
|
|
630
|
+
const result = coreRestoreAsCopy(getMindRoot(), trashId);
|
|
631
|
+
invalidateCache();
|
|
632
|
+
return result;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
export function permanentlyDeleteFromTrash(trashId: string) {
|
|
636
|
+
corePermanentlyDelete(getMindRoot(), trashId);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
export function listTrash() {
|
|
640
|
+
return coreListTrash(getMindRoot());
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
export function emptyTrashAll() {
|
|
644
|
+
return coreEmptyTrash(getMindRoot());
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
export function purgeExpiredTrash() {
|
|
648
|
+
return corePurgeExpired(getMindRoot());
|
|
649
|
+
}
|
|
650
|
+
|
|
604
651
|
export function findBacklinks(targetPath: string): BacklinkEntry[] {
|
|
605
652
|
const { allFiles } = ensureCache();
|
|
606
653
|
return coreFindBacklinks(getMindRoot(), targetPath, allFiles);
|