@geminilight/mindos 0.5.69 → 0.5.70

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.
@@ -0,0 +1,197 @@
1
+ export const dynamic = 'force-dynamic';
2
+ export const runtime = 'nodejs';
3
+
4
+ import fs from 'fs';
5
+ import path from 'path';
6
+ import { NextRequest, NextResponse } from 'next/server';
7
+ import { revalidatePath } from 'next/cache';
8
+ import { sanitizeFileName, convertToMarkdown } from '@/lib/core/file-convert';
9
+ import { resolveSafe } from '@/lib/core/security';
10
+ import { scaffoldIfNewSpace } from '@/lib/core/space-scaffold';
11
+ import { organizeAfterImport } from '@/lib/core/organize';
12
+ import { invalidateSearchIndex } from '@/lib/core/search';
13
+ import { effectiveSopRoot } from '@/lib/settings';
14
+ import { invalidateCache } from '@/lib/fs';
15
+
16
+ const MAX_FILES = 20;
17
+ const MAX_CONTENT_LENGTH = 5 * 1024 * 1024;
18
+
19
+ type ConflictMode = 'skip' | 'rename' | 'overwrite';
20
+
21
+ interface ImportRequest {
22
+ files: Array<{
23
+ name: string;
24
+ content: string;
25
+ encoding?: 'text' | 'base64';
26
+ }>;
27
+ targetSpace?: string;
28
+ organize?: boolean;
29
+ conflict?: ConflictMode;
30
+ }
31
+
32
+ function normalizeTargetSpace(raw: unknown): string {
33
+ if (raw === undefined || raw === null) return '';
34
+ if (typeof raw !== 'string') return '';
35
+ return raw.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, '').trim();
36
+ }
37
+
38
+ function decodeFileContent(
39
+ encoding: 'text' | 'base64' | undefined,
40
+ content: string,
41
+ sanitizedName: string,
42
+ ): string {
43
+ if (encoding === 'base64') {
44
+ const buf = Buffer.from(content, 'base64');
45
+ if (sanitizedName.toLowerCase().endsWith('.pdf')) {
46
+ return buf.toString('latin1');
47
+ }
48
+ return buf.toString('utf-8');
49
+ }
50
+ return content;
51
+ }
52
+
53
+ function resolveUniquePath(
54
+ mindRoot: string,
55
+ relPath: string,
56
+ conflict: ConflictMode,
57
+ ): { relPath: string; resolved: string; skipped?: string } {
58
+ let rel = relPath.replace(/\\/g, '/');
59
+ let resolved = resolveSafe(mindRoot, rel);
60
+ if (!fs.existsSync(resolved)) {
61
+ return { relPath: rel, resolved };
62
+ }
63
+ if (conflict === 'skip') {
64
+ return { relPath: rel, resolved, skipped: 'file exists' };
65
+ }
66
+ if (conflict === 'overwrite') {
67
+ return { relPath: rel, resolved };
68
+ }
69
+ let n = 0;
70
+ while (fs.existsSync(resolved)) {
71
+ n += 1;
72
+ const dir = path.posix.dirname(rel);
73
+ const base = path.posix.basename(rel);
74
+ const ext = path.posix.extname(base);
75
+ const stem = ext ? base.slice(0, -ext.length) : base;
76
+ const newBase = `${stem}-${n}${ext}`;
77
+ rel = dir && dir !== '.' ? path.posix.join(dir, newBase) : newBase;
78
+ resolved = resolveSafe(mindRoot, rel);
79
+ }
80
+ return { relPath: rel, resolved };
81
+ }
82
+
83
+ export async function POST(req: NextRequest) {
84
+ let body: unknown;
85
+ try {
86
+ body = await req.json();
87
+ } catch {
88
+ return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
89
+ }
90
+
91
+ const mindRoot = effectiveSopRoot().trim();
92
+ if (!mindRoot) {
93
+ return NextResponse.json({ error: 'MIND_ROOT is not configured' }, { status: 400 });
94
+ }
95
+
96
+ if (!body || typeof body !== 'object') {
97
+ return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
98
+ }
99
+
100
+ const reqBody = body as ImportRequest;
101
+ if (!Array.isArray(reqBody.files)) {
102
+ return NextResponse.json({ error: 'files must be an array' }, { status: 400 });
103
+ }
104
+
105
+ if (reqBody.files.length > MAX_FILES) {
106
+ return NextResponse.json({ error: `At most ${MAX_FILES} files per request` }, { status: 400 });
107
+ }
108
+
109
+ const targetSpaceNorm = normalizeTargetSpace(reqBody.targetSpace);
110
+ const organize = reqBody.organize !== false;
111
+ const conflict: ConflictMode =
112
+ reqBody.conflict === 'skip' || reqBody.conflict === 'overwrite' || reqBody.conflict === 'rename'
113
+ ? reqBody.conflict
114
+ : 'rename';
115
+
116
+ const created: Array<{ original: string; path: string }> = [];
117
+ const skipped: Array<{ name: string; reason: string }> = [];
118
+ const errors: Array<{ name: string; error: string }> = [];
119
+ const createdPaths: string[] = [];
120
+ const updatedFiles: string[] = [];
121
+
122
+ for (const entry of reqBody.files) {
123
+ const originalName = typeof entry?.name === 'string' ? entry.name : '';
124
+ try {
125
+ if (typeof entry?.name !== 'string' || typeof entry?.content !== 'string') {
126
+ errors.push({ name: originalName || '(unknown)', error: 'name and content must be strings' });
127
+ continue;
128
+ }
129
+ if (!entry.name.trim()) {
130
+ errors.push({ name: '(empty)', error: 'name must not be empty' });
131
+ continue;
132
+ }
133
+ if (entry.content.length > MAX_CONTENT_LENGTH) {
134
+ errors.push({ name: entry.name, error: `content exceeds ${MAX_CONTENT_LENGTH} characters` });
135
+ continue;
136
+ }
137
+
138
+ const sanitized = sanitizeFileName(entry.name);
139
+ const encoding = entry.encoding === 'base64' ? 'base64' : 'text';
140
+ const raw = decodeFileContent(encoding, entry.content, sanitized);
141
+ const convertResult = convertToMarkdown(sanitized, raw);
142
+
143
+ let relPath = targetSpaceNorm
144
+ ? path.posix.join(targetSpaceNorm, convertResult.targetName)
145
+ : convertResult.targetName;
146
+
147
+ const { relPath: finalRel, resolved, skipped: skipReason } = resolveUniquePath(
148
+ mindRoot,
149
+ relPath,
150
+ conflict,
151
+ );
152
+
153
+ if (skipReason) {
154
+ skipped.push({ name: entry.name, reason: skipReason });
155
+ continue;
156
+ }
157
+
158
+ fs.mkdirSync(path.dirname(resolved), { recursive: true });
159
+ fs.writeFileSync(resolved, convertResult.content, 'utf-8');
160
+ scaffoldIfNewSpace(mindRoot, finalRel);
161
+
162
+ created.push({ original: entry.name, path: finalRel });
163
+ createdPaths.push(finalRel);
164
+ } catch (e) {
165
+ errors.push({ name: originalName || '(unknown)', error: (e as Error).message });
166
+ }
167
+ }
168
+
169
+ if (organize && createdPaths.length > 0) {
170
+ try {
171
+ const { readmeUpdated } = organizeAfterImport(mindRoot, createdPaths, targetSpaceNorm);
172
+ if (readmeUpdated && targetSpaceNorm) {
173
+ updatedFiles.push(path.posix.join(targetSpaceNorm, 'README.md'));
174
+ }
175
+ } catch {
176
+ /* organize is best-effort */
177
+ }
178
+ }
179
+
180
+ if (created.length > 0 || updatedFiles.length > 0) {
181
+ invalidateSearchIndex();
182
+ invalidateCache();
183
+ }
184
+
185
+ try {
186
+ revalidatePath('/');
187
+ } catch {
188
+ /* noop in test env */
189
+ }
190
+
191
+ return NextResponse.json({
192
+ created,
193
+ skipped,
194
+ errors,
195
+ updatedFiles,
196
+ });
197
+ }
@@ -2,13 +2,13 @@
2
2
 
3
3
  import { useRef, useCallback, useState, useEffect } from 'react';
4
4
  import Link from 'next/link';
5
- import { FolderTree, Search, Settings, RefreshCw, Blocks, Bot, Compass, HelpCircle, ChevronLeft, ChevronRight, Radio } from 'lucide-react';
5
+ import { FolderTree, Search, Settings, RefreshCw, Bot, Compass, HelpCircle, ChevronLeft, ChevronRight, Radio } from 'lucide-react';
6
6
  import { useLocale } from '@/lib/LocaleContext';
7
7
  import { DOT_COLORS, getStatusLevel } from './SyncStatusBar';
8
8
  import type { SyncStatus } from './settings/SyncTab';
9
9
  import Logo from './Logo';
10
10
 
11
- export type PanelId = 'files' | 'search' | 'echo' | 'plugins' | 'agents' | 'discover';
11
+ export type PanelId = 'files' | 'search' | 'echo' | 'agents' | 'discover';
12
12
 
13
13
  export const RAIL_WIDTH_COLLAPSED = 48;
14
14
  export const RAIL_WIDTH_EXPANDED = 180;
@@ -181,9 +181,8 @@ export default function ActivityBar({
181
181
  {/* ── Middle: Core panel toggles ── */}
182
182
  <div className={`flex flex-col ${expanded ? 'px-1.5' : 'items-center'} gap-1 py-2`}>
183
183
  <RailButton icon={<FolderTree size={18} />} label={t.sidebar.files} active={activePanel === 'files'} expanded={expanded} onClick={() => toggle('files')} walkthroughId="files-panel" />
184
- <RailButton icon={<Radio size={18} />} label={t.sidebar.echo} active={activePanel === 'echo'} expanded={expanded} onClick={() => toggle('echo')} />
185
184
  <RailButton icon={<Search size={18} />} label={t.sidebar.searchTitle} shortcut="⌘K" active={activePanel === 'search'} expanded={expanded} onClick={() => toggle('search')} walkthroughId="search-button" />
186
- <RailButton icon={<Blocks size={18} />} label={t.sidebar.plugins} active={activePanel === 'plugins'} expanded={expanded} onClick={() => toggle('plugins')} />
185
+ <RailButton icon={<Radio size={18} />} label={t.sidebar.echo} active={activePanel === 'echo'} expanded={expanded} onClick={() => toggle('echo')} />
187
186
  <RailButton
188
187
  icon={<Bot size={18} />}
189
188
  label={t.sidebar.agents}
@@ -6,7 +6,7 @@ import { FileNode } from '@/lib/types';
6
6
  import { encodePath } from '@/lib/utils';
7
7
  import {
8
8
  ChevronDown, FileText, Table, Folder, FolderOpen, Plus, Loader2,
9
- Trash2, Pencil, Layers, ScrollText,
9
+ Trash2, Pencil, Layers, ScrollText, FolderInput,
10
10
  } from 'lucide-react';
11
11
  import { createFileAction, deleteFileAction, renameFileAction, renameSpaceAction, deleteSpaceAction, convertToSpaceAction, deleteFolderAction } from '@/lib/actions';
12
12
  import { useLocale } from '@/lib/LocaleContext';
@@ -23,6 +23,7 @@ interface FileTreeProps {
23
23
  onNavigate?: () => void;
24
24
  maxOpenDepth?: number | null;
25
25
  parentIsSpace?: boolean;
26
+ onImport?: (space: string) => void;
26
27
  }
27
28
 
28
29
  function getIcon(node: FileNode) {
@@ -96,8 +97,8 @@ const MENU_DIVIDER = "my-1 border-t border-border/50";
96
97
 
97
98
  // ─── SpaceContextMenu ─────────────────────────────────────────────────────────
98
99
 
99
- function SpaceContextMenu({ x, y, node, onClose, onRename }: {
100
- x: number; y: number; node: FileNode; onClose: () => void; onRename: () => void;
100
+ function SpaceContextMenu({ x, y, node, onClose, onRename, onImport }: {
101
+ x: number; y: number; node: FileNode; onClose: () => void; onRename: () => void; onImport?: (space: string) => void;
101
102
  }) {
102
103
  const router = useRouter();
103
104
  const { t } = useLocale();
@@ -108,6 +109,11 @@ function SpaceContextMenu({ x, y, node, onClose, onRename }: {
108
109
  <button className={MENU_ITEM} onClick={() => { router.push(`/view/${encodePath(`${node.path}/INSTRUCTION.md`)}`); onClose(); }}>
109
110
  <ScrollText size={14} className="shrink-0" /> {t.fileTree.editRules}
110
111
  </button>
112
+ {onImport && (
113
+ <button className={MENU_ITEM} onClick={() => { onImport(node.path); onClose(); }}>
114
+ <FolderInput size={14} className="shrink-0" /> {t.fileTree.importFile}
115
+ </button>
116
+ )}
111
117
  <button className={MENU_ITEM} onClick={() => { onRename(); onClose(); }}>
112
118
  <Pencil size={14} className="shrink-0" /> {t.fileTree.renameSpace}
113
119
  </button>
@@ -240,9 +246,9 @@ function NewFileInline({ dirPath, depth, onDone }: { dirPath: string; depth: num
240
246
 
241
247
  // ─── DirectoryNode ────────────────────────────────────────────────────────────
242
248
 
243
- function DirectoryNode({ node, depth, currentPath, onNavigate, maxOpenDepth }: {
249
+ function DirectoryNode({ node, depth, currentPath, onNavigate, maxOpenDepth, onImport }: {
244
250
  node: FileNode; depth: number; currentPath: string; onNavigate?: () => void;
245
- maxOpenDepth?: number | null;
251
+ maxOpenDepth?: number | null; onImport?: (space: string) => void;
246
252
  }) {
247
253
  const router = useRouter();
248
254
  const isActive = currentPath.startsWith(node.path + '/') || currentPath === node.path;
@@ -255,6 +261,8 @@ function DirectoryNode({ node, depth, currentPath, onNavigate, maxOpenDepth }: {
255
261
  const renameRef = useRef<HTMLInputElement>(null);
256
262
  const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
257
263
  const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
264
+ const [plusPopover, setPlusPopover] = useState(false);
265
+ const plusRef = useRef<HTMLButtonElement>(null);
258
266
  const { t } = useLocale();
259
267
 
260
268
  const toggle = useCallback(() => setOpen(v => !v), []);
@@ -391,12 +399,12 @@ function DirectoryNode({ node, depth, currentPath, onNavigate, maxOpenDepth }: {
391
399
  </button>
392
400
  <div className="absolute right-1 top-1/2 -translate-y-1/2 hidden group-hover/dir:flex items-center gap-0.5 z-10">
393
401
  <button
402
+ ref={plusRef}
394
403
  type="button"
395
404
  onClick={(e) => {
396
405
  e.preventDefault();
397
406
  e.stopPropagation();
398
- setOpen(true);
399
- setShowNewFile(true);
407
+ setPlusPopover(v => !v);
400
408
  }}
401
409
  className="p-0.5 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
402
410
  title={t.fileTree.newFileTitle}
@@ -444,6 +452,7 @@ function DirectoryNode({ node, depth, currentPath, onNavigate, maxOpenDepth }: {
444
452
  onNavigate={onNavigate}
445
453
  maxOpenDepth={maxOpenDepth}
446
454
  parentIsSpace={isSpace}
455
+ onImport={onImport}
447
456
  />
448
457
  )}
449
458
  {showNewFile && (
@@ -462,6 +471,7 @@ function DirectoryNode({ node, depth, currentPath, onNavigate, maxOpenDepth }: {
462
471
  node={node}
463
472
  onClose={() => setContextMenu(null)}
464
473
  onRename={() => startRename()}
474
+ onImport={onImport}
465
475
  />
466
476
  ) : (
467
477
  <FolderContextMenu
@@ -472,6 +482,22 @@ function DirectoryNode({ node, depth, currentPath, onNavigate, maxOpenDepth }: {
472
482
  onRename={() => startRename()}
473
483
  />
474
484
  ))}
485
+
486
+ {plusPopover && plusRef.current && (() => {
487
+ const rect = plusRef.current!.getBoundingClientRect();
488
+ return (
489
+ <ContextMenuShell x={rect.left} y={rect.bottom + 4} onClose={() => setPlusPopover(false)} menuHeight={80}>
490
+ <button className={MENU_ITEM} onClick={() => { setPlusPopover(false); setOpen(true); setShowNewFile(true); }}>
491
+ <FileText size={14} className="shrink-0" /> {t.fileTree.newFile}
492
+ </button>
493
+ {onImport && (
494
+ <button className={MENU_ITEM} onClick={() => { setPlusPopover(false); onImport(node.path); }}>
495
+ <FolderInput size={14} className="shrink-0" /> {t.fileTree.importFile}
496
+ </button>
497
+ )}
498
+ </ContextMenuShell>
499
+ );
500
+ })()}
475
501
  </div>
476
502
  );
477
503
  }
@@ -589,7 +615,7 @@ function FileNodeItem({ node, depth, currentPath, onNavigate }: {
589
615
 
590
616
  // ─── FileTree (root) ──────────────────────────────────────────────────────────
591
617
 
592
- export default function FileTree({ nodes, depth = 0, onNavigate, maxOpenDepth, parentIsSpace }: FileTreeProps) {
618
+ export default function FileTree({ nodes, depth = 0, onNavigate, maxOpenDepth, parentIsSpace, onImport }: FileTreeProps) {
593
619
  const pathname = usePathname();
594
620
  const currentPath = getCurrentFilePath(pathname);
595
621
 
@@ -609,7 +635,7 @@ export default function FileTree({ nodes, depth = 0, onNavigate, maxOpenDepth, p
609
635
  <div className="flex flex-col gap-0.5">
610
636
  {visibleNodes.map((node) =>
611
637
  node.type === 'directory' ? (
612
- <DirectoryNode key={node.path} node={node} depth={depth} currentPath={currentPath} onNavigate={onNavigate} maxOpenDepth={maxOpenDepth} />
638
+ <DirectoryNode key={node.path} node={node} depth={depth} currentPath={currentPath} onNavigate={onNavigate} maxOpenDepth={maxOpenDepth} onImport={onImport} />
613
639
  ) : (
614
640
  <FileNodeItem key={node.path} node={node} depth={depth} currentPath={currentPath} onNavigate={onNavigate} />
615
641
  )