add export features
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@ backend/uploads/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
frontend-vue/dist
|
frontend-vue/dist
|
||||||
|
.npm/**
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import * as ChannelRoutes from "./routes/channel";
|
|||||||
import * as FileRoutes from "./routes/file";
|
import * as FileRoutes from "./routes/file";
|
||||||
import * as MessageRoutes from "./routes/message";
|
import * as MessageRoutes from "./routes/message";
|
||||||
import * as SearchRoutes from "./routes/search";
|
import * as SearchRoutes from "./routes/search";
|
||||||
|
import * as BackupRoutes from "./routes/backup";
|
||||||
import { authenticate } from "./middleware/auth";
|
import { authenticate } from "./middleware/auth";
|
||||||
import { initializeDB } from "./db";
|
import { initializeDB } from "./db";
|
||||||
import { FRONTEND_DIR, UPLOAD_DIR } from "./config";
|
import { FRONTEND_DIR, UPLOAD_DIR } from "./config";
|
||||||
@@ -20,6 +21,7 @@ app.use("/channels", ChannelRoutes.router);
|
|||||||
app.use("/channels/:channelId/messages", MessageRoutes.router);
|
app.use("/channels/:channelId/messages", MessageRoutes.router);
|
||||||
app.use("/channels/:channelId/messages/:messageId/files", FileRoutes.router);
|
app.use("/channels/:channelId/messages/:messageId/files", FileRoutes.router);
|
||||||
app.use("/search", SearchRoutes.router);
|
app.use("/search", SearchRoutes.router);
|
||||||
|
app.use("/backup", BackupRoutes.router);
|
||||||
|
|
||||||
app.get('/check-token', authenticate, (req, res) => {
|
app.get('/check-token', authenticate, (req, res) => {
|
||||||
res.json({ message: 'Token is valid' });
|
res.json({ message: 'Token is valid' });
|
||||||
|
|||||||
162
backend/src/routes/backup.ts
Normal file
162
backend/src/routes/backup.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { Router, type Request, type Response } from 'express';
|
||||||
|
import { authenticate } from '../middleware/auth';
|
||||||
|
import { db, FTS5Enabled } from '../db';
|
||||||
|
import { DB_PATH } from '../config';
|
||||||
|
import { logger } from '../globals';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import multer from 'multer';
|
||||||
|
import { unlink } from 'fs/promises';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
export const router = Router();
|
||||||
|
|
||||||
|
const upload = multer({ dest: tmpdir() });
|
||||||
|
|
||||||
|
// GET /backup - Download the entire database as a .db file
|
||||||
|
router.get('/', authenticate, async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
logger.info('Creating database backup...');
|
||||||
|
|
||||||
|
// Use better-sqlite3's backup API to create a safe copy
|
||||||
|
const backupPath = join(tmpdir(), `notebrook-backup-${Date.now()}.db`);
|
||||||
|
await db.backup(backupPath);
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString().split('T')[0];
|
||||||
|
const filename = `notebrook-backup-${timestamp}.db`;
|
||||||
|
|
||||||
|
res.download(backupPath, filename, async (err) => {
|
||||||
|
// Clean up temp file after download
|
||||||
|
try {
|
||||||
|
await unlink(backupPath);
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn(`Failed to clean up backup temp file: ${e}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
logger.critical(`Backup download error: ${err}`);
|
||||||
|
} else {
|
||||||
|
logger.info('Backup download completed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.critical(`Backup failed: ${error}`);
|
||||||
|
res.status(500).json({ error: 'Failed to create backup' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /restore - Upload a .db file and restore the database
|
||||||
|
router.post('/', authenticate, upload.single('database'), async (req: Request, res: Response) => {
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({ error: 'No database file provided' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadedPath = req.file.path;
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info(`Restoring database from uploaded file: ${uploadedPath}`);
|
||||||
|
|
||||||
|
// Open the uploaded database to validate and read data
|
||||||
|
const uploadedDb = new Database(uploadedPath, { readonly: true });
|
||||||
|
|
||||||
|
// Validate that it has the expected tables
|
||||||
|
const tables = uploadedDb.prepare(`SELECT name FROM sqlite_master WHERE type='table'`).all() as { name: string }[];
|
||||||
|
const tableNames = tables.map(t => t.name);
|
||||||
|
|
||||||
|
if (!tableNames.includes('channels') || !tableNames.includes('messages')) {
|
||||||
|
uploadedDb.close();
|
||||||
|
await unlink(uploadedPath);
|
||||||
|
return res.status(400).json({ error: 'Invalid backup file: missing required tables' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read all data from uploaded database
|
||||||
|
const channels = uploadedDb.prepare('SELECT * FROM channels').all();
|
||||||
|
const messages = uploadedDb.prepare('SELECT * FROM messages').all();
|
||||||
|
const files = tableNames.includes('files')
|
||||||
|
? uploadedDb.prepare('SELECT * FROM files').all()
|
||||||
|
: [];
|
||||||
|
const meta = tableNames.includes('meta')
|
||||||
|
? uploadedDb.prepare('SELECT * FROM meta').all()
|
||||||
|
: [];
|
||||||
|
|
||||||
|
uploadedDb.close();
|
||||||
|
|
||||||
|
// Begin transaction to restore data
|
||||||
|
const transaction = db.transaction(() => {
|
||||||
|
// Clear existing data (order matters due to foreign keys)
|
||||||
|
if (FTS5Enabled) {
|
||||||
|
db.exec('DELETE FROM messages_fts');
|
||||||
|
}
|
||||||
|
db.exec('DELETE FROM messages');
|
||||||
|
db.exec('DELETE FROM files');
|
||||||
|
db.exec('DELETE FROM channels');
|
||||||
|
|
||||||
|
// Reset auto-increment counters
|
||||||
|
db.exec(`DELETE FROM sqlite_sequence WHERE name IN ('channels', 'messages', 'files')`);
|
||||||
|
|
||||||
|
// Insert channels
|
||||||
|
if (channels.length > 0) {
|
||||||
|
const insertChannel = db.prepare(`
|
||||||
|
INSERT INTO channels (id, name, created_at) VALUES (@id, @name, @created_at)
|
||||||
|
`);
|
||||||
|
for (const channel of channels) {
|
||||||
|
insertChannel.run(channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert files first (messages reference files)
|
||||||
|
if (files.length > 0) {
|
||||||
|
const insertFile = db.prepare(`
|
||||||
|
INSERT INTO files (id, channel_id, file_path, file_type, file_size, original_name, created_at)
|
||||||
|
VALUES (@id, @channel_id, @file_path, @file_type, @file_size, @original_name, @created_at)
|
||||||
|
`);
|
||||||
|
for (const file of files) {
|
||||||
|
insertFile.run(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert messages
|
||||||
|
if (messages.length > 0) {
|
||||||
|
const insertMessage = db.prepare(`
|
||||||
|
INSERT INTO messages (id, channel_id, content, file_id, checked, created_at)
|
||||||
|
VALUES (@id, @channel_id, @content, @file_id, @checked, @created_at)
|
||||||
|
`);
|
||||||
|
for (const message of messages) {
|
||||||
|
insertMessage.run(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild FTS index
|
||||||
|
if (FTS5Enabled) {
|
||||||
|
db.exec(`INSERT INTO messages_fts(messages_fts) VALUES('rebuild')`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
transaction();
|
||||||
|
|
||||||
|
// Clean up uploaded file
|
||||||
|
await unlink(uploadedPath);
|
||||||
|
|
||||||
|
logger.info('Database restore completed successfully');
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Database restored successfully',
|
||||||
|
stats: {
|
||||||
|
channels: channels.length,
|
||||||
|
messages: messages.length,
|
||||||
|
files: files.length
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.critical(`Restore failed: ${error}`);
|
||||||
|
|
||||||
|
// Clean up uploaded file on error
|
||||||
|
try {
|
||||||
|
await unlink(uploadedPath);
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(500).json({ error: 'Failed to restore database: ' + (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -43,10 +43,9 @@ const getOrCreateCertificate = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const createSelfSignedSSLCert = async () => {
|
const createSelfSignedSSLCert = async () => {
|
||||||
const selfsigned = await import('selfsigned');
|
const pems = await selfSigned.generate([{ name: 'commonName', value: 'localhost' }], {
|
||||||
const pems = selfsigned.generate([{ name: 'Notebrook Self Signed Auto Generated Key', value: 'localhost' }], {
|
|
||||||
keySize: 2048,
|
keySize: 2048,
|
||||||
days: 365
|
algorithm: 'sha256'
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
key: pems.private,
|
key: pems.private,
|
||||||
|
|||||||
111
frontend-vue/package-lock.json
generated
111
frontend-vue/package-lock.json
generated
@@ -8,9 +8,11 @@
|
|||||||
"name": "notebrook-frontend-vue",
|
"name": "notebrook-frontend-vue",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/jszip": "^3.4.0",
|
||||||
"@vueuse/core": "^14.1.0",
|
"@vueuse/core": "^14.1.0",
|
||||||
"@vueuse/sound": "^2.1.3",
|
"@vueuse/sound": "^2.1.3",
|
||||||
"idb-keyval": "^6.2.2",
|
"idb-keyval": "^6.2.2",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.5.25",
|
"vue": "^3.5.25",
|
||||||
"vue-router": "^4.6.3"
|
"vue-router": "^4.6.3"
|
||||||
@@ -2859,6 +2861,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/jszip": {
|
||||||
|
"version": "3.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/jszip/-/jszip-3.4.0.tgz",
|
||||||
|
"integrity": "sha512-GFHqtQQP3R4NNuvZH3hNCYD0NbyBZ42bkN7kO3NDrU/SnvIZWMS8Bp38XCsRKBT5BXvgm0y1zqpZWp/ZkRzBzg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jszip": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.10.1",
|
"version": "24.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz",
|
||||||
@@ -4384,6 +4395,12 @@
|
|||||||
"url": "https://opencollective.com/core-js"
|
"url": "https://opencollective.com/core-js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/core-util-is": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -5717,6 +5734,12 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immediate": {
|
||||||
|
"version": "3.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||||
|
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
@@ -5744,6 +5767,12 @@
|
|||||||
"node": ">=0.8.19"
|
"node": ">=0.8.19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/inherits": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/internal-slot": {
|
"node_modules/internal-slot": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
||||||
@@ -6358,6 +6387,18 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jszip": {
|
||||||
|
"version": "3.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||||
|
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||||
|
"license": "(MIT OR GPL-3.0-or-later)",
|
||||||
|
"dependencies": {
|
||||||
|
"lie": "~3.3.0",
|
||||||
|
"pako": "~1.0.2",
|
||||||
|
"readable-stream": "~2.3.6",
|
||||||
|
"setimmediate": "^1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -6392,6 +6433,15 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lie": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"immediate": "~3.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/locate-path": {
|
"node_modules/locate-path": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||||
@@ -6699,6 +6749,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BlueOak-1.0.0"
|
"license": "BlueOak-1.0.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/pako": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||||
|
"license": "(MIT AND Zlib)"
|
||||||
|
},
|
||||||
"node_modules/parent-module": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
@@ -6932,6 +6988,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/process-nextick-args": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
@@ -6973,6 +7035,33 @@
|
|||||||
"safe-buffer": "^5.1.0"
|
"safe-buffer": "^5.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/readable-stream": {
|
||||||
|
"version": "2.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||||
|
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"core-util-is": "~1.0.0",
|
||||||
|
"inherits": "~2.0.3",
|
||||||
|
"isarray": "~1.0.0",
|
||||||
|
"process-nextick-args": "~2.0.0",
|
||||||
|
"safe-buffer": "~5.1.1",
|
||||||
|
"string_decoder": "~1.1.1",
|
||||||
|
"util-deprecate": "~1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/readable-stream/node_modules/isarray": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/readable-stream/node_modules/safe-buffer": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/reflect.getprototypeof": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
@@ -7345,6 +7434,12 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/setimmediate": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/shebang-command": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
@@ -7539,6 +7634,21 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/string_decoder": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "~5.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/string_decoder/node_modules/safe-buffer": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/string-width": {
|
"node_modules/string-width": {
|
||||||
"version": "5.1.2",
|
"version": "5.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||||
@@ -8325,7 +8435,6 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
|
|||||||
@@ -12,27 +12,29 @@
|
|||||||
"format": "prettier --write src/"
|
"format": "prettier --write src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vue": "^3.5.25",
|
"@types/jszip": "^3.4.0",
|
||||||
"vue-router": "^4.6.3",
|
|
||||||
"pinia": "^3.0.4",
|
|
||||||
"idb-keyval": "^6.2.2",
|
|
||||||
"@vueuse/core": "^14.1.0",
|
"@vueuse/core": "^14.1.0",
|
||||||
"@vueuse/sound": "^2.1.3"
|
"@vueuse/sound": "^2.1.3",
|
||||||
|
"idb-keyval": "^6.2.2",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
|
"pinia": "^3.0.4",
|
||||||
|
"vue": "^3.5.25",
|
||||||
|
"vue-router": "^4.6.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"@vitejs/plugin-vue": "^6.0.2",
|
|
||||||
"@vue/tsconfig": "^0.8.1",
|
|
||||||
"typescript": "^5.9.3",
|
|
||||||
"vite": "^7.2.6",
|
|
||||||
"vue-tsc": "^3.1.5",
|
|
||||||
"vite-plugin-pwa": "^1.2.0",
|
|
||||||
"@typescript-eslint/eslint-plugin": "^8.48.1",
|
"@typescript-eslint/eslint-plugin": "^8.48.1",
|
||||||
"@typescript-eslint/parser": "^8.48.1",
|
"@typescript-eslint/parser": "^8.48.1",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.2",
|
||||||
"@vue/eslint-config-prettier": "^10.2.0",
|
"@vue/eslint-config-prettier": "^10.2.0",
|
||||||
"@vue/eslint-config-typescript": "^14.6.0",
|
"@vue/eslint-config-typescript": "^14.6.0",
|
||||||
|
"@vue/tsconfig": "^0.8.1",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"eslint-plugin-vue": "^10.6.2",
|
"eslint-plugin-vue": "^10.6.2",
|
||||||
"prettier": "^3.7.3"
|
"prettier": "^3.7.3",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^7.2.6",
|
||||||
|
"vite-plugin-pwa": "^1.2.0",
|
||||||
|
"vue-tsc": "^3.1.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -313,14 +313,22 @@ const handleFocus = () => {
|
|||||||
|
|
||||||
const toggleAriaLabel = computed(() => {
|
const toggleAriaLabel = computed(() => {
|
||||||
if (isChecked.value === true) return 'Mark as unchecked'
|
if (isChecked.value === true) return 'Mark as unchecked'
|
||||||
if (isChecked.value === false) return 'Mark as checked'
|
if (isChecked.value === false) return 'Remove check'
|
||||||
return 'Mark as checked'
|
return 'Mark as checked'
|
||||||
})
|
})
|
||||||
|
|
||||||
const toggleChecked = async () => {
|
const toggleChecked = async () => {
|
||||||
if (props.isUnsent) return
|
if (props.isUnsent) return
|
||||||
const msg = props.message as ExtendedMessage
|
const msg = props.message as ExtendedMessage
|
||||||
const next = isChecked.value !== true
|
// Cycle: null → true → false → null
|
||||||
|
let next: boolean | null
|
||||||
|
if (isChecked.value === null) {
|
||||||
|
next = true
|
||||||
|
} else if (isChecked.value === true) {
|
||||||
|
next = false
|
||||||
|
} else {
|
||||||
|
next = null
|
||||||
|
}
|
||||||
const prev = isChecked.value
|
const prev = isChecked.value
|
||||||
try {
|
try {
|
||||||
// optimistic
|
// optimistic
|
||||||
|
|||||||
@@ -146,6 +146,69 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-group">
|
||||||
|
<h3>Data Backup</h3>
|
||||||
|
|
||||||
|
<p class="setting-description">
|
||||||
|
Download a complete backup of all channels, messages, and data. Restore will replace all existing data.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="setting-actions">
|
||||||
|
<BaseButton
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
@click="handleBackup"
|
||||||
|
:loading="isBackingUp"
|
||||||
|
>
|
||||||
|
Download Backup
|
||||||
|
</BaseButton>
|
||||||
|
|
||||||
|
<BaseButton
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
@click="triggerRestoreInput"
|
||||||
|
:disabled="isRestoring"
|
||||||
|
>
|
||||||
|
Restore from Backup
|
||||||
|
</BaseButton>
|
||||||
|
<input
|
||||||
|
ref="restoreInput"
|
||||||
|
type="file"
|
||||||
|
accept=".db,.sqlite,.sqlite3"
|
||||||
|
style="display: none"
|
||||||
|
@change="handleRestoreFileSelect"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-group">
|
||||||
|
<h3>Export Data</h3>
|
||||||
|
|
||||||
|
<p class="setting-description">
|
||||||
|
Export all channels and messages in various formats.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<label for="export-format">Format</label>
|
||||||
|
<select id="export-format" v-model="exportFormat" class="select">
|
||||||
|
<option value="markdown">Markdown (zipped)</option>
|
||||||
|
<option value="html-single">HTML (single file)</option>
|
||||||
|
<option value="html-individual">HTML (individual files, zipped)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-actions">
|
||||||
|
<BaseButton
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
@click="handleExport"
|
||||||
|
:loading="isExporting"
|
||||||
|
>
|
||||||
|
Export
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="setting-group">
|
<div class="setting-group">
|
||||||
<h3>Account</h3>
|
<h3>Account</h3>
|
||||||
|
|
||||||
@@ -218,6 +281,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Restore Confirmation Dialog -->
|
||||||
|
<div v-if="showRestoreConfirm" class="confirm-overlay">
|
||||||
|
<div class="confirm-dialog">
|
||||||
|
<h3>Restore from Backup</h3>
|
||||||
|
<p>This will replace all existing data with the backup. All current channels, messages, and data will be overwritten. This cannot be undone.</p>
|
||||||
|
<p v-if="pendingRestoreFile" class="file-info">
|
||||||
|
File: {{ pendingRestoreFile.name }}
|
||||||
|
</p>
|
||||||
|
<div class="confirm-actions">
|
||||||
|
<BaseButton
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
@click="cancelRestore"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
type="button"
|
||||||
|
variant="danger"
|
||||||
|
@click="handleRestore"
|
||||||
|
:loading="isRestoring"
|
||||||
|
>
|
||||||
|
Restore Backup
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -228,6 +319,8 @@ import { useAppStore } from '@/stores/app'
|
|||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import { useAudio } from '@/composables/useAudio'
|
import { useAudio } from '@/composables/useAudio'
|
||||||
|
import { apiService } from '@/services/api'
|
||||||
|
import { getExporter, downloadBlob, type ExportFormat } from '@/utils/export'
|
||||||
import { clear } from 'idb-keyval'
|
import { clear } from 'idb-keyval'
|
||||||
import BaseButton from '@/components/base/BaseButton.vue'
|
import BaseButton from '@/components/base/BaseButton.vue'
|
||||||
import type { AppSettings } from '@/types'
|
import type { AppSettings } from '@/types'
|
||||||
@@ -244,9 +337,16 @@ const { availableVoices, speak, setVoice } = useAudio()
|
|||||||
|
|
||||||
const isSaving = ref(false)
|
const isSaving = ref(false)
|
||||||
const isResetting = ref(false)
|
const isResetting = ref(false)
|
||||||
|
const isBackingUp = ref(false)
|
||||||
|
const isRestoring = ref(false)
|
||||||
|
const isExporting = ref(false)
|
||||||
|
const exportFormat = ref<ExportFormat>('markdown')
|
||||||
const showResetConfirm = ref(false)
|
const showResetConfirm = ref(false)
|
||||||
|
const showRestoreConfirm = ref(false)
|
||||||
|
const pendingRestoreFile = ref<File | null>(null)
|
||||||
const selectedVoiceURI = ref('')
|
const selectedVoiceURI = ref('')
|
||||||
const soundInput = ref()
|
const soundInput = ref()
|
||||||
|
const restoreInput = ref<HTMLInputElement>()
|
||||||
|
|
||||||
// Computed property for current server URL
|
// Computed property for current server URL
|
||||||
const currentServerUrl = computed(() => authStore.serverUrl)
|
const currentServerUrl = computed(() => authStore.serverUrl)
|
||||||
@@ -334,6 +434,85 @@ const handleResetData = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleBackup = async () => {
|
||||||
|
isBackingUp.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiService.downloadBackup()
|
||||||
|
toastStore.success('Backup downloaded successfully')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Backup failed:', error)
|
||||||
|
toastStore.error('Failed to download backup')
|
||||||
|
} finally {
|
||||||
|
isBackingUp.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggerRestoreInput = () => {
|
||||||
|
restoreInput.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRestoreFileSelect = (event: Event) => {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const file = input.files?.[0]
|
||||||
|
|
||||||
|
if (file) {
|
||||||
|
pendingRestoreFile.value = file
|
||||||
|
showRestoreConfirm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset input so the same file can be selected again
|
||||||
|
input.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRestore = async () => {
|
||||||
|
if (!pendingRestoreFile.value) return
|
||||||
|
|
||||||
|
isRestoring.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await apiService.restoreBackup(pendingRestoreFile.value)
|
||||||
|
toastStore.success(`Restored ${result.stats.channels} channels, ${result.stats.messages} messages`)
|
||||||
|
|
||||||
|
// Clear local cache and reload data
|
||||||
|
await clear()
|
||||||
|
appStore.$reset()
|
||||||
|
|
||||||
|
showRestoreConfirm.value = false
|
||||||
|
pendingRestoreFile.value = null
|
||||||
|
emit('close')
|
||||||
|
|
||||||
|
// Reload the page to refresh all data
|
||||||
|
window.location.reload()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Restore failed:', error)
|
||||||
|
toastStore.error((error as Error).message || 'Failed to restore backup')
|
||||||
|
} finally {
|
||||||
|
isRestoring.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelRestore = () => {
|
||||||
|
showRestoreConfirm.value = false
|
||||||
|
pendingRestoreFile.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
isExporting.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const exporter = getExporter(exportFormat.value)
|
||||||
|
const blob = await exporter.export(appStore.channels, appStore.messages)
|
||||||
|
downloadBlob(blob, exporter.filename)
|
||||||
|
toastStore.success('Export completed')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Export failed:', error)
|
||||||
|
toastStore.error('Export failed')
|
||||||
|
} finally {
|
||||||
|
isExporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// Copy current settings to local state
|
// Copy current settings to local state
|
||||||
Object.assign(localSettings, appStore.settings)
|
Object.assign(localSettings, appStore.settings)
|
||||||
@@ -382,6 +561,22 @@ onMounted(() => {
|
|||||||
color: #374151;
|
color: #374151;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.setting-description {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-info {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
background: #f3f4f6;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
.checkbox {
|
.checkbox {
|
||||||
width: 1.25rem;
|
width: 1.25rem;
|
||||||
height: 1.25rem;
|
height: 1.25rem;
|
||||||
@@ -543,5 +738,14 @@ onMounted(() => {
|
|||||||
.confirm-dialog p {
|
.confirm-dialog p {
|
||||||
color: rgba(255, 255, 255, 0.6);
|
color: rgba(255, 255, 255, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.setting-description {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-info {
|
||||||
|
background: #374151;
|
||||||
|
color: rgba(255, 255, 255, 0.87);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -230,7 +230,7 @@ let animationInterval: number | null = null
|
|||||||
|
|
||||||
const startWaveAnimation = () => {
|
const startWaveAnimation = () => {
|
||||||
waveAnimation.value = Array.from({ length: 20 }, () => Math.random() * 40 + 10)
|
waveAnimation.value = Array.from({ length: 20 }, () => Math.random() * 40 + 10)
|
||||||
animationInterval = setInterval(() => {
|
animationInterval = window.setInterval(() => {
|
||||||
waveAnimation.value = waveAnimation.value.map(() => Math.random() * 40 + 10)
|
waveAnimation.value = waveAnimation.value.map(() => Math.random() * 40 + 10)
|
||||||
}, 150)
|
}, 150)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -248,7 +248,7 @@ export function useAudio() {
|
|||||||
recordingStartTime = Date.now()
|
recordingStartTime = Date.now()
|
||||||
|
|
||||||
// Update duration every 100ms
|
// Update duration every 100ms
|
||||||
recordingInterval = setInterval(() => {
|
recordingInterval = window.setInterval(() => {
|
||||||
recording.value.duration = (Date.now() - recordingStartTime) / 1000
|
recording.value.duration = (Date.now() - recordingStartTime) / 1000
|
||||||
}, 100)
|
}, 100)
|
||||||
|
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ export function useOfflineSync() {
|
|||||||
const startAutoSave = () => {
|
const startAutoSave = () => {
|
||||||
if (syncInterval) clearInterval(syncInterval)
|
if (syncInterval) clearInterval(syncInterval)
|
||||||
|
|
||||||
syncInterval = setInterval(async () => {
|
syncInterval = window.setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
await appStore.saveState()
|
await appStore.saveState()
|
||||||
|
|
||||||
|
|||||||
@@ -167,6 +167,55 @@ class ApiService {
|
|||||||
getFileUrl(filePath: string): string {
|
getFileUrl(filePath: string): string {
|
||||||
return `${this.baseUrl}/uploads/${filePath.replace(/^.*\/uploads\//, '')}`
|
return `${this.baseUrl}/uploads/${filePath.replace(/^.*\/uploads\//, '')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Backup - returns a download URL
|
||||||
|
async downloadBackup(): Promise<void> {
|
||||||
|
const response = await fetch(`${this.baseUrl}/backup`, {
|
||||||
|
headers: { Authorization: this.token }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Backup failed: ${response.status} ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get filename from Content-Disposition header or use default
|
||||||
|
const contentDisposition = response.headers.get('Content-Disposition')
|
||||||
|
let filename = 'notebrook-backup.db'
|
||||||
|
if (contentDisposition) {
|
||||||
|
const match = contentDisposition.match(/filename="?(.+?)"?(?:;|$)/)
|
||||||
|
if (match && match[1]) filename = match[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download the file
|
||||||
|
const blob = await response.blob()
|
||||||
|
const url = window.URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = filename
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
window.URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore - upload a .db file
|
||||||
|
async restoreBackup(file: File): Promise<{ success: boolean; message: string; stats: { channels: number; messages: number; files: number } }> {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('database', file)
|
||||||
|
|
||||||
|
const response = await fetch(`${this.baseUrl}/backup`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: this.token },
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||||
|
throw new Error(error.error || `Restore failed: ${response.status} ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const apiService = new ApiService()
|
export const apiService = new ApiService()
|
||||||
|
|||||||
201
frontend-vue/src/utils/export.ts
Normal file
201
frontend-vue/src/utils/export.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import JSZip from 'jszip'
|
||||||
|
import type { Channel, ExtendedMessage } from '@/types'
|
||||||
|
|
||||||
|
export type ExportFormat = 'markdown' | 'html-single' | 'html-individual'
|
||||||
|
|
||||||
|
interface Exporter {
|
||||||
|
export(channels: Channel[], messages: Record<number, ExtendedMessage[]>): Promise<Blob>
|
||||||
|
filename: string
|
||||||
|
mimeType: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDateString(): string {
|
||||||
|
return new Date().toISOString().split('T')[0] ?? 'export'
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(dateString: string): string {
|
||||||
|
return new Date(dateString).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeFilename(name: string): string {
|
||||||
|
return name.replace(/[<>:"/\\|?*]/g, '_').trim() || 'untitled'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Markdown Exporter ============
|
||||||
|
|
||||||
|
class MarkdownExporter implements Exporter {
|
||||||
|
get filename(): string {
|
||||||
|
return `notebrook-export-${getDateString()}.zip`
|
||||||
|
}
|
||||||
|
|
||||||
|
get mimeType(): string {
|
||||||
|
return 'application/zip'
|
||||||
|
}
|
||||||
|
|
||||||
|
async export(channels: Channel[], messages: Record<number, ExtendedMessage[]>): Promise<Blob> {
|
||||||
|
const zip = new JSZip()
|
||||||
|
|
||||||
|
for (const channel of channels) {
|
||||||
|
const channelMessages = messages[channel.id] || []
|
||||||
|
const content = this.formatChannel(channel, channelMessages)
|
||||||
|
const filename = `${sanitizeFilename(channel.name)}.md`
|
||||||
|
zip.file(filename, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
return zip.generateAsync({ type: 'blob' })
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatChannel(channel: Channel, messages: ExtendedMessage[]): string {
|
||||||
|
const lines: string[] = []
|
||||||
|
lines.push(`# ${channel.name}`)
|
||||||
|
lines.push('')
|
||||||
|
|
||||||
|
for (const msg of messages) {
|
||||||
|
const timestamp = formatTimestamp(msg.created_at)
|
||||||
|
lines.push(`## ${timestamp}`)
|
||||||
|
lines.push('')
|
||||||
|
lines.push(`### ${msg.content}`)
|
||||||
|
lines.push('')
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ HTML Single Exporter ============
|
||||||
|
|
||||||
|
class HtmlSingleExporter implements Exporter {
|
||||||
|
get filename(): string {
|
||||||
|
return `notebrook-export-${getDateString()}.html`
|
||||||
|
}
|
||||||
|
|
||||||
|
get mimeType(): string {
|
||||||
|
return 'text/html'
|
||||||
|
}
|
||||||
|
|
||||||
|
async export(channels: Channel[], messages: Record<number, ExtendedMessage[]>): Promise<Blob> {
|
||||||
|
const html = this.generateHtml(channels, messages)
|
||||||
|
return new Blob([html], { type: this.mimeType })
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateHtml(channels: Channel[], messages: Record<number, ExtendedMessage[]>): string {
|
||||||
|
const body: string[] = []
|
||||||
|
|
||||||
|
for (const channel of channels) {
|
||||||
|
const channelMessages = messages[channel.id] || []
|
||||||
|
body.push(`<h2>${escapeHtml(channel.name)}</h2>`)
|
||||||
|
|
||||||
|
for (const msg of channelMessages) {
|
||||||
|
const timestamp = formatTimestamp(msg.created_at)
|
||||||
|
body.push(`<h3>${escapeHtml(timestamp)}</h3>`)
|
||||||
|
body.push(`<h4>${escapeHtml(msg.content)}</h4>`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Notebrook Export</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, -apple-system, sans-serif; max-width: 800px; margin: 0 auto; padding: 2rem; line-height: 1.6; }
|
||||||
|
h1 { border-bottom: 2px solid #333; padding-bottom: 0.5rem; }
|
||||||
|
h2 { margin-top: 2rem; color: #2563eb; }
|
||||||
|
h3 { font-size: 0.875rem; color: #6b7280; margin-bottom: 0.25rem; }
|
||||||
|
h4 { margin-top: 0; font-weight: normal; white-space: pre-wrap; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Notebrook Export</h1>
|
||||||
|
${body.join('\n ')}
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ HTML Individual Exporter ============
|
||||||
|
|
||||||
|
class HtmlIndividualExporter implements Exporter {
|
||||||
|
get filename(): string {
|
||||||
|
return `notebrook-export-${getDateString()}.zip`
|
||||||
|
}
|
||||||
|
|
||||||
|
get mimeType(): string {
|
||||||
|
return 'application/zip'
|
||||||
|
}
|
||||||
|
|
||||||
|
async export(channels: Channel[], messages: Record<number, ExtendedMessage[]>): Promise<Blob> {
|
||||||
|
const zip = new JSZip()
|
||||||
|
|
||||||
|
for (const channel of channels) {
|
||||||
|
const channelMessages = messages[channel.id] || []
|
||||||
|
const html = this.generateChannelHtml(channel, channelMessages)
|
||||||
|
const filename = `${sanitizeFilename(channel.name)}.html`
|
||||||
|
zip.file(filename, html)
|
||||||
|
}
|
||||||
|
|
||||||
|
return zip.generateAsync({ type: 'blob' })
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateChannelHtml(channel: Channel, messages: ExtendedMessage[]): string {
|
||||||
|
const body: string[] = []
|
||||||
|
|
||||||
|
for (const msg of messages) {
|
||||||
|
const timestamp = formatTimestamp(msg.created_at)
|
||||||
|
body.push(`<h2>${escapeHtml(timestamp)}</h2>`)
|
||||||
|
body.push(`<h3>${escapeHtml(msg.content)}</h3>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${escapeHtml(channel.name)} - Notebrook Export</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, -apple-system, sans-serif; max-width: 800px; margin: 0 auto; padding: 2rem; line-height: 1.6; }
|
||||||
|
h1 { border-bottom: 2px solid #333; padding-bottom: 0.5rem; }
|
||||||
|
h2 { font-size: 0.875rem; color: #6b7280; margin-bottom: 0.25rem; }
|
||||||
|
h3 { margin-top: 0; font-weight: normal; white-space: pre-wrap; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>${escapeHtml(channel.name)}</h1>
|
||||||
|
${body.join('\n ')}
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Factory ============
|
||||||
|
|
||||||
|
const exporters: Record<ExportFormat, Exporter> = {
|
||||||
|
'markdown': new MarkdownExporter(),
|
||||||
|
'html-single': new HtmlSingleExporter(),
|
||||||
|
'html-individual': new HtmlIndividualExporter()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getExporter(format: ExportFormat): Exporter {
|
||||||
|
return exporters[format]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadBlob(blob: Blob, filename: string): void {
|
||||||
|
const url = window.URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = filename
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
window.URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user