From 74fbd7dc4a4f921ab8b622f6ca19b098a997c5fd Mon Sep 17 00:00:00 2001 From: oriol Date: Thu, 8 Jan 2026 10:52:24 +0000 Subject: [PATCH] add export features --- .gitignore | 3 +- backend/src/app.ts | 2 + backend/src/routes/backup.ts | 162 +++++++++++++ backend/src/server.ts | 5 +- frontend-vue/package-lock.json | 111 ++++++++- frontend-vue/package.json | 28 ++- .../src/components/chat/MessageItem.vue | 12 +- .../src/components/dialogs/SettingsDialog.vue | 222 +++++++++++++++++- .../dialogs/VoiceRecordingDialog.vue | 2 +- frontend-vue/src/composables/useAudio.ts | 2 +- .../src/composables/useOfflineSync.ts | 2 +- frontend-vue/src/services/api.ts | 49 ++++ frontend-vue/src/utils/export.ts | 201 ++++++++++++++++ 13 files changed, 769 insertions(+), 32 deletions(-) create mode 100644 backend/src/routes/backup.ts create mode 100644 frontend-vue/src/utils/export.ts diff --git a/.gitignore b/.gitignore index c837f93..5663bd5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ backend/db.sqlite backend/uploads/ .DS_Store frontend/dist/ -frontend-vue/dist \ No newline at end of file +frontend-vue/dist +.npm/** diff --git a/backend/src/app.ts b/backend/src/app.ts index 1b9b56f..1206934 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -4,6 +4,7 @@ import * as ChannelRoutes from "./routes/channel"; import * as FileRoutes from "./routes/file"; import * as MessageRoutes from "./routes/message"; import * as SearchRoutes from "./routes/search"; +import * as BackupRoutes from "./routes/backup"; import { authenticate } from "./middleware/auth"; import { initializeDB } from "./db"; 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/:messageId/files", FileRoutes.router); app.use("/search", SearchRoutes.router); +app.use("/backup", BackupRoutes.router); app.get('/check-token', authenticate, (req, res) => { res.json({ message: 'Token is valid' }); diff --git a/backend/src/routes/backup.ts b/backend/src/routes/backup.ts new file mode 100644 index 0000000..d7206da --- /dev/null +++ b/backend/src/routes/backup.ts @@ -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 }); + } +}); diff --git a/backend/src/server.ts b/backend/src/server.ts index 58f8e1a..4d4a2b3 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -43,10 +43,9 @@ const getOrCreateCertificate = async () => { } const createSelfSignedSSLCert = async () => { - const selfsigned = await import('selfsigned'); - const pems = selfsigned.generate([{ name: 'Notebrook Self Signed Auto Generated Key', value: 'localhost' }], { + const pems = await selfSigned.generate([{ name: 'commonName', value: 'localhost' }], { keySize: 2048, - days: 365 + algorithm: 'sha256' }); return { key: pems.private, diff --git a/frontend-vue/package-lock.json b/frontend-vue/package-lock.json index c31d46f..34bfb71 100644 --- a/frontend-vue/package-lock.json +++ b/frontend-vue/package-lock.json @@ -8,9 +8,11 @@ "name": "notebrook-frontend-vue", "version": "1.0.0", "dependencies": { + "@types/jszip": "^3.4.0", "@vueuse/core": "^14.1.0", "@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" @@ -2859,6 +2861,15 @@ "dev": true, "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": { "version": "24.10.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", @@ -4384,6 +4395,12 @@ "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": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5717,6 +5734,12 @@ "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": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -5744,6 +5767,12 @@ "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -6358,6 +6387,18 @@ "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": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -6392,6 +6433,15 @@ "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": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -6699,6 +6749,12 @@ "dev": true, "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": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -6932,6 +6988,12 @@ "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": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -6973,6 +7035,33 @@ "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": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -7345,6 +7434,12 @@ "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -7539,6 +7634,21 @@ "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": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -8325,7 +8435,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, "node_modules/vite": { diff --git a/frontend-vue/package.json b/frontend-vue/package.json index 5c220a7..4b14459 100644 --- a/frontend-vue/package.json +++ b/frontend-vue/package.json @@ -12,27 +12,29 @@ "format": "prettier --write src/" }, "dependencies": { - "vue": "^3.5.25", - "vue-router": "^4.6.3", - "pinia": "^3.0.4", - "idb-keyval": "^6.2.2", + "@types/jszip": "^3.4.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": { "@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/parser": "^8.48.1", + "@vitejs/plugin-vue": "^6.0.2", "@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-typescript": "^14.6.0", + "@vue/tsconfig": "^0.8.1", "eslint": "^9.39.1", "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" } -} \ No newline at end of file +} diff --git a/frontend-vue/src/components/chat/MessageItem.vue b/frontend-vue/src/components/chat/MessageItem.vue index 89bfa0a..1573a3b 100644 --- a/frontend-vue/src/components/chat/MessageItem.vue +++ b/frontend-vue/src/components/chat/MessageItem.vue @@ -313,14 +313,22 @@ const handleFocus = () => { const toggleAriaLabel = computed(() => { 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' }) const toggleChecked = async () => { if (props.isUnsent) return 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 try { // optimistic diff --git a/frontend-vue/src/components/dialogs/SettingsDialog.vue b/frontend-vue/src/components/dialogs/SettingsDialog.vue index 73cc260..5a69e21 100644 --- a/frontend-vue/src/components/dialogs/SettingsDialog.vue +++ b/frontend-vue/src/components/dialogs/SettingsDialog.vue @@ -145,17 +145,80 @@ - + +
+

Data Backup

+ +

+ Download a complete backup of all channels, messages, and data. Restore will replace all existing data. +

+ +
+ + Download Backup + + + + Restore from Backup + + +
+
+ +
+

Export Data

+ +

+ Export all channels and messages in various formats. +

+ +
+ + +
+ +
+ + Export + +
+
+

Account

- +
{{ currentServerUrl || 'Default' }}
- +
Logout - +
- +
+ + +
+
+

Restore from Backup

+

This will replace all existing data with the backup. All current channels, messages, and data will be overwritten. This cannot be undone.

+

+ File: {{ pendingRestoreFile.name }} +

+
+ + Cancel + + + Restore Backup + +
+
+
@@ -228,6 +319,8 @@ import { useAppStore } from '@/stores/app' import { useAuthStore } from '@/stores/auth' import { useToastStore } from '@/stores/toast' import { useAudio } from '@/composables/useAudio' +import { apiService } from '@/services/api' +import { getExporter, downloadBlob, type ExportFormat } from '@/utils/export' import { clear } from 'idb-keyval' import BaseButton from '@/components/base/BaseButton.vue' import type { AppSettings } from '@/types' @@ -244,9 +337,16 @@ const { availableVoices, speak, setVoice } = useAudio() const isSaving = ref(false) const isResetting = ref(false) +const isBackingUp = ref(false) +const isRestoring = ref(false) +const isExporting = ref(false) +const exportFormat = ref('markdown') const showResetConfirm = ref(false) +const showRestoreConfirm = ref(false) +const pendingRestoreFile = ref(null) const selectedVoiceURI = ref('') const soundInput = ref() +const restoreInput = ref() // Computed property for current server URL const currentServerUrl = computed(() => authStore.serverUrl) @@ -311,19 +411,19 @@ const handleLogout = async () => { const handleResetData = async () => { isResetting.value = true - + try { // Clear all IndexedDB data await clear() - + // Clear stores await authStore.clearAuth() appStore.$reset() - + toastStore.success('All data has been reset') showResetConfirm.value = false emit('close') - + // Redirect to auth page router.push('/auth') } catch (error) { @@ -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(() => { // Copy current settings to local state Object.assign(localSettings, appStore.settings) @@ -382,6 +561,22 @@ onMounted(() => { 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 { width: 1.25rem; height: 1.25rem; @@ -543,5 +738,14 @@ onMounted(() => { .confirm-dialog p { 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); + } } \ No newline at end of file diff --git a/frontend-vue/src/components/dialogs/VoiceRecordingDialog.vue b/frontend-vue/src/components/dialogs/VoiceRecordingDialog.vue index 75f8e84..229b590 100644 --- a/frontend-vue/src/components/dialogs/VoiceRecordingDialog.vue +++ b/frontend-vue/src/components/dialogs/VoiceRecordingDialog.vue @@ -230,7 +230,7 @@ let animationInterval: number | null = null const startWaveAnimation = () => { waveAnimation.value = Array.from({ length: 20 }, () => Math.random() * 40 + 10) - animationInterval = setInterval(() => { + animationInterval = window.setInterval(() => { waveAnimation.value = waveAnimation.value.map(() => Math.random() * 40 + 10) }, 150) } diff --git a/frontend-vue/src/composables/useAudio.ts b/frontend-vue/src/composables/useAudio.ts index 89d445e..3f115f8 100644 --- a/frontend-vue/src/composables/useAudio.ts +++ b/frontend-vue/src/composables/useAudio.ts @@ -248,7 +248,7 @@ export function useAudio() { recordingStartTime = Date.now() // Update duration every 100ms - recordingInterval = setInterval(() => { + recordingInterval = window.setInterval(() => { recording.value.duration = (Date.now() - recordingStartTime) / 1000 }, 100) diff --git a/frontend-vue/src/composables/useOfflineSync.ts b/frontend-vue/src/composables/useOfflineSync.ts index 29fcb69..615252b 100644 --- a/frontend-vue/src/composables/useOfflineSync.ts +++ b/frontend-vue/src/composables/useOfflineSync.ts @@ -119,7 +119,7 @@ export function useOfflineSync() { const startAutoSave = () => { if (syncInterval) clearInterval(syncInterval) - syncInterval = setInterval(async () => { + syncInterval = window.setInterval(async () => { try { await appStore.saveState() diff --git a/frontend-vue/src/services/api.ts b/frontend-vue/src/services/api.ts index 9742cd8..0eeefff 100644 --- a/frontend-vue/src/services/api.ts +++ b/frontend-vue/src/services/api.ts @@ -167,6 +167,55 @@ class ApiService { getFileUrl(filePath: string): string { return `${this.baseUrl}/uploads/${filePath.replace(/^.*\/uploads\//, '')}` } + + // Backup - returns a download URL + async downloadBackup(): Promise { + 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() diff --git a/frontend-vue/src/utils/export.ts b/frontend-vue/src/utils/export.ts new file mode 100644 index 0000000..716d50c --- /dev/null +++ b/frontend-vue/src/utils/export.ts @@ -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): Promise + 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, ''') +} + +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): Promise { + 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): Promise { + const html = this.generateHtml(channels, messages) + return new Blob([html], { type: this.mimeType }) + } + + private generateHtml(channels: Channel[], messages: Record): string { + const body: string[] = [] + + for (const channel of channels) { + const channelMessages = messages[channel.id] || [] + body.push(`

${escapeHtml(channel.name)}

`) + + for (const msg of channelMessages) { + const timestamp = formatTimestamp(msg.created_at) + body.push(`

${escapeHtml(timestamp)}

`) + body.push(`

${escapeHtml(msg.content)}

`) + } + } + + return ` + + + + + Notebrook Export + + + +

Notebrook Export

+ ${body.join('\n ')} + +` + } +} + +// ============ 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): Promise { + 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(`

${escapeHtml(timestamp)}

`) + body.push(`

${escapeHtml(msg.content)}

`) + } + + return ` + + + + + ${escapeHtml(channel.name)} - Notebrook Export + + + +

${escapeHtml(channel.name)}

+ ${body.join('\n ')} + +` + } +} + +// ============ Factory ============ + +const exporters: Record = { + '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) +}