add export features

This commit is contained in:
2026-01-08 10:52:24 +00:00
parent 619fcdb9ae
commit 74fbd7dc4a
13 changed files with 769 additions and 32 deletions

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@ backend/uploads/
.DS_Store
frontend/dist/
frontend-vue/dist
.npm/**

View File

@@ -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' });

View 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 });
}
});

View File

@@ -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,

View File

@@ -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": {

View File

@@ -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"
}
}

View File

@@ -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

View File

@@ -146,6 +146,69 @@
</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">
<h3>Account</h3>
@@ -218,6 +281,34 @@
</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>
</template>
@@ -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<ExportFormat>('markdown')
const showResetConfirm = ref(false)
const showRestoreConfirm = ref(false)
const pendingRestoreFile = ref<File | null>(null)
const selectedVoiceURI = ref('')
const soundInput = ref()
const restoreInput = ref<HTMLInputElement>()
// Computed property for current server URL
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(() => {
// 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);
}
}
</style>

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -119,7 +119,7 @@ export function useOfflineSync() {
const startAutoSave = () => {
if (syncInterval) clearInterval(syncInterval)
syncInterval = setInterval(async () => {
syncInterval = window.setInterval(async () => {
try {
await appStore.saveState()

View File

@@ -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<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()

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
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)
}