diff --git a/design/Preview.png b/design/Preview.png new file mode 100644 index 00000000..5a029049 Binary files /dev/null and b/design/Preview.png differ diff --git a/design/分子结构与反应活性_基础.mmap b/design/分子结构与反应活性_基础.mmap new file mode 100644 index 00000000..6836d76e Binary files /dev/null and b/design/分子结构与反应活性_基础.mmap differ diff --git a/design/导图1.mmap b/design/导图1.mmap new file mode 100644 index 00000000..4246288e Binary files /dev/null and b/design/导图1.mmap differ diff --git a/wolai-frontend/package.json b/wolai-frontend/package.json index 190464cc..f825b93e 100644 --- a/wolai-frontend/package.json +++ b/wolai-frontend/package.json @@ -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", diff --git a/wolai-frontend/pnpm-lock.yaml b/wolai-frontend/pnpm-lock.yaml index d0c63675..8dc0500e 100644 --- a/wolai-frontend/pnpm-lock.yaml +++ b/wolai-frontend/pnpm-lock.yaml @@ -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 diff --git a/wolai-frontend/src/app/api/mindmap/import/route.ts b/wolai-frontend/src/app/api/mindmap/import/route.ts new file mode 100644 index 00000000..cdc1f00d --- /dev/null +++ b/wolai-frontend/src/app/api/mindmap/import/route.ts @@ -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 = (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) => { + 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) && + typeof (input as Record)['#text'] === 'string' + ) { + return ((input as Record)['#text'] as string).trim(); + } + return null; +}; + +const convertMmapTopic = ( + raw: any, + uriDataMap?: Map +): 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 => { + 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(); + 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(); + 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(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 } + ); + } +} diff --git a/wolai-frontend/src/app/lab/mindmap/mindmap-lab-client.tsx b/wolai-frontend/src/app/lab/mindmap/mindmap-lab-client.tsx index 69e4af58..95b20dd9 100644 --- a/wolai-frontend/src/app/lab/mindmap/mindmap-lab-client.tsx +++ b/wolai-frontend/src/app/lab/mindmap/mindmap-lab-client.tsx @@ -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) @@ -2157,7 +2185,7 @@ export function MindmapLabClient({ diff --git a/wolai-frontend/temp-import-check.js b/wolai-frontend/temp-import-check.js new file mode 100644 index 00000000..f8e5e7e9 --- /dev/null +++ b/wolai-frontend/temp-import-check.js @@ -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)); +})();