Compare commits
8 Commits
d786a7463b
...
b5d4a6ab76
| Author | SHA1 | Date | |
|---|---|---|---|
| b5d4a6ab76 | |||
| 9ea94e0e31 | |||
| 81022c4e21 | |||
| ebe8389cb3 | |||
| 051c032f1d | |||
| 7e49e14901 | |||
| 74fbd7dc4a | |||
| 619fcdb9ae |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@ backend/uploads/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
frontend-vue/dist
|
frontend-vue/dist
|
||||||
|
.npm/**
|
||||||
|
|||||||
2018
backend/package-lock.json
generated
2018
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
|||||||
162
backend/src/routes/backup.ts
Normal file
162
backend/src/routes/backup.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
2518
frontend-vue/package-lock.json
generated
2518
frontend-vue/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
144
frontend-vue/src/components/dialogs/LinkSelectionDialog.vue
Normal file
144
frontend-vue/src/components/dialogs/LinkSelectionDialog.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -136,10 +136,17 @@ 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]
|
||||||
|
if (existingChannel) {
|
||||||
|
channels[channelIndex] = {
|
||||||
|
id: existingChannel.id,
|
||||||
|
name: data.name,
|
||||||
|
created_at: existingChannel.created_at
|
||||||
|
}
|
||||||
appStore.setChannels(channels)
|
appStore.setChannels(channels)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const setupEventHandlers = () => {
|
const setupEventHandlers = () => {
|
||||||
websocketService.on('message-created', handleMessageCreated)
|
websocketService.on('message-created', handleMessageCreated)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
@@ -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
124
lib/python/channels.py
Normal 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
10
lib/rust/Cargo.toml
Normal 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
195
lib/rust/src/lib.rs
Normal 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
124
lib/sh/channels.sh
Normal 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
124
lib/typescript/channels.ts
Normal 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user