feat: prisma schema, groups API, deploy workflow
This commit is contained in:
Generated
-5453
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "nanaxe-api-server",
|
||||
"name": "xflow-api-server",
|
||||
"version": "1.0.0",
|
||||
"description": "API server for Nanaxe CRM mindmap synchronization",
|
||||
"description": "API server for XFlow CRM",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
@@ -31,4 +31,4 @@
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,6 @@ const morgan = require('morgan');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
require('dotenv').config();
|
||||
|
||||
const mindmapRoutes = require('./routes/mindmap');
|
||||
const syncRoutes = require('./routes/sync');
|
||||
const authRoutes = require('./routes/auth');
|
||||
const { errorHandler } = require('./middleware/errorHandler');
|
||||
|
||||
@@ -37,8 +35,8 @@ app.use(morgan('combined'));
|
||||
|
||||
// Health check
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
version: '1.0.0'
|
||||
});
|
||||
@@ -46,8 +44,6 @@ app.get('/health', (req, res) => {
|
||||
|
||||
// API Routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/mindmaps', mindmapRoutes);
|
||||
app.use('/api/sync', syncRoutes);
|
||||
|
||||
// Error handling
|
||||
app.use(errorHandler);
|
||||
@@ -58,7 +54,5 @@ app.use((req, res) => {
|
||||
});
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`🚀 Nanaxe API Server running on port ${PORT}`);
|
||||
console.log(`📁 Mindmap storage: ${process.env.MINDMAP_STORAGE_PATH || '/app/mindmaps'}`);
|
||||
console.log(`🔗 Gitea URL: ${process.env.GITEA_URL || 'http://gitea:3000'}`);
|
||||
console.log(`🚀 XFlow API Server running on port ${PORT}`);
|
||||
});
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const fs = require('fs-extra');
|
||||
const path = require('path');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
const MarkdownConverter = require('../utils/markdownConverter');
|
||||
|
||||
const STORAGE_PATH = process.env.MINDMAP_STORAGE_PATH || '/app/mindmaps';
|
||||
|
||||
// Ensure storage directory exists
|
||||
fs.ensureDirSync(STORAGE_PATH);
|
||||
|
||||
/**
|
||||
* GET /api/mindmaps - List all mindmaps
|
||||
*/
|
||||
router.get('/', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const userDir = path.join(STORAGE_PATH, userId);
|
||||
|
||||
await fs.ensureDir(userDir);
|
||||
|
||||
const files = await fs.readdir(userDir);
|
||||
const mindmaps = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.md')) {
|
||||
const filePath = path.join(userDir, file);
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const mindmap = MarkdownConverter.fromMarkdown(content);
|
||||
|
||||
mindmaps.push({
|
||||
id: mindmap.id,
|
||||
title: mindmap.title,
|
||||
description: mindmap.description,
|
||||
nodeCount: mindmap.nodes.length,
|
||||
updatedAt: mindmap.updatedAt,
|
||||
filename: file
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by updatedAt desc
|
||||
mindmaps.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
|
||||
|
||||
res.json({
|
||||
count: mindmaps.length,
|
||||
mindmaps
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error listing mindmaps:', error);
|
||||
res.status(500).json({ error: 'Failed to list mindmaps' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/mindmaps/:id - Get single mindmap
|
||||
*/
|
||||
router.get('/:id', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userId = req.user.id;
|
||||
const filePath = path.join(STORAGE_PATH, userId, `${id}.md`);
|
||||
|
||||
if (!await fs.pathExists(filePath)) {
|
||||
return res.status(404).json({ error: 'Mindmap not found' });
|
||||
}
|
||||
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const mindmap = MarkdownConverter.fromMarkdown(content, id);
|
||||
|
||||
res.json(mindmap);
|
||||
} catch (error) {
|
||||
console.error('Error getting mindmap:', error);
|
||||
res.status(500).json({ error: 'Failed to get mindmap' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/mindmaps - Create new mindmap
|
||||
*/
|
||||
router.post('/', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const { title, description, nodes = [], settings = {} } = req.body;
|
||||
|
||||
if (!title) {
|
||||
return res.status(400).json({ error: 'Title is required' });
|
||||
}
|
||||
|
||||
const id = uuidv4();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const mindmap = {
|
||||
id,
|
||||
title,
|
||||
description: description || '',
|
||||
nodes: nodes.length > 0 ? nodes : [{
|
||||
id: uuidv4(),
|
||||
title: 'Root',
|
||||
level: 0,
|
||||
parentId: null,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
}],
|
||||
settings,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
};
|
||||
|
||||
const userDir = path.join(STORAGE_PATH, userId);
|
||||
await fs.ensureDir(userDir);
|
||||
|
||||
const markdown = MarkdownConverter.toMarkdown(mindmap);
|
||||
const filePath = path.join(userDir, `${id}.md`);
|
||||
|
||||
await fs.writeFile(filePath, markdown, 'utf-8');
|
||||
|
||||
res.status(201).json(mindmap);
|
||||
} catch (error) {
|
||||
console.error('Error creating mindmap:', error);
|
||||
res.status(500).json({ error: 'Failed to create mindmap' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/mindmaps/:id - Update mindmap
|
||||
*/
|
||||
router.put('/:id', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userId = req.user.id;
|
||||
const updates = req.body;
|
||||
|
||||
const filePath = path.join(STORAGE_PATH, userId, `${id}.md`);
|
||||
|
||||
if (!await fs.pathExists(filePath)) {
|
||||
return res.status(404).json({ error: 'Mindmap not found' });
|
||||
}
|
||||
|
||||
// Read existing
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const existing = MarkdownConverter.fromMarkdown(content, id);
|
||||
|
||||
// Merge updates
|
||||
const updated = {
|
||||
...existing,
|
||||
...updates,
|
||||
id, // Preserve ID
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Save
|
||||
const markdown = MarkdownConverter.toMarkdown(updated);
|
||||
await fs.writeFile(filePath, markdown, 'utf-8');
|
||||
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
console.error('Error updating mindmap:', error);
|
||||
res.status(500).json({ error: 'Failed to update mindmap' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/mindmaps/:id - Delete mindmap
|
||||
*/
|
||||
router.delete('/:id', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userId = req.user.id;
|
||||
const filePath = path.join(STORAGE_PATH, userId, `${id}.md`);
|
||||
|
||||
if (!await fs.pathExists(filePath)) {
|
||||
return res.status(404).json({ error: 'Mindmap not found' });
|
||||
}
|
||||
|
||||
await fs.remove(filePath);
|
||||
|
||||
res.json({ message: 'Mindmap deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting mindmap:', error);
|
||||
res.status(500).json({ error: 'Failed to delete mindmap' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/mindmaps/:id/markdown - Get mindmap as raw markdown
|
||||
*/
|
||||
router.get('/:id/markdown', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userId = req.user.id;
|
||||
const filePath = path.join(STORAGE_PATH, userId, `${id}.md`);
|
||||
|
||||
if (!await fs.pathExists(filePath)) {
|
||||
return res.status(404).json({ error: 'Mindmap not found' });
|
||||
}
|
||||
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
|
||||
res.setHeader('Content-Type', 'text/markdown');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${id}.md"`);
|
||||
res.send(content);
|
||||
} catch (error) {
|
||||
console.error('Error getting markdown:', error);
|
||||
res.status(500).json({ error: 'Failed to get markdown' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/mindmaps/:id/markdown - Update mindmap from markdown
|
||||
*/
|
||||
router.post('/:id/markdown', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userId = req.user.id;
|
||||
const { markdown } = req.body;
|
||||
|
||||
if (!markdown) {
|
||||
return res.status(400).json({ error: 'Markdown content is required' });
|
||||
}
|
||||
|
||||
const userDir = path.join(STORAGE_PATH, userId);
|
||||
await fs.ensureDir(userDir);
|
||||
|
||||
const filePath = path.join(userDir, `${id}.md`);
|
||||
|
||||
await fs.writeFile(filePath, markdown, 'utf-8');
|
||||
|
||||
// Parse and return
|
||||
const mindmap = MarkdownConverter.fromMarkdown(markdown, id);
|
||||
|
||||
res.json(mindmap);
|
||||
} catch (error) {
|
||||
console.error('Error saving markdown:', error);
|
||||
res.status(500).json({ error: 'Failed to save markdown' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/mindmaps/:id/markmap - Get mindmap in Markmap format
|
||||
*/
|
||||
router.get('/:id/markmap', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userId = req.user.id;
|
||||
const filePath = path.join(STORAGE_PATH, userId, `${id}.md`);
|
||||
|
||||
if (!await fs.pathExists(filePath)) {
|
||||
return res.status(404).json({ error: 'Mindmap not found' });
|
||||
}
|
||||
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const mindmap = MarkdownConverter.fromMarkdown(content, id);
|
||||
const markmap = MarkdownConverter.toMarkmap(mindmap);
|
||||
|
||||
res.setHeader('Content-Type', 'text/markdown');
|
||||
res.send(markmap);
|
||||
} catch (error) {
|
||||
console.error('Error getting markmap:', error);
|
||||
res.status(500).json({ error: 'Failed to get markmap' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,268 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const fs = require('fs-extra');
|
||||
const path = require('path');
|
||||
const simpleGit = require('simple-git');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
const MarkdownConverter = require('../utils/markdownConverter');
|
||||
|
||||
const STORAGE_PATH = process.env.MINDMAP_STORAGE_PATH || '/app/mindmaps';
|
||||
const GITEA_URL = process.env.GITEA_URL || 'http://gitea:3000';
|
||||
const GITEA_TOKEN = process.env.GITEA_TOKEN;
|
||||
|
||||
/**
|
||||
* POST /api/sync/gitea/push - Push mindmaps to Gitea repository
|
||||
*/
|
||||
router.post('/gitea/push', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const { repoName, commitMessage } = req.body;
|
||||
|
||||
const userDir = path.join(STORAGE_PATH, userId);
|
||||
const repoDir = path.join(userDir, '.git-repo');
|
||||
|
||||
if (!await fs.pathExists(userDir)) {
|
||||
return res.status(404).json({ error: 'No mindmaps found' });
|
||||
}
|
||||
|
||||
// Initialize or open git repo
|
||||
const git = simpleGit(repoDir);
|
||||
|
||||
if (!await fs.pathExists(path.join(repoDir, '.git'))) {
|
||||
await fs.ensureDir(repoDir);
|
||||
await git.init();
|
||||
|
||||
// Configure git
|
||||
await git.addConfig('user.email', req.user.email || 'user@nanaxe.app');
|
||||
await git.addConfig('user.name', req.user.username || 'Nanaxe User');
|
||||
}
|
||||
|
||||
// Copy all mindmaps to repo
|
||||
const files = await fs.readdir(userDir);
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.md')) {
|
||||
await fs.copy(
|
||||
path.join(userDir, file),
|
||||
path.join(repoDir, file)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Commit changes
|
||||
await git.add('.');
|
||||
const status = await git.status();
|
||||
|
||||
if (status.files.length === 0) {
|
||||
return res.json({ message: 'No changes to commit', synced: false });
|
||||
}
|
||||
|
||||
await git.commit(commitMessage || 'Sync mindmaps from Nanaxe CRM');
|
||||
|
||||
// Push to Gitea if configured
|
||||
if (GITEA_TOKEN) {
|
||||
const remoteUrl = `${GITEA_URL}/api/v1/repos/${req.user.username}/${repoName || 'mindmaps'}`;
|
||||
|
||||
try {
|
||||
// Check if remote exists
|
||||
const remotes = await git.getRemotes(true);
|
||||
const originExists = remotes.some(r => r.name === 'origin');
|
||||
|
||||
if (!originExists) {
|
||||
await git.addRemote('origin', remoteUrl);
|
||||
}
|
||||
|
||||
await git.push('origin', 'master');
|
||||
} catch (pushError) {
|
||||
console.error('Push error:', pushError);
|
||||
// Continue even if push fails - local commit is successful
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: 'Mindmaps synced successfully',
|
||||
synced: true,
|
||||
filesCommitted: status.files.length,
|
||||
commitMessage: commitMessage || 'Sync mindmaps from Nanaxe CRM'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error syncing to Gitea:', error);
|
||||
res.status(500).json({ error: 'Failed to sync mindmaps' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/sync/gitea/pull - Pull mindmaps from Gitea repository
|
||||
*/
|
||||
router.post('/gitea/pull', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const { repoName } = req.body;
|
||||
|
||||
const userDir = path.join(STORAGE_PATH, userId);
|
||||
const repoDir = path.join(userDir, '.git-repo');
|
||||
|
||||
if (!GITEA_TOKEN) {
|
||||
return res.status(400).json({ error: 'Gitea not configured' });
|
||||
}
|
||||
|
||||
const git = simpleGit(repoDir);
|
||||
|
||||
if (!await fs.pathExists(path.join(repoDir, '.git'))) {
|
||||
await fs.ensureDir(repoDir);
|
||||
|
||||
// Clone from Gitea
|
||||
const cloneUrl = `${GITEA_URL}/api/v1/repos/${req.user.username}/${repoName || 'mindmaps'}`;
|
||||
await git.clone(cloneUrl, repoDir);
|
||||
} else {
|
||||
// Pull latest
|
||||
await git.pull('origin', 'master');
|
||||
}
|
||||
|
||||
// Copy files from repo to storage
|
||||
const files = await fs.readdir(repoDir);
|
||||
let importedCount = 0;
|
||||
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.md') && !file.startsWith('.')) {
|
||||
await fs.copy(
|
||||
path.join(repoDir, file),
|
||||
path.join(userDir, file),
|
||||
{ overwrite: true }
|
||||
);
|
||||
importedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: 'Mindmaps pulled successfully',
|
||||
imported: importedCount
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error pulling from Gitea:', error);
|
||||
res.status(500).json({ error: 'Failed to pull mindmaps' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/sync/status - Get sync status
|
||||
*/
|
||||
router.get('/status', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const userDir = path.join(STORAGE_PATH, userId);
|
||||
const repoDir = path.join(userDir, '.git-repo');
|
||||
|
||||
const status = {
|
||||
hasLocalData: await fs.pathExists(userDir),
|
||||
hasGitRepo: await fs.pathExists(path.join(repoDir, '.git')),
|
||||
giteaConfigured: !!GITEA_TOKEN,
|
||||
lastSync: null,
|
||||
pendingChanges: 0
|
||||
};
|
||||
|
||||
if (status.hasGitRepo) {
|
||||
const git = simpleGit(repoDir);
|
||||
const gitStatus = await git.status();
|
||||
status.pendingChanges = gitStatus.files.length;
|
||||
|
||||
// Get last commit date
|
||||
try {
|
||||
const log = await git.log({ maxCount: 1 });
|
||||
if (log.latest) {
|
||||
status.lastSync = log.latest.date;
|
||||
}
|
||||
} catch (e) {
|
||||
// No commits yet
|
||||
}
|
||||
}
|
||||
|
||||
res.json(status);
|
||||
} catch (error) {
|
||||
console.error('Error getting sync status:', error);
|
||||
res.status(500).json({ error: 'Failed to get sync status' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/sync/export - Export all mindmaps as zip
|
||||
*/
|
||||
router.post('/export', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const userDir = path.join(STORAGE_PATH, userId);
|
||||
|
||||
if (!await fs.pathExists(userDir)) {
|
||||
return res.status(404).json({ error: 'No mindmaps found' });
|
||||
}
|
||||
|
||||
// Create temporary zip
|
||||
const archiver = require('archiver');
|
||||
const archive = archiver('zip', { zlib: { level: 9 } });
|
||||
|
||||
res.setHeader('Content-Type', 'application/zip');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="nanaxe-mindmaps.zip"');
|
||||
|
||||
archive.pipe(res);
|
||||
|
||||
const files = await fs.readdir(userDir);
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.md')) {
|
||||
archive.file(path.join(userDir, file), { name: file });
|
||||
}
|
||||
}
|
||||
|
||||
await archive.finalize();
|
||||
} catch (error) {
|
||||
console.error('Error exporting mindmaps:', error);
|
||||
res.status(500).json({ error: 'Failed to export mindmaps' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/sync/import - Import mindmaps from files
|
||||
*/
|
||||
router.post('/import', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const { files } = req.body; // Array of { filename, content }
|
||||
|
||||
if (!files || !Array.isArray(files)) {
|
||||
return res.status(400).json({ error: 'Files array required' });
|
||||
}
|
||||
|
||||
const userDir = path.join(STORAGE_PATH, userId);
|
||||
await fs.ensureDir(userDir);
|
||||
|
||||
const results = {
|
||||
imported: 0,
|
||||
errors: []
|
||||
};
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
if (!file.filename.endsWith('.md')) {
|
||||
results.errors.push({ file: file.filename, error: 'Not a markdown file' });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate markdown
|
||||
const mindmap = MarkdownConverter.fromMarkdown(file.content);
|
||||
|
||||
// Save
|
||||
const filePath = path.join(userDir, file.filename);
|
||||
await fs.writeFile(filePath, file.content, 'utf-8');
|
||||
|
||||
results.imported++;
|
||||
} catch (error) {
|
||||
results.errors.push({ file: file.filename, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
res.json(results);
|
||||
} catch (error) {
|
||||
console.error('Error importing mindmaps:', error);
|
||||
res.status(500).json({ error: 'Failed to import mindmaps' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,261 +0,0 @@
|
||||
/**
|
||||
* Конвертер между MindMap форматом и Markdown (Obsidian-совместимый)
|
||||
*
|
||||
* Obsidian MindMap формат:
|
||||
* - Заголовки (# ## ###) = уровни узлов
|
||||
* - Списки (- *) = дочерние узлы
|
||||
* - [[ссылки]] = внутренние связи
|
||||
* - Теги #тег = метки узлов
|
||||
*/
|
||||
|
||||
const YAML = require('yaml');
|
||||
|
||||
class MarkdownConverter {
|
||||
/**
|
||||
* Конвертирует MindMap из JSON в Markdown
|
||||
* @param {Object} mindmap - Объект mindmap
|
||||
* @returns {string} Markdown строка
|
||||
*/
|
||||
static toMarkdown(mindmap) {
|
||||
const { id, title, description, nodes, settings = {} } = mindmap;
|
||||
|
||||
// Frontmatter с метаданными
|
||||
const frontmatter = {
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
createdAt: mindmap.createdAt,
|
||||
updatedAt: mindmap.updatedAt,
|
||||
...settings
|
||||
};
|
||||
|
||||
let markdown = '---\n';
|
||||
markdown += YAML.stringify(frontmatter);
|
||||
markdown += '---\n\n';
|
||||
|
||||
// Заголовок mindmap
|
||||
markdown += `# ${title}\n\n`;
|
||||
|
||||
if (description) {
|
||||
markdown += `> ${description}\n\n`;
|
||||
}
|
||||
|
||||
// Конвертируем узлы в иерархию
|
||||
if (nodes && nodes.length > 0) {
|
||||
const rootNodes = nodes.filter(n => n.level === 0 || !n.parentId);
|
||||
|
||||
for (const root of rootNodes) {
|
||||
markdown += this._nodeToMarkdown(nodes, root, 0);
|
||||
}
|
||||
}
|
||||
|
||||
return markdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Рекурсивно конвертирует узел в Markdown
|
||||
*/
|
||||
static _nodeToMarkdown(allNodes, node, level) {
|
||||
const indent = ' '.repeat(level);
|
||||
let markdown = '';
|
||||
|
||||
// Заголовок или список в зависимости от уровня
|
||||
if (level === 0) {
|
||||
markdown += `## ${node.title}\n`;
|
||||
} else if (level === 1) {
|
||||
markdown += `### ${node.title}\n`;
|
||||
} else if (level === 2) {
|
||||
markdown += `#### ${node.title}\n`;
|
||||
} else {
|
||||
markdown += `${indent}- ${node.title}\n`;
|
||||
}
|
||||
|
||||
// Добавляем метаданные узла
|
||||
if (node.taskId) {
|
||||
markdown += `${indent} - Task: [[${node.taskId}]]\n`;
|
||||
}
|
||||
if (node.color) {
|
||||
markdown += `${indent} - Color: \`${node.color}\`\n`;
|
||||
}
|
||||
|
||||
// Находим дочерние узлы
|
||||
const children = allNodes.filter(n => n.parentId === node.id);
|
||||
|
||||
for (const child of children) {
|
||||
markdown += this._nodeToMarkdown(allNodes, child, level + 1);
|
||||
}
|
||||
|
||||
markdown += '\n';
|
||||
return markdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсит Markdown в MindMap JSON
|
||||
* @param {string} markdown - Markdown строка
|
||||
* @returns {Object} Объект mindmap
|
||||
*/
|
||||
static fromMarkdown(markdown, existingId = null) {
|
||||
const lines = markdown.split('\n');
|
||||
|
||||
// Парсим frontmatter
|
||||
let frontmatter = {};
|
||||
let contentStart = 0;
|
||||
|
||||
if (lines[0] === '---') {
|
||||
const endIndex = lines.indexOf('---', 1);
|
||||
if (endIndex !== -1) {
|
||||
const yamlContent = lines.slice(1, endIndex).join('\n');
|
||||
frontmatter = YAML.parse(yamlContent) || {};
|
||||
contentStart = endIndex + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Парсим контент
|
||||
const content = lines.slice(contentStart).join('\n');
|
||||
const nodes = this._parseNodes(content);
|
||||
|
||||
return {
|
||||
id: existingId || frontmatter.id || this._generateId(),
|
||||
title: frontmatter.title || this._extractTitle(content) || 'Untitled MindMap',
|
||||
description: frontmatter.description || '',
|
||||
nodes,
|
||||
settings: frontmatter.settings || {},
|
||||
createdAt: frontmatter.createdAt || new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсит узлы из Markdown контента
|
||||
*/
|
||||
static _parseNodes(content) {
|
||||
const lines = content.split('\n');
|
||||
const nodes = [];
|
||||
const nodeStack = []; // Стек для отслеживания родительских узлов
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
// Определяем уровень и тип
|
||||
let level = 0;
|
||||
let title = '';
|
||||
let parentId = null;
|
||||
|
||||
// Заголовки H2-H4
|
||||
const headingMatch = trimmed.match(/^(#{2,4})\s+(.+)$/);
|
||||
if (headingMatch) {
|
||||
level = headingMatch[1].length - 1; // ## = 1, ### = 2, #### = 3
|
||||
title = headingMatch[2].trim();
|
||||
}
|
||||
// Списки
|
||||
else if (trimmed.match(/^[-*]\s+/)) {
|
||||
const indent = line.search(/\S/);
|
||||
level = Math.floor(indent / 2) + 3;
|
||||
title = trimmed.replace(/^[-*]\s+/, '');
|
||||
}
|
||||
// Пропускаем H1 (это заголовок карты)
|
||||
else if (trimmed.startsWith('# ')) {
|
||||
continue;
|
||||
}
|
||||
else {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Находим родителя
|
||||
while (nodeStack.length > 0 && nodeStack[nodeStack.length - 1].level >= level) {
|
||||
nodeStack.pop();
|
||||
}
|
||||
|
||||
if (nodeStack.length > 0) {
|
||||
parentId = nodeStack[nodeStack.length - 1].id;
|
||||
}
|
||||
|
||||
const node = {
|
||||
id: this._generateId(),
|
||||
title,
|
||||
level,
|
||||
parentId,
|
||||
color: this._extractColor(trimmed),
|
||||
taskId: this._extractTaskId(trimmed),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
nodes.push(node);
|
||||
nodeStack.push({ id: node.id, level });
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает цвет из строки
|
||||
*/
|
||||
static _extractColor(line) {
|
||||
const colorMatch = line.match(/`#([0-9A-Fa-f]{6})`/);
|
||||
return colorMatch ? `#${colorMatch[1]}` : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает ID задачи из ссылки
|
||||
*/
|
||||
static _extractTaskId(line) {
|
||||
const taskMatch = line.match(/\[\[([^\]]+)\]\]/);
|
||||
return taskMatch ? taskMatch[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает заголовок из контента
|
||||
*/
|
||||
static _extractTitle(content) {
|
||||
const match = content.match(/^#\s+(.+)$/m);
|
||||
return match ? match[1].trim() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Генерирует уникальный ID
|
||||
*/
|
||||
static _generateId() {
|
||||
return 'mm_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
||||
}
|
||||
|
||||
/**
|
||||
* Конвертирует MindMap в формат Markmap (для визуализации)
|
||||
* @param {Object} mindmap - Объект mindmap
|
||||
* @returns {string} Markmap Markdown
|
||||
*/
|
||||
static toMarkmap(mindmap) {
|
||||
const { title, nodes } = mindmap;
|
||||
|
||||
let markdown = `# ${title}\n\n`;
|
||||
|
||||
if (nodes && nodes.length > 0) {
|
||||
const rootNodes = nodes.filter(n => n.level === 0 || !n.parentId);
|
||||
|
||||
for (const root of rootNodes) {
|
||||
markdown += this._nodeToMarkmap(nodes, root, 0);
|
||||
}
|
||||
}
|
||||
|
||||
return markdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Рекурсивно конвертирует узел в Markmap формат
|
||||
*/
|
||||
static _nodeToMarkmap(allNodes, node, level) {
|
||||
const indent = ' '.repeat(level);
|
||||
let markdown = `${indent}- ${node.title}\n`;
|
||||
|
||||
const children = allNodes.filter(n => n.parentId === node.id);
|
||||
|
||||
for (const child of children) {
|
||||
markdown += this._nodeToMarkmap(allNodes, child, level + 1);
|
||||
}
|
||||
|
||||
return markdown;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MarkdownConverter;
|
||||
Reference in New Issue
Block a user