add export features
This commit is contained in:
111
frontend-vue/package-lock.json
generated
111
frontend-vue/package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -145,17 +145,80 @@
|
||||
</select>
|
||||
</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>
|
||||
|
||||
|
||||
<div class="setting-item">
|
||||
<label>Current Server</label>
|
||||
<div class="server-info">
|
||||
{{ currentServerUrl || 'Default' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="setting-actions">
|
||||
<BaseButton
|
||||
type="button"
|
||||
@@ -165,7 +228,7 @@
|
||||
>
|
||||
Logout
|
||||
</BaseButton>
|
||||
|
||||
|
||||
<BaseButton
|
||||
type="button"
|
||||
variant="danger"
|
||||
@@ -176,7 +239,7 @@
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="form-actions">
|
||||
<BaseButton
|
||||
type="button"
|
||||
@@ -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)
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ export function useOfflineSync() {
|
||||
const startAutoSave = () => {
|
||||
if (syncInterval) clearInterval(syncInterval)
|
||||
|
||||
syncInterval = setInterval(async () => {
|
||||
syncInterval = window.setInterval(async () => {
|
||||
try {
|
||||
await appStore.saveState()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
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