add export features
This commit is contained in:
@@ -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