git save current version as 0.0.7

This commit is contained in:
liaibo
2025-12-01 21:35:30 +08:00
parent 5db5923535
commit 3a9b0dc83c
8 changed files with 434 additions and 9 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.
Binary file not shown.
+4
View File
@@ -42,8 +42,12 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"fast-xml-parser": "^4.5.3",
"iconv-lite": "^0.6.3",
"jszip": "^3.10.1",
"lucide-react": "^0.554.0",
"next": "16.0.3",
"papaparse": "^5.5.3",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-dropzone": "^14.3.8",
+30
View File
@@ -98,12 +98,24 @@ importers:
cmdk:
specifier: ^1.1.1
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.5))(@types/react@19.2.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
fast-xml-parser:
specifier: ^4.5.3
version: 4.5.3
iconv-lite:
specifier: ^0.6.3
version: 0.6.3
jszip:
specifier: ^3.10.1
version: 3.10.1
lucide-react:
specifier: ^0.554.0
version: 0.554.0(react@19.2.0)
next:
specifier: 16.0.3
version: 16.0.3(@babel/core@7.28.5)(@playwright/test@1.57.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
papaparse:
specifier: ^5.5.3
version: 5.5.3
react:
specifier: 19.2.0
version: 19.2.0
@@ -2927,6 +2939,10 @@ packages:
fast-levenshtein@2.0.6:
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
fast-xml-parser@4.5.3:
resolution: {integrity: sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==}
hasBin: true
fastq@1.19.1:
resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==}
@@ -3935,6 +3951,9 @@ packages:
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
papaparse@5.5.3:
resolution: {integrity: sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==}
parchment@3.0.0:
resolution: {integrity: sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==}
@@ -4547,6 +4566,9 @@ packages:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
strnum@1.1.2:
resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==}
styled-jsx@5.1.6:
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
engines: {node: '>= 12.0.0'}
@@ -8053,6 +8075,10 @@ snapshots:
fast-levenshtein@2.0.6: {}
fast-xml-parser@4.5.3:
dependencies:
strnum: 1.1.2
fastq@1.19.1:
dependencies:
reusify: 1.1.0
@@ -9393,6 +9419,8 @@ snapshots:
pako@1.0.11: {}
papaparse@5.5.3: {}
parchment@3.0.0: {}
parent-module@1.0.1:
@@ -10195,6 +10223,8 @@ snapshots:
strip-json-comments@3.1.1: {}
strnum@1.1.2: {}
styled-jsx@5.1.6(@babel/core@7.28.5)(react@19.2.0):
dependencies:
client-only: 0.0.1
@@ -0,0 +1,303 @@
import { NextResponse } from 'next/server';
import JSZip from 'jszip';
import { XMLParser } from 'fast-xml-parser';
import Papa from 'papaparse';
import iconv from 'iconv-lite';
import { normalizeSnapshot } from '@/lib/mindmap/snapshot';
import type { MindmapNode, MindmapSnapshot } from '@/types/mindmap';
const arrify = <T>(value: T | T[] | undefined | null): T[] => {
if (Array.isArray(value)) return value;
if (value === undefined || value === null) return [];
return [value];
};
const safeText = (value: unknown, fallback = '未命名节点') => {
if (typeof value === 'string' && value.trim()) return value.trim();
if (value && typeof value === 'object' && 'PlainText' in value) {
const maybe = (value as { PlainText?: string }).PlainText;
if (typeof maybe === 'string' && maybe.trim()) return maybe.trim();
}
return fallback;
};
const pickUid = (input: Record<string, unknown>) => {
const candidates = ['OId', 'Guid', 'ID', 'Id', 'uid'];
for (const key of candidates) {
const value = input[key];
if (typeof value === 'string' && value.trim()) return value.trim();
}
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID();
}
return `node_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`;
};
const decodeBuffer = (buffer: Buffer) => {
const utf8 = buffer.toString('utf8');
const bad = (utf8.match(/\ufffd/g) ?? []).length;
if (bad / Math.max(utf8.length, 1) <= 0.01) {
return utf8;
}
try {
return iconv.decode(buffer, 'gb18030');
} catch {
return utf8;
}
};
const parseUri = (input: unknown): string | null => {
if (typeof input === 'string') {
return input.trim();
}
if (
input &&
typeof input === 'object' &&
'#text' in (input as Record<string, unknown>) &&
typeof (input as Record<string, unknown>)['#text'] === 'string'
) {
return ((input as Record<string, unknown>)['#text'] as string).trim();
}
return null;
};
const convertMmapTopic = (
raw: any,
uriDataMap?: Map<string, { url: string; width?: number; height?: number }>
): MindmapNode => {
const data: MindmapNode['data'] = {
text: safeText(raw?.Text ?? raw?.text, ''),
};
if (raw?.Notes?.PlainText) {
data.note = String(raw.Notes.PlainText);
}
const hyperlink =
raw?.Hyperlink?.Url ??
raw?.Hyperlink?.URL ??
raw?.Hyperlink?.link ??
raw?.hyperlink?.url;
if (typeof hyperlink === 'string' && hyperlink.trim()) {
data.hyperlink = hyperlink.trim();
}
if (raw?.Labels?.Label) {
const tags = arrify(raw.Labels.Label)
.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter(Boolean);
if (tags.length > 0) data.tag = tags;
}
const images = [...arrify(raw?.OneImage), ...arrify(raw?.Image)];
if (images.length > 0) {
const first = images[0];
const uri = parseUri(
first?.Image?.ImageData?.Uri ??
first?.ImageData?.Uri ??
first?.Uri
);
const size =
first?.Image?.ImageSize ??
first?.ImageSize;
if (uri && uriDataMap?.has(uri)) {
const payload = uriDataMap.get(uri)!;
data.image = payload.url;
if (size?.Width || size?.Height) {
data.imageSize = {
width: Number(size.Width) || 0,
height: Number(size.Height) || 0,
custom: true,
};
}
}
}
if (!data.text && images.length === 0) {
data.text = '未命名节点';
}
const childrenRaw =
raw?.SubTopics?.Topic ??
raw?.Topics?.Topic ??
raw?.SubTopics?.oneTopic ??
raw?.Topic;
const children = arrify(childrenRaw).map((child) =>
convertMmapTopic(child, uriDataMap)
);
return {
uid: pickUid(raw ?? {}),
data,
children,
};
};
const parseMmap = async (buffer: Buffer): Promise<MindmapSnapshot> => {
const zip = await JSZip.loadAsync(buffer);
const docEntry =
zip.file('Document.xml') ??
zip.file('document.xml') ??
zip.file(/Document\.xml$/i);
const target = Array.isArray(docEntry) ? docEntry[0] : docEntry;
if (!target) {
throw new Error('未找到 Document.xml');
}
const xmlText = await target.async('text');
const parser = new XMLParser({
ignoreAttributes: false,
removeNSPrefix: true,
attributeNamePrefix: '',
});
const parsed = parser.parse(xmlText);
const topicRoot =
parsed?.Map?.OneTopic?.Topic ??
parsed?.OneTopic?.Topic ??
parsed?.Topic ??
null;
const rootTopic = Array.isArray(topicRoot) ? topicRoot[0] : topicRoot;
if (!rootTopic) {
throw new Error('mmap 文件中未找到 Topic 节点');
}
const uriSet = new Set<string>();
const collectImageUris = (node: any) => {
if (!node || typeof node !== 'object') return;
const images = [...arrify(node.OneImage), ...arrify(node.Image)];
images.forEach((img) => {
const uri = parseUri(
img?.Image?.ImageData?.Uri ??
img?.ImageData?.Uri ??
img?.Uri
);
if (uri) {
uriSet.add(uri);
}
});
const children =
arrify(node.SubTopics?.Topic) ??
arrify(node.Topics?.Topic) ??
arrify(node.Topic);
children.forEach(collectImageUris);
};
collectImageUris(rootTopic);
const uriDataMap = new Map<string, { url: string; width?: number; height?: number }>();
const detectMime = (buf: Buffer) => {
if (buf.slice(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
return 'image/png';
}
if (buf.slice(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff]))) {
return 'image/jpeg';
}
if (buf.slice(0, 2).equals(Buffer.from([0x42, 0x4d]))) {
return 'image/bmp';
}
return 'application/octet-stream';
};
for (const uri of uriSet) {
const path = uri.startsWith('mmarch://') ? uri.replace('mmarch://', '') : uri;
const file = zip.file(path);
if (!file) continue;
const bin = await file.async('nodebuffer');
const mime = detectMime(bin);
const base64 = bin.toString('base64');
uriDataMap.set(uri, { url: `data:${mime};base64,${base64}` });
}
const rootNode = convertMmapTopic(rootTopic, uriDataMap);
return {
version: Date.now(),
layout: 'logicalStructure',
root: rootNode,
theme: { template: 'default', config: {} },
view: null,
};
};
const parseCsv = (buffer: Buffer): MindmapSnapshot => {
const text = decodeBuffer(buffer);
const { data, errors } = Papa.parse<string[]>(text, {
skipEmptyLines: true,
});
if (errors && errors.length > 0) {
console.warn('CSV parse warnings', errors);
}
const rows = (data as string[][]).filter((row) => Array.isArray(row));
let root: MindmapNode | null = null;
const stack: MindmapNode[] = [];
rows.forEach((row) => {
const idx = row.findIndex((cell) => typeof cell === 'string' && cell.trim());
if (idx === -1) return;
const textValue = String(row[idx]).trim();
const node: MindmapNode = {
uid: pickUid({}),
data: { text: textValue },
children: [],
};
stack[idx] = node;
stack.length = idx + 1;
if (idx === 0) {
if (!root) {
root = node;
} else {
(root.children ??= []).push(node);
}
return;
}
const parent = stack[idx - 1] ?? root;
if (parent) {
(parent.children ??= []).push(node);
} else {
root = node;
}
});
if (!root) {
root = {
uid: pickUid({}),
data: { text: '导入导图' },
children: [],
};
}
return {
version: Date.now(),
layout: 'logicalStructure',
root,
theme: { template: 'default', config: {} },
view: null,
};
};
export async function POST(request: Request) {
try {
const formData = await request.formData();
const file = formData.get('file');
if (!(file instanceof File)) {
return NextResponse.json({ error: '缺少文件' }, { status: 400 });
}
const buffer = Buffer.from(await file.arrayBuffer());
const ext = file.name.split('.').pop()?.toLowerCase() ?? '';
let snapshot: MindmapSnapshot;
if (ext === 'mmap') {
snapshot = await parseMmap(buffer);
} else if (ext === 'csv') {
snapshot = parseCsv(buffer);
} else {
return NextResponse.json(
{ error: '不支持的文件类型,请上传 .mmap 或 .csv' },
{ status: 400 }
);
}
return NextResponse.json(normalizeSnapshot(snapshot));
} catch (error) {
console.error('导入导图失败', error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : '导入失败' },
{ status: 500 }
);
}
}
@@ -786,15 +786,43 @@ export function MindmapLabClient({
const file = event.target.files?.[0];
if (!file) return;
const ext = file.name.split('.').pop()?.toLowerCase();
try {
const text = await file.text();
const parsed = JSON.parse(text);
const normalized = scrubRichTextSnapshot(normalizeSnapshot(parsed));
setSnapshot(normalized);
if (initialDocumentId) {
scheduleSave(normalized);
if (ext === 'json' || ext === 'txt') {
const text = await file.text();
const parsed = JSON.parse(text);
const normalized = scrubRichTextSnapshot(normalizeSnapshot(parsed));
setSnapshot(normalized);
if (initialDocumentId) {
scheduleSave(normalized);
}
toast.success('导入成功', { description: file.name });
} else if (ext === 'mmap' || ext === 'csv') {
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/mindmap/import', {
method: 'POST',
body: formData,
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
throw new Error(payload?.error ?? '导入失败');
}
const payload = (await response.json()) as MindmapSnapshot;
const normalized = scrubRichTextSnapshot(normalizeSnapshot(payload));
setSnapshot(normalized);
if (initialDocumentId) {
scheduleSave(normalized);
}
toast.success('已导入 MindManager/CSV 导图', {
description: file.name,
});
} else {
toast.error('暂不支持的文件类型', {
description: '请上传 JSON、MMAP 或 CSV 文件',
});
}
toast.success('导入成功', { description: file.name });
} catch (error) {
console.error('导入导图失败', error);
toast.error('导入失败', {
@@ -1796,7 +1824,7 @@ export function MindmapLabClient({
onClick={handleImportClick}
type="button"
>
JSON
(JSON/MMAP/CSV)
</Button>
</div>
</div>
@@ -2157,7 +2185,7 @@ export function MindmapLabClient({
<Input
ref={fileInputRef}
type="file"
accept="application/json"
accept=".json,.txt,.mmap,.csv"
className="hidden"
onChange={handleImportChange}
/>
+60
View File
@@ -0,0 +1,60 @@
const fs=require("fs");
const JSZip=require("jszip");
const {XMLParser}=require("fast-xml-parser");
const arrify=(v)=>Array.isArray(v)?v:(v==null?[]:[v]);
const safeText=(v)=>typeof v==='string'&&v.trim()?v.trim():(v&&typeof v==='object'&&v.PlainText?String(v.PlainText).trim():"未命名节点");
const pickUid=(obj)=>Object.entries(obj||{}).find(([k,v])=>['OId','Guid','ID','Id','uid'].includes(k)&&typeof v==='string'&&v.trim())?.[1]||`node_${Math.random().toString(16).slice(2)}`;
const parseUri=(input)=>{if(typeof input==='string') return input.trim(); if(input && typeof input==='object' && '#text' in input && typeof input['#text']==='string') return input['#text'].trim(); return null;};
async function parse(buf){
const zip=await JSZip.loadAsync(buf);
const xml=await zip.file('Document.xml').async('text');
const parser=new XMLParser({ignoreAttributes:false,removeNSPrefix:true,attributeNamePrefix:''});
const parsed=parser.parse(xml);
const rootTopic=(parsed.Map?.OneTopic?.Topic)||parsed.OneTopic?.Topic||parsed.Topic;
const root=Array.isArray(rootTopic)?rootTopic[0]:rootTopic;
const uriSet=new Set();
(function collect(node){
if(!node||typeof node!=='object') return;
const images=[...arrify(node.OneImage), ...arrify(node.Image)];
images.forEach(img=>{
const uri=parseUri(img?.Image?.ImageData?.Uri ?? img?.ImageData?.Uri ?? img?.Uri);
if(uri) uriSet.add(uri);
});
const children=(arrify(node.SubTopics?.Topic)||arrify(node.Topics?.Topic)||arrify(node.Topic));
children.forEach(collect);
})(root);
const uriData=new Map();
const detectMime=(b)=> b.slice(0,8).equals(Buffer.from([0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a]))?'image/png':b.slice(0,3).equals(Buffer.from([0xff,0xd8,0xff]))?'image/jpeg':b.slice(0,2).equals(Buffer.from([0x42,0x4d]))?'image/bmp':'application/octet-stream';
for(const uri of uriSet){
const path=uri.startsWith('mmarch://')?uri.replace('mmarch://',''):uri;
const file=zip.file(path);
if(!file) continue;
const bin=await file.async('nodebuffer');
const mime=detectMime(bin);
uriData.set(uri,{url:`data:${mime};base64,${bin.toString('base64')}`});
}
const conv=(node)=>{
const data={text:safeText(node?.Text??node?.text)};
const images=[...arrify(node?.OneImage), ...arrify(node?.Image)];
if(images.length){
const first=images[0];
const uri=parseUri(first?.Image?.ImageData?.Uri ?? first?.ImageData?.Uri ?? first?.Uri);
const size=first?.Image?.ImageSize||first?.ImageSize;
if(uri && uriData.has(uri)){
data.image=uriData.get(uri).url;
if(size?.Width||size?.Height){
data.imageSize={width:Number(size.Width)||0,height:Number(size.Height)||0,custom:true};
}
}
}
const kids=(arrify(node?.SubTopics?.Topic)||arrify(node?.Topics?.Topic)||arrify(node?.Topic)).map(conv);
return {uid:pickUid(node||{}), data, children:kids};
};
return conv(root);
}
(async()=>{
const root=await parse(fs.readFileSync('..\\design\\导图1.mmap'));
const imgs=[];const walk=(n)=>{if(n.data.image) imgs.push(n.data.image.slice(0,40));(n.children||[]).forEach(walk)};walk(root);
console.log('image count', imgs.length);
console.log('sample', imgs[0]?.slice(0,80));
})();