export/import functionality
This commit is contained in:
Generated
+1166
-13
File diff suppressed because it is too large
Load Diff
@@ -13,10 +13,14 @@
|
|||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
|
"@types/archiver": "^7.0.0",
|
||||||
|
"@types/multer": "^2.0.0",
|
||||||
"@types/socket.io": "^3.0.1",
|
"@types/socket.io": "^3.0.1",
|
||||||
|
"archiver": "^7.0.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"multer": "^2.0.2",
|
||||||
"socket.io": "^4.8.1"
|
"socket.io": "^4.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
Binary file not shown.
@@ -2,8 +2,11 @@ import express from "express";
|
|||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import fs from "fs";
|
||||||
import { createServer } from "http";
|
import { createServer } from "http";
|
||||||
import { Server } from "socket.io";
|
import { Server } from "socket.io";
|
||||||
|
import multer from "multer";
|
||||||
|
import archiver from "archiver";
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { PrismaClient } from "./generated/client";
|
import { PrismaClient } from "./generated/client";
|
||||||
|
|
||||||
@@ -26,8 +29,12 @@ const io = new Server(httpServer, {
|
|||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
const PORT = process.env.PORT || 8000;
|
const PORT = process.env.PORT || 8000;
|
||||||
|
|
||||||
|
// Multer setup for file uploads
|
||||||
|
const upload = multer({ dest: "uploads/" });
|
||||||
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json({ limit: "50mb" }));
|
app.use(express.json({ limit: "50mb" }));
|
||||||
|
app.use(express.urlencoded({ extended: true, limit: "50mb" }));
|
||||||
|
|
||||||
// Socket.io Logic
|
// Socket.io Logic
|
||||||
interface User {
|
interface User {
|
||||||
@@ -375,6 +382,189 @@ app.delete("/collections/:id", async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Export/Import Endpoints ---
|
||||||
|
|
||||||
|
// GET /export - Export SQLite database
|
||||||
|
app.get("/export", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const dbPath = path.resolve(__dirname, "../prisma/dev.db");
|
||||||
|
|
||||||
|
if (!fs.existsSync(dbPath)) {
|
||||||
|
return res.status(404).json({ error: "Database file not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.setHeader("Content-Type", "application/octet-stream");
|
||||||
|
res.setHeader(
|
||||||
|
"Content-Disposition",
|
||||||
|
`attachment; filename="excalidash-db-${
|
||||||
|
new Date().toISOString().split("T")[0]
|
||||||
|
}.sqlite"`
|
||||||
|
);
|
||||||
|
|
||||||
|
const fileStream = fs.createReadStream(dbPath);
|
||||||
|
fileStream.pipe(res);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({ error: "Failed to export database" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /export/json - Export drawings as ZIP of .excalidraw files
|
||||||
|
app.get("/export/json", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const drawings = await prisma.drawing.findMany({
|
||||||
|
include: {
|
||||||
|
collection: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.setHeader("Content-Type", "application/zip");
|
||||||
|
res.setHeader(
|
||||||
|
"Content-Disposition",
|
||||||
|
`attachment; filename="excalidraw-drawings-${
|
||||||
|
new Date().toISOString().split("T")[0]
|
||||||
|
}.zip"`
|
||||||
|
);
|
||||||
|
|
||||||
|
const archive = archiver("zip", { zlib: { level: 9 } });
|
||||||
|
|
||||||
|
archive.on("error", (err) => {
|
||||||
|
console.error("Archive error:", err);
|
||||||
|
res.status(500).json({ error: "Failed to create archive" });
|
||||||
|
});
|
||||||
|
|
||||||
|
archive.pipe(res);
|
||||||
|
|
||||||
|
// Group drawings by collection
|
||||||
|
const drawingsByCollection: { [key: string]: any[] } = {};
|
||||||
|
|
||||||
|
drawings.forEach((drawing: any) => {
|
||||||
|
const collectionName = drawing.collection?.name || "Unorganized";
|
||||||
|
if (!drawingsByCollection[collectionName]) {
|
||||||
|
drawingsByCollection[collectionName] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const drawingData = {
|
||||||
|
elements: JSON.parse(drawing.elements),
|
||||||
|
appState: JSON.parse(drawing.appState),
|
||||||
|
files: JSON.parse(drawing.files || "{}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
drawingsByCollection[collectionName].push({
|
||||||
|
name: drawing.name,
|
||||||
|
data: drawingData,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create folders and add files
|
||||||
|
Object.entries(drawingsByCollection).forEach(
|
||||||
|
([collectionName, collectionDrawings]) => {
|
||||||
|
const folderName = collectionName.replace(/[<>:"/\\|?*]/g, "_"); // Sanitize folder name
|
||||||
|
collectionDrawings.forEach((drawing, index) => {
|
||||||
|
const fileName = `${drawing.name.replace(
|
||||||
|
/[<>:"/\\|?*]/g,
|
||||||
|
"_"
|
||||||
|
)}.excalidraw`;
|
||||||
|
const filePath = `${folderName}/${fileName}`;
|
||||||
|
|
||||||
|
archive.append(JSON.stringify(drawing.data, null, 2), {
|
||||||
|
name: filePath,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add a readme file
|
||||||
|
const readmeContent = `ExcaliDash Export
|
||||||
|
|
||||||
|
This archive contains your ExcaliDash drawings organized by collection folders.
|
||||||
|
|
||||||
|
Structure:
|
||||||
|
- Each collection has its own folder
|
||||||
|
- Each drawing is saved as a .excalidraw file
|
||||||
|
- Files can be imported back into ExcaliDash
|
||||||
|
|
||||||
|
Export Date: ${new Date().toISOString()}
|
||||||
|
Total Collections: ${Object.keys(drawingsByCollection).length}
|
||||||
|
Total Drawings: ${drawings.length}
|
||||||
|
|
||||||
|
Collections:
|
||||||
|
${Object.entries(drawingsByCollection)
|
||||||
|
.map(([name, drawings]) => `- ${name}: ${drawings.length} drawings`)
|
||||||
|
.join("\n")}
|
||||||
|
`;
|
||||||
|
|
||||||
|
archive.append(readmeContent, { name: "README.txt" });
|
||||||
|
|
||||||
|
await archive.finalize();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).json({ error: "Failed to export drawings" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /import/sqlite/verify - Verify SQLite database before import
|
||||||
|
app.post("/import/sqlite/verify", upload.single("db"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({ error: "No file uploaded" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic verification: check if it's a SQLite file
|
||||||
|
const buffer = fs.readFileSync(req.file.path);
|
||||||
|
const header = buffer.slice(0, 16).toString("ascii");
|
||||||
|
|
||||||
|
if (!header.startsWith("SQLite format 3")) {
|
||||||
|
fs.unlinkSync(req.file.path);
|
||||||
|
return res.status(400).json({ error: "Invalid SQLite file" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional verification could be added here
|
||||||
|
// For now, we'll just check the file signature
|
||||||
|
|
||||||
|
fs.unlinkSync(req.file.path);
|
||||||
|
res.json({ valid: true, message: "Database file is valid" });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
if (req.file && fs.existsSync(req.file.path)) {
|
||||||
|
fs.unlinkSync(req.file.path);
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: "Failed to verify database file" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /import/sqlite - Import SQLite database
|
||||||
|
app.post("/import/sqlite", upload.single("db"), async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({ error: "No file uploaded" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbPath = path.resolve(__dirname, "../prisma/dev.db");
|
||||||
|
|
||||||
|
// Backup current database
|
||||||
|
if (fs.existsSync(dbPath)) {
|
||||||
|
const backupPath = path.resolve(__dirname, "../prisma/dev.db.backup");
|
||||||
|
fs.copyFileSync(dbPath, backupPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace database file
|
||||||
|
fs.copyFileSync(req.file.path, dbPath);
|
||||||
|
fs.unlinkSync(req.file.path);
|
||||||
|
|
||||||
|
// Reinitialize Prisma client
|
||||||
|
await prisma.$disconnect();
|
||||||
|
|
||||||
|
res.json({ success: true, message: "Database imported successfully" });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
if (req.file && fs.existsSync(req.file.path)) {
|
||||||
|
fs.unlinkSync(req.file.path);
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: "Failed to import database" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Ensure Trash collection exists
|
// Ensure Trash collection exists
|
||||||
const ensureTrashCollection = async () => {
|
const ensureTrashCollection = async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { AlertTriangle, X } from 'lucide-react';
|
import { AlertTriangle, CheckCircle, X } from 'lucide-react';
|
||||||
|
|
||||||
interface ConfirmModalProps {
|
interface ConfirmModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -12,6 +12,7 @@ interface ConfirmModalProps {
|
|||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
isDangerous?: boolean; // Makes confirm button red
|
isDangerous?: boolean; // Makes confirm button red
|
||||||
showCancel?: boolean;
|
showCancel?: boolean;
|
||||||
|
variant?: 'warning' | 'success'; // Controls icon and styling
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ConfirmModal: React.FC<ConfirmModalProps> = ({
|
export const ConfirmModal: React.FC<ConfirmModalProps> = ({
|
||||||
@@ -23,10 +24,18 @@ export const ConfirmModal: React.FC<ConfirmModalProps> = ({
|
|||||||
onConfirm,
|
onConfirm,
|
||||||
onCancel,
|
onCancel,
|
||||||
isDangerous = true,
|
isDangerous = true,
|
||||||
showCancel = true
|
showCancel = true,
|
||||||
|
variant = 'warning'
|
||||||
}) => {
|
}) => {
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
// Icon and styling based on variant
|
||||||
|
const isSuccess = variant === 'success';
|
||||||
|
const IconComponent = isSuccess ? CheckCircle : AlertTriangle;
|
||||||
|
const iconClasses = isSuccess
|
||||||
|
? "w-12 h-12 rounded-full bg-emerald-100 dark:bg-emerald-900/30 flex items-center justify-center text-emerald-600 dark:text-emerald-300 border-2 border-emerald-200 dark:border-emerald-900/30"
|
||||||
|
: "w-12 h-12 rounded-full bg-rose-100 dark:bg-rose-900/30 flex items-center justify-center text-rose-600 dark:text-rose-300 border-2 border-rose-200 dark:border-rose-900/30";
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
@@ -45,8 +54,8 @@ export const ConfirmModal: React.FC<ConfirmModalProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex flex-col items-center text-center gap-4">
|
<div className="flex flex-col items-center text-center gap-4">
|
||||||
<div className="w-12 h-12 rounded-full bg-rose-100 dark:bg-rose-900/30 flex items-center justify-center text-rose-600 dark:text-rose-300 border-2 border-rose-200 dark:border-rose-900/30">
|
<div className={iconClasses}>
|
||||||
<AlertTriangle size={24} strokeWidth={2.5} />
|
<IconComponent size={24} strokeWidth={2.5} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useEffect, useState, useCallback, useRef } from 'react';
|
|||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { Layout } from '../components/Layout';
|
import { Layout } from '../components/Layout';
|
||||||
import { DrawingCard } from '../components/DrawingCard';
|
import { DrawingCard } from '../components/DrawingCard';
|
||||||
import { Plus, Search, Loader2, Inbox, Trash2, Folder, ArrowRight, Copy } from 'lucide-react';
|
import { Plus, Search, Loader2, Inbox, Trash2, Folder, ArrowRight, Copy, Upload } from 'lucide-react';
|
||||||
import { useNavigate, useSearchParams, useLocation } from 'react-router-dom';
|
import { useNavigate, useSearchParams, useLocation } from 'react-router-dom';
|
||||||
import * as api from '../api';
|
import * as api from '../api';
|
||||||
import type { Drawing, Collection } from '../types';
|
import type { Drawing, Collection } from '../types';
|
||||||
@@ -79,6 +79,10 @@ export const Dashboard: React.FC = () => {
|
|||||||
const [drawingToDelete, setDrawingToDelete] = useState<string | null>(null);
|
const [drawingToDelete, setDrawingToDelete] = useState<string | null>(null);
|
||||||
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
|
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
|
||||||
|
|
||||||
|
// Import state
|
||||||
|
const [showImportError, setShowImportError] = useState<{ isOpen: boolean; message: string }>({ isOpen: false, message: '' });
|
||||||
|
const [showImportSuccess, setShowImportSuccess] = useState(false);
|
||||||
|
|
||||||
// Drag Selection State
|
// Drag Selection State
|
||||||
const [isDragSelecting, setIsDragSelecting] = useState(false);
|
const [isDragSelecting, setIsDragSelecting] = useState(false);
|
||||||
const [dragStart, setDragStart] = useState<Point | null>(null);
|
const [dragStart, setDragStart] = useState<Point | null>(null);
|
||||||
@@ -306,6 +310,24 @@ export const Dashboard: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleImportDrawings = async (files: FileList | null) => {
|
||||||
|
if (!files || isTrashView) return;
|
||||||
|
|
||||||
|
const fileArray = Array.from(files);
|
||||||
|
const targetCollectionId = selectedCollectionId === undefined ? null : selectedCollectionId;
|
||||||
|
|
||||||
|
const result = await importDrawings(fileArray, targetCollectionId, refreshData);
|
||||||
|
|
||||||
|
if (result.failed > 0) {
|
||||||
|
setShowImportError({
|
||||||
|
isOpen: true,
|
||||||
|
message: `Import complete with errors.\nSuccess: ${result.success}\nFailed: ${result.failed}\nErrors:\n${result.errors.join('\n')}`
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setShowImportSuccess(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleRenameDrawing = async (id: string, name: string) => {
|
const handleRenameDrawing = async (id: string, name: string) => {
|
||||||
setDrawings(prev => prev.map(d => d.id === id ? { ...d, name } : d));
|
setDrawings(prev => prev.map(d => d.id === id ? { ...d, name } : d));
|
||||||
await api.updateDrawing(id, { name });
|
await api.updateDrawing(id, { name });
|
||||||
@@ -784,6 +806,32 @@ export const Dashboard: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept=".json,.excalidraw"
|
||||||
|
className="hidden"
|
||||||
|
id="dashboard-import"
|
||||||
|
onChange={(e) => {
|
||||||
|
handleImportDrawings(e.target.files);
|
||||||
|
e.target.value = ''; // Reset input
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => document.getElementById('dashboard-import')?.click()}
|
||||||
|
disabled={isTrashView}
|
||||||
|
className={clsx(
|
||||||
|
"h-[42px] w-full sm:w-auto flex items-center justify-center gap-2 px-6 rounded-xl border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] transition-all font-bold text-sm whitespace-nowrap",
|
||||||
|
isTrashView
|
||||||
|
? "bg-slate-100 dark:bg-slate-800 text-slate-400 dark:text-slate-600 border-slate-300 dark:border-slate-700 shadow-none cursor-not-allowed"
|
||||||
|
: "bg-emerald-600 dark:bg-neutral-800 text-white hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-1 active:translate-y-0 active:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:active:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Upload size={18} strokeWidth={2.5} />
|
||||||
|
Import
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleCreateDrawing}
|
onClick={handleCreateDrawing}
|
||||||
disabled={isTrashView}
|
disabled={isTrashView}
|
||||||
@@ -911,6 +959,29 @@ export const Dashboard: React.FC = () => {
|
|||||||
onConfirm={executeBulkPermanentDelete}
|
onConfirm={executeBulkPermanentDelete}
|
||||||
onCancel={() => setShowBulkDeleteConfirm(false)}
|
onCancel={() => setShowBulkDeleteConfirm(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={showImportError.isOpen}
|
||||||
|
title="Import Failed"
|
||||||
|
message={showImportError.message}
|
||||||
|
confirmText="OK"
|
||||||
|
showCancel={false}
|
||||||
|
isDangerous={false}
|
||||||
|
onConfirm={() => setShowImportError({ isOpen: false, message: '' })}
|
||||||
|
onCancel={() => setShowImportError({ isOpen: false, message: '' })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={showImportSuccess}
|
||||||
|
title="Import Successful"
|
||||||
|
message="Drawings imported successfully."
|
||||||
|
confirmText="OK"
|
||||||
|
showCancel={false}
|
||||||
|
isDangerous={false}
|
||||||
|
variant="success"
|
||||||
|
onConfirm={() => setShowImportSuccess(false)}
|
||||||
|
onCancel={() => setShowImportSuccess(false)}
|
||||||
|
/>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -259,6 +259,7 @@ export const Settings: React.FC = () => {
|
|||||||
confirmText="OK"
|
confirmText="OK"
|
||||||
showCancel={false}
|
showCancel={false}
|
||||||
isDangerous={false}
|
isDangerous={false}
|
||||||
|
variant="success"
|
||||||
onConfirm={() => setImportSuccess(false)}
|
onConfirm={() => setImportSuccess(false)}
|
||||||
onCancel={() => setImportSuccess(false)}
|
onCancel={() => setImportSuccess(false)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user