Compare commits

...

8 Commits

32 changed files with 4699 additions and 1612 deletions

1
.gitignore vendored
View File

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

2022
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,22 +14,22 @@
"typescript": "^5.5.4" "typescript": "^5.5.4"
}, },
"dependencies": { "dependencies": {
"@types/better-sqlite3": "^7.6.11", "@types/better-sqlite3": "^7.6.13",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.19",
"@types/express": "^4.17.21", "@types/express": "^5.0.6",
"@types/jsonwebtoken": "^9.0.6", "@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^1.4.11", "@types/multer": "^2.0.0",
"@types/ws": "^8.5.12", "@types/ws": "^8.18.1",
"better-sqlite3": "^11.2.1", "better-sqlite3": "^12.5.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.5", "dotenv": "^17.2.3",
"express": "^4.19.2", "express": "^5.2.1",
"multer": "^1.4.5-lts.1", "multer": "^2.0.2",
"ollama": "^0.5.8", "ollama": "^0.6.3",
"openai": "^4.56.0", "openai": "^6.9.1",
"selfsigned": "^2.4.1", "selfsigned": "^5.2.0",
"sharp": "^0.33.5", "sharp": "^0.34.5",
"tsx": "^4.18.0", "tsx": "^4.21.0",
"ws": "^8.18.0" "ws": "^8.18.3"
} }
} }

View File

@@ -4,6 +4,7 @@ import * as ChannelRoutes from "./routes/channel";
import * as FileRoutes from "./routes/file"; import * as FileRoutes from "./routes/file";
import * as MessageRoutes from "./routes/message"; import * as MessageRoutes from "./routes/message";
import * as SearchRoutes from "./routes/search"; import * as SearchRoutes from "./routes/search";
import * as BackupRoutes from "./routes/backup";
import { authenticate } from "./middleware/auth"; import { authenticate } from "./middleware/auth";
import { initializeDB } from "./db"; import { initializeDB } from "./db";
import { FRONTEND_DIR, UPLOAD_DIR } from "./config"; import { FRONTEND_DIR, UPLOAD_DIR } from "./config";
@@ -20,6 +21,7 @@ app.use("/channels", ChannelRoutes.router);
app.use("/channels/:channelId/messages", MessageRoutes.router); app.use("/channels/:channelId/messages", MessageRoutes.router);
app.use("/channels/:channelId/messages/:messageId/files", FileRoutes.router); app.use("/channels/:channelId/messages/:messageId/files", FileRoutes.router);
app.use("/search", SearchRoutes.router); app.use("/search", SearchRoutes.router);
app.use("/backup", BackupRoutes.router);
app.get('/check-token', authenticate, (req, res) => { app.get('/check-token', authenticate, (req, res) => {
res.json({ message: 'Token is valid' }); res.json({ message: 'Token is valid' });

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 createSelfSignedSSLCert = async () => {
const selfsigned = await import('selfsigned'); const pems = await selfSigned.generate([{ name: 'commonName', value: 'localhost' }], {
const pems = selfsigned.generate([{ name: 'Notebrook Self Signed Auto Generated Key', value: 'localhost' }], {
keySize: 2048, keySize: 2048,
days: 365 algorithm: 'sha256'
}); });
return { return {
key: pems.private, key: pems.private,

File diff suppressed because it is too large Load Diff

View File

@@ -12,27 +12,29 @@
"format": "prettier --write src/" "format": "prettier --write src/"
}, },
"dependencies": { "dependencies": {
"vue": "^3.5.13", "@types/jszip": "^3.4.0",
"vue-router": "^4.4.5", "@vueuse/core": "^14.1.0",
"pinia": "^2.3.0", "@vueuse/sound": "^2.1.3",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.2",
"@vueuse/core": "^11.3.0", "jszip": "^3.10.1",
"@vueuse/sound": "^2.0.1" "pinia": "^3.0.4",
"vue": "^3.5.25",
"vue-router": "^4.6.3"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.10.2", "@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^5.2.1", "@typescript-eslint/eslint-plugin": "^8.48.1",
"@vue/tsconfig": "^0.7.0", "@typescript-eslint/parser": "^8.48.1",
"typescript": "^5.7.2", "@vitejs/plugin-vue": "^6.0.2",
"vite": "^6.0.5", "@vue/eslint-config-prettier": "^10.2.0",
"vue-tsc": "^2.1.10", "@vue/eslint-config-typescript": "^14.6.0",
"vite-plugin-pwa": "^0.21.2", "@vue/tsconfig": "^0.8.1",
"@typescript-eslint/eslint-plugin": "^8.18.2", "eslint": "^9.39.1",
"@typescript-eslint/parser": "^8.18.2", "eslint-plugin-vue": "^10.6.2",
"@vue/eslint-config-prettier": "^10.1.0", "prettier": "^3.7.3",
"@vue/eslint-config-typescript": "^14.1.3", "typescript": "^5.9.3",
"eslint": "^9.17.0", "vite": "^7.2.6",
"eslint-plugin-vue": "^9.32.0", "vite-plugin-pwa": "^1.2.0",
"prettier": "^3.4.2" "vue-tsc": "^3.1.5"
} }
} }

View File

@@ -108,12 +108,12 @@ const trapFocus = (event: KeyboardEvent) => {
if (event.shiftKey) { if (event.shiftKey) {
if (document.activeElement === firstElement) { if (document.activeElement === firstElement) {
event.preventDefault() event.preventDefault()
lastElement.focus() lastElement?.focus()
} }
} else { } else {
if (document.activeElement === lastElement) { if (document.activeElement === lastElement) {
event.preventDefault() event.preventDefault()
firstElement.focus() firstElement?.focus()
} }
} }
} }

View File

@@ -40,6 +40,17 @@
</BaseButton> </BaseButton>
<BaseButton
variant="ghost"
size="xs"
class="open-url-button"
@click="$emit('open-url')"
aria-label="Open URL in focused message"
:disabled="disabled"
>
URL
</BaseButton>
<BaseButton <BaseButton
variant="primary" variant="primary"
size="sm" size="sm"
@@ -70,6 +81,7 @@ defineEmits<{
'camera': [] 'camera': []
'voice': [] 'voice': []
'toggle-check': [] 'toggle-check': []
'open-url': []
'send': [] 'send': []
}>() }>()
</script> </script>
@@ -82,9 +94,11 @@ defineEmits<{
flex-shrink: 0; flex-shrink: 0;
} }
/* Mobile-only for the checked toggle button */ /* Mobile-only for the checked toggle button and open URL button */
.input-actions [aria-label="Toggle check on focused message"] { display: none; } .input-actions [aria-label="Toggle check on focused message"],
.input-actions .open-url-button { display: none; }
@media (max-width: 480px) { @media (max-width: 480px) {
.input-actions [aria-label="Toggle check on focused message"] { display: inline-flex; } .input-actions [aria-label="Toggle check on focused message"],
.input-actions .open-url-button { display: inline-flex; }
} }
</style> </style>

View File

@@ -18,6 +18,7 @@
@camera="$emit('camera')" @camera="$emit('camera')"
@voice="$emit('voice')" @voice="$emit('voice')"
@toggle-check="$emit('toggle-check')" @toggle-check="$emit('toggle-check')"
@open-url="$emit('open-url')"
@send="handleSubmit" @send="handleSubmit"
/> />
</div> </div>
@@ -37,6 +38,7 @@ const emit = defineEmits<{
'camera': [] 'camera': []
'voice': [] 'voice': []
'toggle-check': [] 'toggle-check': []
'open-url': []
}>() }>()
const appStore = useAppStore() const appStore = useAppStore()

View File

@@ -67,6 +67,7 @@ interface Props {
const emit = defineEmits<{ const emit = defineEmits<{
'open-dialog': [message: ExtendedMessage | UnsentMessage] 'open-dialog': [message: ExtendedMessage | UnsentMessage]
'open-dialog-edit': [message: ExtendedMessage | UnsentMessage] 'open-dialog-edit': [message: ExtendedMessage | UnsentMessage]
'open-links': [links: string[], message: ExtendedMessage | UnsentMessage]
'focus': [] 'focus': []
}>() }>()
@@ -195,7 +196,42 @@ const handleClick = () => {
} }
} }
// Extract URLs from text content
const extractUrls = (text: string): string[] => {
const urlRegex = /https?:\/\/[^\s<>"{}|\\^`\[\]]+/gi
const matches = text.match(urlRegex) || []
// Remove duplicates
return [...new Set(matches)]
}
// Handle Shift+Enter: open URL(s) or fall back to edit
const handleOpenUrl = () => {
if (props.isUnsent) return
const urls = extractUrls(props.message.content)
if (urls.length === 0) {
// No links found, fall back to edit
emit('open-dialog-edit', props.message)
} else if (urls.length === 1) {
// Single link, open directly
window.open(urls[0], '_blank', 'noopener,noreferrer')
toastStore.success('Opening link')
} else {
// Multiple links, emit event for selection dialog
emit('open-links', urls, props.message)
}
}
const handleKeydown = (event: KeyboardEvent) => { const handleKeydown = (event: KeyboardEvent) => {
// Handle Shift+Enter for opening URLs
if (event.shiftKey && event.key === 'Enter') {
event.preventDefault()
event.stopPropagation()
handleOpenUrl()
return
}
// Don't interfere with normal keyboard shortcuts (Ctrl+C, Ctrl+V, etc.) // Don't interfere with normal keyboard shortcuts (Ctrl+C, Ctrl+V, etc.)
if (event.ctrlKey || event.metaKey || event.altKey) { if (event.ctrlKey || event.metaKey || event.altKey) {
return return
@@ -313,14 +349,22 @@ const handleFocus = () => {
const toggleAriaLabel = computed(() => { const toggleAriaLabel = computed(() => {
if (isChecked.value === true) return 'Mark as unchecked' if (isChecked.value === true) return 'Mark as unchecked'
if (isChecked.value === false) return 'Mark as checked' if (isChecked.value === false) return 'Remove check'
return 'Mark as checked' return 'Mark as checked'
}) })
const toggleChecked = async () => { const toggleChecked = async () => {
if (props.isUnsent) return if (props.isUnsent) return
const msg = props.message as ExtendedMessage const msg = props.message as ExtendedMessage
const next = isChecked.value !== true // Cycle: null → true → false → null
let next: boolean | null
if (isChecked.value === null) {
next = true
} else if (isChecked.value === true) {
next = false
} else {
next = null
}
const prev = isChecked.value const prev = isChecked.value
try { try {
// optimistic // optimistic
@@ -333,6 +377,11 @@ const toggleChecked = async () => {
} }
} }
// Expose methods for external use (e.g., mobile button)
defineExpose({
handleOpenUrl,
extractUrls
})
</script> </script>
<style scoped> <style scoped>

View File

@@ -8,7 +8,8 @@
:aria-selected="index === focusedMessageIndex ? 'true' : 'false'" :aria-selected="index === focusedMessageIndex ? 'true' : 'false'"
@focus="focusedMessageIndex = index" @focus="focusedMessageIndex = index"
@open-dialog="emit('open-message-dialog', $event)" @open-dialog="emit('open-message-dialog', $event)"
@open-dialog-edit="emit('open-message-dialog-edit', $event)" /> @open-dialog-edit="emit('open-message-dialog-edit', $event)"
@open-links="(links, msg) => emit('open-links', links, msg)" />
<!-- Unsent Messages --> <!-- Unsent Messages -->
<MessageItem v-for="(unsentMsg, index) in unsentMessages" :key="unsentMsg.id" :message="unsentMsg" <MessageItem v-for="(unsentMsg, index) in unsentMessages" :key="unsentMsg.id" :message="unsentMsg"
@@ -16,7 +17,8 @@
:aria-selected="(messages.length + index) === focusedMessageIndex ? 'true' : 'false'" :aria-selected="(messages.length + index) === focusedMessageIndex ? 'true' : 'false'"
:data-message-index="messages.length + index" @focus="focusedMessageIndex = messages.length + index" :data-message-index="messages.length + index" @focus="focusedMessageIndex = messages.length + index"
@open-dialog="emit('open-message-dialog', $event)" @open-dialog="emit('open-message-dialog', $event)"
@open-dialog-edit="emit('open-message-dialog-edit', $event)" /> @open-dialog-edit="emit('open-message-dialog-edit', $event)"
@open-links="(links, msg) => emit('open-links', links, msg)" />
</div> </div>
</div> </div>
</template> </template>
@@ -35,6 +37,7 @@ const emit = defineEmits<{
'message-selected': [message: ExtendedMessage | UnsentMessage, index: number] 'message-selected': [message: ExtendedMessage | UnsentMessage, index: number]
'open-message-dialog': [message: ExtendedMessage | UnsentMessage] 'open-message-dialog': [message: ExtendedMessage | UnsentMessage]
'open-message-dialog-edit': [message: ExtendedMessage | UnsentMessage] 'open-message-dialog-edit': [message: ExtendedMessage | UnsentMessage]
'open-links': [links: string[], message: ExtendedMessage | UnsentMessage]
}>() }>()
const props = defineProps<Props>() const props = defineProps<Props>()
@@ -241,15 +244,47 @@ onMounted(() => {
const getFocusedMessage = (): ExtendedMessage | UnsentMessage | null => { const getFocusedMessage = (): ExtendedMessage | UnsentMessage | null => {
const messages = allMessages.value const messages = allMessages.value
if (focusedMessageIndex.value >= 0 && focusedMessageIndex.value < messages.length) { if (focusedMessageIndex.value >= 0 && focusedMessageIndex.value < messages.length) {
return messages[focusedMessageIndex.value] return messages[focusedMessageIndex.value] ?? null
} }
return null return null
} }
// Extract URLs from text content
const extractUrls = (text: string): string[] => {
const urlRegex = /https?:\/\/[^\s<>"{}|\\^`\[\]]+/gi
const matches = text.match(urlRegex) || []
return [...new Set(matches)]
}
// Handle open URL for the focused message (for mobile button)
const handleOpenUrlFocused = (): { action: 'none' | 'single' | 'multiple', urls: string[], message: ExtendedMessage | UnsentMessage | null } => {
const message = getFocusedMessage()
if (!message) {
return { action: 'none', urls: [], message: null }
}
// Don't allow URL opening for unsent messages
if ('channelId' in message) {
return { action: 'none', urls: [], message }
}
const urls = extractUrls(message.content)
if (urls.length === 0) {
return { action: 'none', urls: [], message }
} else if (urls.length === 1) {
return { action: 'single', urls, message }
} else {
return { action: 'multiple', urls, message }
}
}
defineExpose({ defineExpose({
scrollToBottom, scrollToBottom,
focusMessageById, focusMessageById,
getFocusedMessage getFocusedMessage,
handleOpenUrlFocused,
extractUrls
}) })
</script> </script>

View File

@@ -207,8 +207,8 @@ const switchCamera = async () => {
// Determine if this is likely a front camera // Determine if this is likely a front camera
const currentCamera = availableCameras.value[currentCameraIndex.value] const currentCamera = availableCameras.value[currentCameraIndex.value]
isFrontCamera.value = currentCamera.label.toLowerCase().includes('front') || isFrontCamera.value = currentCamera?.label.toLowerCase().includes('front') ||
currentCamera.label.toLowerCase().includes('user') currentCamera?.label.toLowerCase().includes('user') || false
try { try {
await startCamera() await startCamera()

View File

@@ -281,7 +281,10 @@ const performDelete = async () => {
// Switch to first available channel if we were on the deleted channel // Switch to first available channel if we were on the deleted channel
if (appStore.currentChannelId === props.channel.id && appStore.channels.length > 0) { if (appStore.currentChannelId === props.channel.id && appStore.channels.length > 0) {
await appStore.setCurrentChannel(appStore.channels[0].id) const firstChannel = appStore.channels[0]
if (firstChannel) {
await appStore.setCurrentChannel(firstChannel.id)
}
} }
} catch (error) { } catch (error) {
// For delete, we can't do offline fallback easily since it affects server state // For delete, we can't do offline fallback easily since it affects server state

View File

@@ -142,7 +142,7 @@ const uploadFiles = async () => {
// For single file, use the filename as message content // For single file, use the filename as message content
// For multiple files, show count // For multiple files, show count
const messageContent = selectedFiles.value.length === 1 const messageContent = selectedFiles.value.length === 1
? selectedFiles.value[0].name ? selectedFiles.value[0]?.name || 'Uploaded file'
: `Uploaded ${selectedFiles.value.length} files` : `Uploaded ${selectedFiles.value.length} files`
// Create a message first to attach files to // Create a message first to attach files to
@@ -151,6 +151,10 @@ const uploadFiles = async () => {
// Upload the first file (backend uses single file per message) // Upload the first file (backend uses single file per message)
const file = selectedFiles.value[0] const file = selectedFiles.value[0]
if (!file) {
throw new Error('No file selected')
}
try { try {
const uploadedFile = await apiService.uploadFile(appStore.currentChannelId, message.id, file) const uploadedFile = await apiService.uploadFile(appStore.currentChannelId, message.id, file)
uploadProgress.value[0] = 100 uploadProgress.value[0] = 100

View File

@@ -0,0 +1,144 @@
<template>
<div class="link-selection-dialog">
<p class="link-selection-dialog__description">
Select a link to open:
</p>
<div class="link-selection-dialog__links">
<button
v-for="(link, index) in links"
:key="index"
class="link-selection-dialog__link"
@click="openLink(link)"
:title="link"
>
<span class="link-selection-dialog__link-text">{{ formatLink(link) }}</span>
</button>
</div>
<div class="link-selection-dialog__actions">
<BaseButton
variant="ghost"
@click="$emit('close')"
>
Cancel
</BaseButton>
</div>
</div>
</template>
<script setup lang="ts">
import { useToastStore } from '@/stores/toast'
import BaseButton from '@/components/base/BaseButton.vue'
interface Props {
links: string[]
}
const props = defineProps<Props>()
const emit = defineEmits<{
close: []
}>()
const toastStore = useToastStore()
const formatLink = (url: string): string => {
try {
const parsed = new URL(url)
// Show domain + pathname, truncate if too long
let display = parsed.hostname + parsed.pathname
if (display.length > 50) {
display = display.slice(0, 47) + '...'
}
return display
} catch {
// If URL parsing fails, truncate the raw URL
return url.length > 50 ? url.slice(0, 47) + '...' : url
}
}
const openLink = (url: string) => {
window.open(url, '_blank', 'noopener,noreferrer')
toastStore.success('Opening link')
emit('close')
}
</script>
<style scoped>
.link-selection-dialog {
padding: 1rem 0;
display: flex;
flex-direction: column;
gap: 1rem;
}
.link-selection-dialog__description {
color: #374151;
margin: 0;
}
.link-selection-dialog__links {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-height: 300px;
overflow-y: auto;
}
.link-selection-dialog__link {
display: block;
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #f9fafb;
color: #1d4ed8;
font-size: 0.875rem;
text-align: left;
cursor: pointer;
transition: all 0.2s ease;
word-break: break-all;
}
.link-selection-dialog__link:hover,
.link-selection-dialog__link:focus {
background: #eff6ff;
border-color: #3b82f6;
outline: none;
}
.link-selection-dialog__link-text {
display: block;
}
.link-selection-dialog__actions {
display: flex;
justify-content: flex-end;
padding-top: 0.5rem;
border-top: 1px solid #e5e7eb;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.link-selection-dialog__description {
color: rgba(255, 255, 255, 0.87);
}
.link-selection-dialog__link {
background: #374151;
border-color: #4b5563;
color: #60a5fa;
}
.link-selection-dialog__link:hover,
.link-selection-dialog__link:focus {
background: #1e3a5f;
border-color: #60a5fa;
}
.link-selection-dialog__actions {
border-top-color: #374151;
}
}
</style>

View File

@@ -146,6 +146,69 @@
</div> </div>
</div> </div>
<div class="setting-group">
<h3>Data Backup</h3>
<p class="setting-description">
Download a complete backup of all channels, messages, and data. Restore will replace all existing data.
</p>
<div class="setting-actions">
<BaseButton
type="button"
variant="secondary"
@click="handleBackup"
:loading="isBackingUp"
>
Download Backup
</BaseButton>
<BaseButton
type="button"
variant="secondary"
@click="triggerRestoreInput"
:disabled="isRestoring"
>
Restore from Backup
</BaseButton>
<input
ref="restoreInput"
type="file"
accept=".db,.sqlite,.sqlite3"
style="display: none"
@change="handleRestoreFileSelect"
/>
</div>
</div>
<div class="setting-group">
<h3>Export Data</h3>
<p class="setting-description">
Export all channels and messages in various formats.
</p>
<div class="setting-item">
<label for="export-format">Format</label>
<select id="export-format" v-model="exportFormat" class="select">
<option value="markdown">Markdown (zipped)</option>
<option value="html-single">HTML (single file)</option>
<option value="html-individual">HTML (individual files, zipped)</option>
</select>
</div>
<div class="setting-actions">
<BaseButton
type="button"
variant="secondary"
@click="handleExport"
:loading="isExporting"
>
Export
</BaseButton>
</div>
</div>
<div class="setting-group"> <div class="setting-group">
<h3>Account</h3> <h3>Account</h3>
@@ -218,6 +281,34 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Restore Confirmation Dialog -->
<div v-if="showRestoreConfirm" class="confirm-overlay">
<div class="confirm-dialog">
<h3>Restore from Backup</h3>
<p>This will replace all existing data with the backup. All current channels, messages, and data will be overwritten. This cannot be undone.</p>
<p v-if="pendingRestoreFile" class="file-info">
File: {{ pendingRestoreFile.name }}
</p>
<div class="confirm-actions">
<BaseButton
type="button"
variant="secondary"
@click="cancelRestore"
>
Cancel
</BaseButton>
<BaseButton
type="button"
variant="danger"
@click="handleRestore"
:loading="isRestoring"
>
Restore Backup
</BaseButton>
</div>
</div>
</div>
</div> </div>
</template> </template>
@@ -228,6 +319,8 @@ import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useToastStore } from '@/stores/toast' import { useToastStore } from '@/stores/toast'
import { useAudio } from '@/composables/useAudio' import { useAudio } from '@/composables/useAudio'
import { apiService } from '@/services/api'
import { getExporter, downloadBlob, type ExportFormat } from '@/utils/export'
import { clear } from 'idb-keyval' import { clear } from 'idb-keyval'
import BaseButton from '@/components/base/BaseButton.vue' import BaseButton from '@/components/base/BaseButton.vue'
import type { AppSettings } from '@/types' import type { AppSettings } from '@/types'
@@ -244,9 +337,16 @@ const { availableVoices, speak, setVoice } = useAudio()
const isSaving = ref(false) const isSaving = ref(false)
const isResetting = ref(false) const isResetting = ref(false)
const isBackingUp = ref(false)
const isRestoring = ref(false)
const isExporting = ref(false)
const exportFormat = ref<ExportFormat>('markdown')
const showResetConfirm = ref(false) const showResetConfirm = ref(false)
const showRestoreConfirm = ref(false)
const pendingRestoreFile = ref<File | null>(null)
const selectedVoiceURI = ref('') const selectedVoiceURI = ref('')
const soundInput = ref() const soundInput = ref()
const restoreInput = ref<HTMLInputElement>()
// Computed property for current server URL // Computed property for current server URL
const currentServerUrl = computed(() => authStore.serverUrl) const currentServerUrl = computed(() => authStore.serverUrl)
@@ -334,6 +434,85 @@ const handleResetData = async () => {
} }
} }
const handleBackup = async () => {
isBackingUp.value = true
try {
await apiService.downloadBackup()
toastStore.success('Backup downloaded successfully')
} catch (error) {
console.error('Backup failed:', error)
toastStore.error('Failed to download backup')
} finally {
isBackingUp.value = false
}
}
const triggerRestoreInput = () => {
restoreInput.value?.click()
}
const handleRestoreFileSelect = (event: Event) => {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (file) {
pendingRestoreFile.value = file
showRestoreConfirm.value = true
}
// Reset input so the same file can be selected again
input.value = ''
}
const handleRestore = async () => {
if (!pendingRestoreFile.value) return
isRestoring.value = true
try {
const result = await apiService.restoreBackup(pendingRestoreFile.value)
toastStore.success(`Restored ${result.stats.channels} channels, ${result.stats.messages} messages`)
// Clear local cache and reload data
await clear()
appStore.$reset()
showRestoreConfirm.value = false
pendingRestoreFile.value = null
emit('close')
// Reload the page to refresh all data
window.location.reload()
} catch (error) {
console.error('Restore failed:', error)
toastStore.error((error as Error).message || 'Failed to restore backup')
} finally {
isRestoring.value = false
}
}
const cancelRestore = () => {
showRestoreConfirm.value = false
pendingRestoreFile.value = null
}
const handleExport = async () => {
isExporting.value = true
try {
const exporter = getExporter(exportFormat.value)
const blob = await exporter.export(appStore.channels, appStore.messages)
downloadBlob(blob, exporter.filename)
toastStore.success('Export completed')
} catch (error) {
console.error('Export failed:', error)
toastStore.error('Export failed')
} finally {
isExporting.value = false
}
}
onMounted(() => { onMounted(() => {
// Copy current settings to local state // Copy current settings to local state
Object.assign(localSettings, appStore.settings) Object.assign(localSettings, appStore.settings)
@@ -382,6 +561,22 @@ onMounted(() => {
color: #374151; color: #374151;
} }
.setting-description {
margin: 0;
font-size: 0.875rem;
color: #6b7280;
line-height: 1.5;
}
.file-info {
font-family: monospace;
font-size: 0.875rem;
background: #f3f4f6;
padding: 0.5rem;
border-radius: 4px;
word-break: break-all;
}
.checkbox { .checkbox {
width: 1.25rem; width: 1.25rem;
height: 1.25rem; height: 1.25rem;
@@ -543,5 +738,14 @@ onMounted(() => {
.confirm-dialog p { .confirm-dialog p {
color: rgba(255, 255, 255, 0.6); color: rgba(255, 255, 255, 0.6);
} }
.setting-description {
color: rgba(255, 255, 255, 0.6);
}
.file-info {
background: #374151;
color: rgba(255, 255, 255, 0.87);
}
} }
</style> </style>

View File

@@ -230,7 +230,7 @@ let animationInterval: number | null = null
const startWaveAnimation = () => { const startWaveAnimation = () => {
waveAnimation.value = Array.from({ length: 20 }, () => Math.random() * 40 + 10) waveAnimation.value = Array.from({ length: 20 }, () => Math.random() * 40 + 10)
animationInterval = setInterval(() => { animationInterval = window.setInterval(() => {
waveAnimation.value = waveAnimation.value.map(() => Math.random() * 40 + 10) waveAnimation.value = waveAnimation.value.map(() => Math.random() * 40 + 10)
}, 150) }, 150)
} }

View File

@@ -161,11 +161,17 @@ const handleAlphanumericNavigation = (char: string, currentIndex: number) => {
focusChannel(nextMatch) focusChannel(nextMatch)
} else { } else {
// Wrap around to the first match // Wrap around to the first match
focusChannel(matchingIndices[0]) const firstMatch = matchingIndices[0]
if (firstMatch !== undefined) {
focusChannel(firstMatch)
}
} }
} else { } else {
// New character: jump to first match // New character: jump to first match
focusChannel(matchingIndices[0]) const firstMatch = matchingIndices[0]
if (firstMatch !== undefined) {
focusChannel(firstMatch)
}
} }
} }

View File

@@ -62,6 +62,20 @@ export function useAudio() {
console.warn('AudioContext resume failed, user interaction required:', error) console.warn('AudioContext resume failed, user interaction required:', error)
} }
} }
// Play a silent buffer to truly unlock AudioContext on iOS PWA
// On iOS, resume() alone is insufficient — audio must be routed through the context during a user gesture
if (globalAudioContext.state === 'running') {
try {
const silentBuffer = globalAudioContext.createBuffer(1, 1, 22050)
const source = globalAudioContext.createBufferSource()
source.buffer = silentBuffer
source.connect(globalAudioContext.destination)
source.start(0)
} catch (error) {
console.warn('Silent buffer unlock failed:', error)
}
}
} }
// Load a single sound file // Load a single sound file
@@ -94,7 +108,6 @@ export function useAudio() {
try { try {
console.log('Starting to load all sounds...') console.log('Starting to load all sounds...')
soundsLoaded = true
// Load basic sounds // Load basic sounds
const basicSounds = { const basicSounds = {
@@ -135,9 +148,11 @@ export function useAudio() {
} }
} }
soundsLoaded = true
console.log('All sounds loaded and ready to play') console.log('All sounds loaded and ready to play')
} catch (error) { } catch (error) {
console.error('Error loading sounds:', error) console.error('Error loading sounds:', error)
// Don't set soundsLoaded so a retry can happen on next play attempt
} }
} }
@@ -151,6 +166,12 @@ export function useAudio() {
console.error('AudioContext not initialized') console.error('AudioContext not initialized')
return return
} }
// If AudioContext exists but sounds never loaded successfully, retry
if (!soundsLoaded) {
await loadAllSounds()
}
const source = globalAudioContext.createBufferSource() const source = globalAudioContext.createBufferSource()
source.buffer = buffer source.buffer = buffer
source.connect(globalAudioContext.destination) source.connect(globalAudioContext.destination)
@@ -174,14 +195,20 @@ export function useAudio() {
console.log(`playWater called - global: ${globalWaterSounds.length}, reactive: ${waterSounds.value.length} water sounds available`) console.log(`playWater called - global: ${globalWaterSounds.length}, reactive: ${waterSounds.value.length} water sounds available`)
if (globalWaterSounds.length > 0) { if (globalWaterSounds.length > 0) {
const randomIndex = Math.floor(Math.random() * globalWaterSounds.length) const randomIndex = Math.floor(Math.random() * globalWaterSounds.length)
await playSoundBuffer(globalWaterSounds[randomIndex]) const buffer = globalWaterSounds[randomIndex]
if (buffer) {
await playSoundBuffer(buffer)
}
} else { } else {
console.warn('Water sounds not loaded - trying to load them now') console.warn('Water sounds not loaded - trying to load them now')
if (globalAudioContext) { if (globalAudioContext) {
await loadAllSounds() await loadAllSounds()
if (globalWaterSounds.length > 0) { if (globalWaterSounds.length > 0) {
const randomIndex = Math.floor(Math.random() * globalWaterSounds.length) const randomIndex = Math.floor(Math.random() * globalWaterSounds.length)
await playSoundBuffer(globalWaterSounds[randomIndex]) const buffer = globalWaterSounds[randomIndex]
if (buffer) {
await playSoundBuffer(buffer)
}
} }
} }
} }
@@ -190,7 +217,10 @@ export function useAudio() {
const playSent = async () => { const playSent = async () => {
if (globalSentSounds.length > 0) { if (globalSentSounds.length > 0) {
const randomIndex = Math.floor(Math.random() * globalSentSounds.length) const randomIndex = Math.floor(Math.random() * globalSentSounds.length)
await playSoundBuffer(globalSentSounds[randomIndex]) const buffer = globalSentSounds[randomIndex]
if (buffer) {
await playSoundBuffer(buffer)
}
} else { } else {
console.warn('Sent sounds not loaded') console.warn('Sent sounds not loaded')
} }
@@ -239,7 +269,7 @@ export function useAudio() {
recordingStartTime = Date.now() recordingStartTime = Date.now()
// Update duration every 100ms // Update duration every 100ms
recordingInterval = setInterval(() => { recordingInterval = window.setInterval(() => {
recording.value.duration = (Date.now() - recordingStartTime) / 1000 recording.value.duration = (Date.now() - recordingStartTime) / 1000
}, 100) }, 100)
@@ -303,7 +333,7 @@ export function useAudio() {
// Select default voice (prefer English voices) // Select default voice (prefer English voices)
if (!selectedVoice.value && voices.length > 0) { if (!selectedVoice.value && voices.length > 0) {
const englishVoice = voices.find(voice => voice.lang.startsWith('en')) const englishVoice = voices.find(voice => voice.lang.startsWith('en'))
selectedVoice.value = englishVoice || voices[0] selectedVoice.value = englishVoice || voices[0] || null
} }
} }
@@ -402,17 +432,22 @@ export function useAudio() {
audioSystemInitialized = true audioSystemInitialized = true
// Set up user gesture listeners to initialize audio and load sounds // Set up user gesture listeners to initialize audio and load sounds
// Include touchstart for iOS PWA standalone mode where it fires before click
const gestureEvents = ['click', 'keydown', 'touchstart'] as const
const initializeAudio = async () => { const initializeAudio = async () => {
// Remove all gesture listeners immediately so this only fires once
for (const event of gestureEvents) {
document.removeEventListener(event, initializeAudio)
}
console.log('User interaction detected, initializing audio system...') console.log('User interaction detected, initializing audio system...')
await initAudioOnUserGesture() await initAudioOnUserGesture()
await loadAllSounds() // Load sounds after user interaction await loadAllSounds() // Load sounds after user interaction
console.log('Audio system initialized') console.log('Audio system initialized')
document.removeEventListener('click', initializeAudio)
document.removeEventListener('keydown', initializeAudio)
} }
document.addEventListener('click', initializeAudio, { once: true }) for (const event of gestureEvents) {
document.addEventListener('keydown', initializeAudio, { once: true }) document.addEventListener(event, initializeAudio)
}
// Initialize voices for speech synthesis // Initialize voices for speech synthesis
if ('speechSynthesis' in window) { if ('speechSynthesis' in window) {

View File

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

View File

@@ -136,8 +136,15 @@ export function useWebSocket() {
const channels = [...appStore.channels] const channels = [...appStore.channels]
const channelIndex = channels.findIndex(c => c.id === channelId) const channelIndex = channels.findIndex(c => c.id === channelId)
if (channelIndex !== -1) { if (channelIndex !== -1) {
channels[channelIndex] = { ...channels[channelIndex], name: data.name } const existingChannel = channels[channelIndex]
appStore.setChannels(channels) if (existingChannel) {
channels[channelIndex] = {
id: existingChannel.id,
name: data.name,
created_at: existingChannel.created_at
}
appStore.setChannels(channels)
}
} }
} }

View File

@@ -167,6 +167,55 @@ class ApiService {
getFileUrl(filePath: string): string { getFileUrl(filePath: string): string {
return `${this.baseUrl}/uploads/${filePath.replace(/^.*\/uploads\//, '')}` return `${this.baseUrl}/uploads/${filePath.replace(/^.*\/uploads\//, '')}`
} }
// Backup - returns a download URL
async downloadBackup(): Promise<void> {
const response = await fetch(`${this.baseUrl}/backup`, {
headers: { Authorization: this.token }
})
if (!response.ok) {
throw new Error(`Backup failed: ${response.status} ${response.statusText}`)
}
// Get filename from Content-Disposition header or use default
const contentDisposition = response.headers.get('Content-Disposition')
let filename = 'notebrook-backup.db'
if (contentDisposition) {
const match = contentDisposition.match(/filename="?(.+?)"?(?:;|$)/)
if (match && match[1]) filename = match[1]
}
// Download the file
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
}
// Restore - upload a .db file
async restoreBackup(file: File): Promise<{ success: boolean; message: string; stats: { channels: number; messages: number; files: number } }> {
const formData = new FormData()
formData.append('database', file)
const response = await fetch(`${this.baseUrl}/backup`, {
method: 'POST',
headers: { Authorization: this.token },
body: formData
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(error.error || `Restore failed: ${response.status} ${response.statusText}`)
}
return response.json()
}
} }
export const apiService = new ApiService() export const apiService = new ApiService()

View File

@@ -73,11 +73,16 @@ export const useAppStore = defineStore('app', () => {
} }
const channelMessages = messages.value[message.channel_id] const channelMessages = messages.value[message.channel_id]
if (!channelMessages) return
const existingIndex = channelMessages.findIndex(m => m.id === message.id) const existingIndex = channelMessages.findIndex(m => m.id === message.id)
if (existingIndex !== -1) { if (existingIndex !== -1) {
// Upsert: update existing to avoid duplicates from WebSocket vs sync // Upsert: update existing to avoid duplicates from WebSocket vs sync
channelMessages[existingIndex] = { ...channelMessages[existingIndex], ...message } const existingMessage = channelMessages[existingIndex]
if (existingMessage) {
channelMessages[existingIndex] = { ...existingMessage, ...message }
}
} else { } else {
channelMessages.push(message) channelMessages.push(message)
} }
@@ -93,9 +98,14 @@ export const useAppStore = defineStore('app', () => {
const updateMessage = (messageId: number, updates: Partial<ExtendedMessage>) => { const updateMessage = (messageId: number, updates: Partial<ExtendedMessage>) => {
for (const channelId in messages.value) { for (const channelId in messages.value) {
const channelMessages = messages.value[parseInt(channelId)] const channelMessages = messages.value[parseInt(channelId)]
if (!channelMessages) continue
const messageIndex = channelMessages.findIndex(m => m.id === messageId) const messageIndex = channelMessages.findIndex(m => m.id === messageId)
if (messageIndex !== -1) { if (messageIndex !== -1) {
channelMessages[messageIndex] = { ...channelMessages[messageIndex], ...updates } const existingMessage = channelMessages[messageIndex]
if (existingMessage) {
channelMessages[messageIndex] = { ...existingMessage, ...updates }
}
break break
} }
} }
@@ -108,6 +118,8 @@ export const useAppStore = defineStore('app', () => {
const removeMessage = (messageId: number) => { const removeMessage = (messageId: number) => {
for (const channelId in messages.value) { for (const channelId in messages.value) {
const channelMessages = messages.value[parseInt(channelId)] const channelMessages = messages.value[parseInt(channelId)]
if (!channelMessages) continue
const messageIndex = channelMessages.findIndex(m => m.id === messageId) const messageIndex = channelMessages.findIndex(m => m.id === messageId)
if (messageIndex !== -1) { if (messageIndex !== -1) {
channelMessages.splice(messageIndex, 1) channelMessages.splice(messageIndex, 1)
@@ -127,6 +139,11 @@ export const useAppStore = defineStore('app', () => {
} }
const message = sourceMessages[messageIndex] const message = sourceMessages[messageIndex]
if (!message) {
console.warn(`Message ${messageId} not found at index ${messageIndex}`)
return
}
sourceMessages.splice(messageIndex, 1) sourceMessages.splice(messageIndex, 1)
// Update message's channel_id and add to target channel // Update message's channel_id and add to target channel
@@ -137,6 +154,8 @@ export const useAppStore = defineStore('app', () => {
} }
const targetMessages = messages.value[targetChannelId] const targetMessages = messages.value[targetChannelId]
if (!targetMessages) return
targetMessages.push(updatedMessage) targetMessages.push(updatedMessage)
// Keep chronological order in target channel // Keep chronological order in target channel

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

View File

@@ -56,6 +56,7 @@
ref="messagesContainer" ref="messagesContainer"
@open-message-dialog="handleOpenMessageDialog" @open-message-dialog="handleOpenMessageDialog"
@open-message-dialog-edit="handleOpenMessageDialogEdit" @open-message-dialog-edit="handleOpenMessageDialogEdit"
@open-links="handleOpenLinks"
/> />
<!-- Message Input --> <!-- Message Input -->
@@ -65,6 +66,7 @@
@camera="showCameraDialog = true" @camera="showCameraDialog = true"
@voice="showVoiceDialog = true" @voice="showVoiceDialog = true"
@toggle-check="handleToggleCheckFocused" @toggle-check="handleToggleCheckFocused"
@open-url="handleOpenUrlFocused"
ref="messageInput" ref="messageInput"
/> />
</div> </div>
@@ -134,6 +136,13 @@
@move="handleMoveMessage" @move="handleMoveMessage"
/> />
</BaseDialog> </BaseDialog>
<BaseDialog v-model:show="showLinkDialog" title="Open Link">
<LinkSelectionDialog
:links="selectedLinks"
@close="showLinkDialog = false"
/>
</BaseDialog>
</div> </div>
</template> </template>
@@ -166,6 +175,7 @@ import VoiceRecordingDialog from '@/components/dialogs/VoiceRecordingDialog.vue'
import CameraCaptureDialog from '@/components/dialogs/CameraCaptureDialog.vue' import CameraCaptureDialog from '@/components/dialogs/CameraCaptureDialog.vue'
import ChannelInfoDialog from '@/components/dialogs/ChannelInfoDialog.vue' import ChannelInfoDialog from '@/components/dialogs/ChannelInfoDialog.vue'
import MessageDialog from '@/components/dialogs/MessageDialog.vue' import MessageDialog from '@/components/dialogs/MessageDialog.vue'
import LinkSelectionDialog from '@/components/dialogs/LinkSelectionDialog.vue'
// Types // Types
import type { ExtendedMessage, UnsentMessage, Channel } from '@/types' import type { ExtendedMessage, UnsentMessage, Channel } from '@/types'
@@ -198,8 +208,10 @@ const showFileDialog = ref(false)
const showVoiceDialog = ref(false) const showVoiceDialog = ref(false)
const showMessageDialog = ref(false) const showMessageDialog = ref(false)
const showCameraDialog = ref(false) const showCameraDialog = ref(false)
const showLinkDialog = ref(false)
const selectedMessage = ref<ExtendedMessage | null>(null) const selectedMessage = ref<ExtendedMessage | null>(null)
const shouldStartEditing = ref(false) const shouldStartEditing = ref(false)
const selectedLinks = ref<string[]>([])
// Mobile sidebar state // Mobile sidebar state
const sidebarOpen = ref(false) const sidebarOpen = ref(false)
@@ -345,6 +357,38 @@ const handleToggleCheckFocused = async () => {
} }
} }
// Handle opening links from a message (when multiple links found)
const handleOpenLinks = (links: string[], message: ExtendedMessage | UnsentMessage) => {
selectedLinks.value = links
showLinkDialog.value = true
}
// Handle open URL button press (mobile) - triggers URL opening for focused message
const handleOpenUrlFocused = () => {
const result = messagesContainer.value?.handleOpenUrlFocused?.()
if (!result || !result.message) {
toastStore.info('No message is focused')
return
}
if (result.action === 'none') {
// No links found, fall back to edit mode
if ('created_at' in result.message) {
handleOpenMessageDialogEdit(result.message)
} else {
toastStore.info('No links found in this message')
}
} else if (result.action === 'single') {
// Single link, open directly
window.open(result.urls[0], '_blank', 'noopener,noreferrer')
toastStore.success('Opening link')
} else if (result.action === 'multiple') {
// Multiple links, show selection dialog
selectedLinks.value = result.urls
showLinkDialog.value = true
}
}
const selectChannel = async (channelId: number) => { const selectChannel = async (channelId: number) => {
console.log('Selecting channel:', channelId) console.log('Selecting channel:', channelId)
await appStore.setCurrentChannel(channelId) await appStore.setCurrentChannel(channelId)
@@ -443,6 +487,11 @@ const announceLastMessage = (position: number) => {
} }
const message = messages[messageIndex] const message = messages[messageIndex]
if (!message) {
toastStore.info('No message is available in this position')
return
}
const timeStr = formatTimestampForScreenReader(message.created_at) const timeStr = formatTimestampForScreenReader(message.created_at)
const announcement = `${message.content}; sent ${timeStr}` const announcement = `${message.content}; sent ${timeStr}`
@@ -608,7 +657,10 @@ onMounted(async () => {
// 5. Auto-select first channel if none selected and we have channels // 5. Auto-select first channel if none selected and we have channels
if (!appStore.currentChannelId && appStore.channels.length > 0) { if (!appStore.currentChannelId && appStore.channels.length > 0) {
await selectChannel(appStore.channels[0].id) const firstChannel = appStore.channels[0]
if (firstChannel) {
await selectChannel(firstChannel.id)
}
} }
// 6. Auto-focus message input on page load // 6. Auto-focus message input on page load

124
lib/python/channels.py Normal file
View File

@@ -0,0 +1,124 @@
"""
Channel messaging bindings for Python
Provides functions to list channels, read messages, and send messages by channel name
"""
import requests
from typing import Optional
from dataclasses import dataclass
@dataclass
class Channel:
id: str
name: str
data: dict = None
@classmethod
def from_dict(cls, d: dict) -> "Channel":
return cls(id=d["id"], name=d["name"], data=d)
@dataclass
class Message:
id: str
content: str
channel_id: str
timestamp: Optional[str] = None
author: Optional[str] = None
data: dict = None
@classmethod
def from_dict(cls, d: dict) -> "Message":
return cls(
id=d["id"],
content=d["content"],
channel_id=d.get("channelId", d.get("channel_id", "")),
timestamp=d.get("timestamp"),
author=d.get("author"),
data=d,
)
class ChannelClient:
def __init__(self, url: str, token: str):
self.url = url.rstrip("/")
self.token = token
self.session = requests.Session()
self.session.headers.update({
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
})
def _request(self, method: str, endpoint: str, **kwargs) -> dict:
response = self.session.request(method, f"{self.url}{endpoint}", **kwargs)
response.raise_for_status()
return response.json()
def list_channels(self) -> list[Channel]:
"""List all available channels"""
data = self._request("GET", "/channels")
return [Channel.from_dict(c) for c in data]
def find_channel_id_by_name(self, name: str) -> Optional[str]:
"""Find a channel ID by its name"""
channels = self.list_channels()
for channel in channels:
if channel.name == name:
return channel.id
return None
def read_channel(self, name: str) -> Optional[Channel]:
"""Read channel details by name"""
channels = self.list_channels()
for channel in channels:
if channel.name == name:
return channel
return None
def read_messages(self, channel_name: str, limit: Optional[int] = None) -> list[Message]:
"""Read messages from a channel by name"""
channel_id = self.find_channel_id_by_name(channel_name)
if not channel_id:
raise ValueError(f"Channel not found: {channel_name}")
params = {}
if limit:
params["limit"] = limit
data = self._request("GET", f"/channels/{channel_id}/messages", params=params)
return [Message.from_dict(m) for m in data]
def send_message(self, channel_name: str, content: str) -> Message:
"""Send a message to a channel by name"""
channel_id = self.find_channel_id_by_name(channel_name)
if not channel_id:
raise ValueError(f"Channel not found: {channel_name}")
data = self._request("POST", f"/channels/{channel_id}/messages", json={"content": content})
return Message.from_dict(data)
def create_client(url: str, token: str) -> ChannelClient:
"""Create a new channel client"""
return ChannelClient(url, token)
def list_channels(url: str, token: str) -> list[Channel]:
"""List all available channels"""
return create_client(url, token).list_channels()
def read_channel(url: str, token: str, name: str) -> Optional[Channel]:
"""Read channel details by name"""
return create_client(url, token).read_channel(name)
def read_messages(url: str, token: str, channel_name: str, limit: Optional[int] = None) -> list[Message]:
"""Read messages from a channel by name"""
return create_client(url, token).read_messages(channel_name, limit)
def send_message(url: str, token: str, channel_name: str, content: str) -> Message:
"""Send a message to a channel by name"""
return create_client(url, token).send_message(channel_name, content)

10
lib/rust/Cargo.toml Normal file
View File

@@ -0,0 +1,10 @@
[package]
name = "channels"
version = "0.1.0"
edition = "2021"
[dependencies]
reqwest = { version = "0.11", default-features = false, features = ["json", "blocking", "rustls-tls"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "1.0"

195
lib/rust/src/lib.rs Normal file
View File

@@ -0,0 +1,195 @@
//! Channel messaging bindings for Rust
//! Provides functions to list channels, read messages, and send messages by channel name
use reqwest::blocking::Client;
use reqwest::header::{HeaderMap, HeaderName, HeaderValue, CONTENT_TYPE};
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ChannelError {
#[error("HTTP request failed: {0}")]
RequestError(#[from] reqwest::Error),
#[error("Channel not found: {0}")]
ChannelNotFound(String),
#[error("Invalid header value")]
InvalidHeader,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Channel {
pub id: String,
pub name: String,
#[serde(flatten)]
pub extra: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Message {
pub id: String,
pub content: String,
#[serde(alias = "channelId", alias = "channel_id")]
pub channel_id: String,
pub timestamp: Option<String>,
pub author: Option<String>,
#[serde(flatten)]
pub extra: serde_json::Value,
}
#[derive(Debug, Serialize)]
struct SendMessagePayload {
content: String,
}
#[derive(Debug, Serialize)]
struct CreateChannelPayload {
name: String,
}
pub struct ChannelClient {
url: String,
client: Client,
}
impl ChannelClient {
pub fn new(url: &str, token: &str) -> Result<Self, ChannelError> {
let mut headers = HeaderMap::new();
headers.insert(
HeaderName::from_static("authorization"),
HeaderValue::from_str(&format!("Bearer {}", token))
.map_err(|_| ChannelError::InvalidHeader)?,
);
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
let client = Client::builder().default_headers(headers).build()?;
Ok(Self {
url: url.trim_end_matches('/').to_string(),
client,
})
}
/// List all available channels
pub fn list_channels(&self) -> Result<Vec<Channel>, ChannelError> {
let response = self
.client
.get(format!("{}/channels", self.url))
.send()?
.error_for_status()?;
Ok(response.json()?)
}
/// Find a channel ID by its name
pub fn find_channel_id_by_name(&self, name: &str) -> Result<Option<String>, ChannelError> {
let channels = self.list_channels()?;
Ok(channels.into_iter().find(|c| c.name == name).map(|c| c.id))
}
/// Read channel details by name
pub fn read_channel(&self, name: &str) -> Result<Option<Channel>, ChannelError> {
let channels = self.list_channels()?;
Ok(channels.into_iter().find(|c| c.name == name))
}
/// Create a new channel
pub fn create_channel(&self, name: &str) -> Result<Channel, ChannelError> {
let payload = CreateChannelPayload {
name: name.to_string(),
};
let response = self
.client
.post(format!("{}/channels/", self.url))
.json(&payload)
.send()?
.error_for_status()?;
Ok(response.json()?)
}
/// Read messages from a channel by name
pub fn read_messages(
&self,
channel_name: &str,
limit: Option<u32>,
) -> Result<Vec<Message>, ChannelError> {
let channel_id = self
.find_channel_id_by_name(channel_name)?
.ok_or_else(|| ChannelError::ChannelNotFound(channel_name.to_string()))?;
let mut url = format!("{}/channels/{}/messages", self.url, channel_id);
if let Some(limit) = limit {
url.push_str(&format!("?limit={}", limit));
}
let response = self.client.get(&url).send()?.error_for_status()?;
Ok(response.json()?)
}
/// Send a message to a channel by name, creating the channel if it doesn't exist
pub fn send_message(&self, channel_name: &str, content: &str) -> Result<Message, ChannelError> {
let channel_id = match self.find_channel_id_by_name(channel_name)? {
Some(id) => id,
None => {
// Channel doesn't exist, create it
let channel = self.create_channel(channel_name)?;
channel.id
}
};
let payload = SendMessagePayload {
content: content.to_string(),
};
let response = self
.client
.post(format!("{}/channels/{}/messages", self.url, channel_id))
.json(&payload)
.send()?
.error_for_status()?;
Ok(response.json()?)
}
}
/// Create a new channel client
pub fn create_client(url: &str, token: &str) -> Result<ChannelClient, ChannelError> {
ChannelClient::new(url, token)
}
/// List all available channels
pub fn list_channels(url: &str, token: &str) -> Result<Vec<Channel>, ChannelError> {
create_client(url, token)?.list_channels()
}
/// Read channel details by name
pub fn read_channel(url: &str, token: &str, name: &str) -> Result<Option<Channel>, ChannelError> {
create_client(url, token)?.read_channel(name)
}
/// Create a new channel
pub fn create_channel(url: &str, token: &str, name: &str) -> Result<Channel, ChannelError> {
create_client(url, token)?.create_channel(name)
}
/// Read messages from a channel by name
pub fn read_messages(
url: &str,
token: &str,
channel_name: &str,
limit: Option<u32>,
) -> Result<Vec<Message>, ChannelError> {
create_client(url, token)?.read_messages(channel_name, limit)
}
/// Send a message to a channel by name
pub fn send_message(
url: &str,
token: &str,
channel_name: &str,
content: &str,
) -> Result<Message, ChannelError> {
create_client(url, token)?.send_message(channel_name, content)
}

124
lib/sh/channels.sh Normal file
View File

@@ -0,0 +1,124 @@
#!/bin/bash
# Channel messaging bindings for Shell
# Provides functions to list channels, read messages, and send messages by channel name
#
# Usage: source this file to use functions, or run directly with commands
# source channels.sh
# list_channels "https://api.example.com" "your-token"
# read_channel "https://api.example.com" "your-token" "channel-name"
# read_messages "https://api.example.com" "your-token" "channel-name" [limit]
# send_message "https://api.example.com" "your-token" "channel-name" "message content"
set -e
# List all available channels
# Args: $1 = url, $2 = token
list_channels() {
local url="${1%/}"
local token="$2"
curl -s -X GET "${url}/channels" \
-H "Authorization: Bearer ${token}" \
-H "Content-Type: application/json"
}
# Find channel ID by name
# Args: $1 = url, $2 = token, $3 = channel_name
# Returns: channel ID or empty string
find_channel_id_by_name() {
local url="$1"
local token="$2"
local channel_name="$3"
list_channels "$url" "$token" | jq -r --arg name "$channel_name" '.[] | select(.name == $name) | .id'
}
# Read channel details by name
# Args: $1 = url, $2 = token, $3 = channel_name
read_channel() {
local url="$1"
local token="$2"
local channel_name="$3"
list_channels "$url" "$token" | jq --arg name "$channel_name" '.[] | select(.name == $name)'
}
# Read messages from a channel by name
# Args: $1 = url, $2 = token, $3 = channel_name, $4 = limit (optional)
read_messages() {
local url="${1%/}"
local token="$2"
local channel_name="$3"
local limit="$4"
local channel_id
channel_id=$(find_channel_id_by_name "$url" "$token" "$channel_name")
if [ -z "$channel_id" ]; then
echo "Error: Channel not found: $channel_name" >&2
return 1
fi
local endpoint="${url}/channels/${channel_id}/messages"
if [ -n "$limit" ]; then
endpoint="${endpoint}?limit=${limit}"
fi
curl -s -X GET "$endpoint" \
-H "Authorization: Bearer ${token}" \
-H "Content-Type: application/json"
}
# Send a message to a channel by name
# Args: $1 = url, $2 = token, $3 = channel_name, $4 = content
send_message() {
local url="${1%/}"
local token="$2"
local channel_name="$3"
local content="$4"
local channel_id
channel_id=$(find_channel_id_by_name "$url" "$token" "$channel_name")
if [ -z "$channel_id" ]; then
echo "Error: Channel not found: $channel_name" >&2
return 1
fi
curl -s -X POST "${url}/channels/${channel_id}/messages" \
-H "Authorization: Bearer ${token}" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg content "$content" '{content: $content}')"
}
# CLI mode - if script is run directly (not sourced)
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
case "$1" in
list_channels)
shift
list_channels "$@"
;;
read_channel)
shift
read_channel "$@"
;;
read_messages)
shift
read_messages "$@"
;;
send_message)
shift
send_message "$@"
;;
*)
echo "Usage: $0 {list_channels|read_channel|read_messages|send_message} <url> <token> [args...]"
echo ""
echo "Commands:"
echo " list_channels <url> <token>"
echo " read_channel <url> <token> <channel_name>"
echo " read_messages <url> <token> <channel_name> [limit]"
echo " send_message <url> <token> <channel_name> <content>"
exit 1
;;
esac
fi

124
lib/typescript/channels.ts Normal file
View File

@@ -0,0 +1,124 @@
/**
* Channel messaging bindings for TypeScript
* Provides functions to list channels, read messages, and send messages by channel name
*/
export interface Channel {
id: string;
name: string;
[key: string]: unknown;
}
export interface Message {
id: string;
content: string;
channelId: string;
timestamp?: string;
author?: string;
[key: string]: unknown;
}
export interface ChannelClientConfig {
url: string;
token: string;
}
export class ChannelClient {
private url: string;
private token: string;
constructor(config: ChannelClientConfig) {
this.url = config.url.replace(/\/$/, '');
this.token = config.token;
}
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(`${this.url}${endpoint}`, {
...options,
headers: {
'Authorization': `Bearer ${this.token}`,
'Content-Type': 'application/json',
...options.headers,
},
});
if (!response.ok) {
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
}
return response.json();
}
/**
* List all available channels
*/
async listChannels(): Promise<Channel[]> {
return this.request<Channel[]>('/channels');
}
/**
* Find a channel ID by its name
*/
async findChannelIdByName(name: string): Promise<string | null> {
const channels = await this.listChannels();
const channel = channels.find(c => c.name === name);
return channel?.id ?? null;
}
/**
* Read channel details by name
*/
async readChannel(name: string): Promise<Channel | null> {
const channels = await this.listChannels();
return channels.find(c => c.name === name) ?? null;
}
/**
* Read messages from a channel by name
*/
async readMessages(channelName: string, limit?: number): Promise<Message[]> {
const channelId = await this.findChannelIdByName(channelName);
if (!channelId) {
throw new Error(`Channel not found: ${channelName}`);
}
const query = limit ? `?limit=${limit}` : '';
return this.request<Message[]>(`/channels/${channelId}/messages${query}`);
}
/**
* Send a message to a channel by name
*/
async sendMessage(channelName: string, content: string): Promise<Message> {
const channelId = await this.findChannelIdByName(channelName);
if (!channelId) {
throw new Error(`Channel not found: ${channelName}`);
}
return this.request<Message>(`/channels/${channelId}/messages`, {
method: 'POST',
body: JSON.stringify({ content }),
});
}
}
// Convenience functions for standalone usage
export function createClient(url: string, token: string): ChannelClient {
return new ChannelClient({ url, token });
}
export async function listChannels(url: string, token: string): Promise<Channel[]> {
return createClient(url, token).listChannels();
}
export async function readChannel(url: string, token: string, name: string): Promise<Channel | null> {
return createClient(url, token).readChannel(name);
}
export async function readMessages(url: string, token: string, channelName: string, limit?: number): Promise<Message[]> {
return createClient(url, token).readMessages(channelName, limit);
}
export async function sendMessage(url: string, token: string, channelName: string, content: string): Promise<Message> {
return createClient(url, token).sendMessage(channelName, content);
}