Add capture page: quick logs + document upload, NAS vault storage

main
Clawd 3 weeks ago
parent bd4b0e43ca
commit bde4e4caa9

@ -74,7 +74,7 @@ services:
CHUNK_SIZE: ${CHUNK_SIZE:-700}
CHUNK_OVERLAP: ${CHUNK_OVERLAP:-70}
volumes:
- /opt/second-brain-vault:/vault:ro
- /mnt/2brain:/vault
depends_on:
postgres:
condition: service_healthy
@ -92,7 +92,7 @@ services:
VAULT_PATH: /vault
CHAT_MODEL: ${CHAT_MODEL:-qwen3.5:2b}
volumes:
- /opt/second-brain-vault:/vault:ro
- /mnt/2brain:/vault
depends_on:
postgres:
condition: service_healthy

@ -13,7 +13,7 @@ from fastapi.middleware.cors import CORSMiddleware
from core.database import create_pool, close_pool
from core.settings import Settings
from routers import search, chat, documents, index, meta
from routers import search, chat, documents, index, meta, capture
# Global settings instance (imported by routers via dependency)
app_settings = Settings()
@ -57,3 +57,4 @@ app.include_router(chat.router, prefix='/api/v1')
app.include_router(documents.router, prefix='/api/v1')
app.include_router(index.router, prefix='/api/v1')
app.include_router(meta.router, prefix='/api/v1')
app.include_router(capture.router, prefix='/api/v1')

@ -0,0 +1,224 @@
"""
capture.py Quick log entries and file uploads to the vault.
"""
from __future__ import annotations
import os
import re
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, HTTPException, UploadFile, File, Form
from pydantic import BaseModel
router = APIRouter(tags=['capture'])
logger = logging.getLogger(__name__)
# Vault path from environment
VAULT_PATH = Path(os.getenv('VAULT_PATH', '/vault'))
class LogEntry(BaseModel):
"""Quick log/note entry."""
content: str
tags: Optional[list[str]] = None
title: Optional[str] = None
class LogResponse(BaseModel):
"""Response after creating a log entry."""
success: bool
file_path: str
timestamp: str
message: str
class UploadResponse(BaseModel):
"""Response after file upload."""
success: bool
file_path: str
filename: str
size_bytes: int
message: str
def sanitize_filename(name: str) -> str:
"""Remove unsafe characters from filename."""
# Keep alphanumeric, dots, dashes, underscores
safe = re.sub(r'[^\w\-.]', '_', name)
return safe[:200] # Limit length
@router.post('/capture/log', response_model=LogResponse)
async def create_log_entry(entry: LogEntry):
"""
Create a quick log entry. Entries are appended to daily log files
in the logs/ directory with automatic timestamps.
"""
now = datetime.now(timezone.utc)
date_str = now.strftime('%Y-%m-%d')
time_str = now.strftime('%H:%M:%S')
logs_dir = VAULT_PATH / 'logs'
logs_dir.mkdir(parents=True, exist_ok=True)
log_file = logs_dir / f'{date_str}.md'
# Format the entry
lines = []
# If file doesn't exist, add header
if not log_file.exists():
lines.append(f'# Log — {date_str}\n\n')
# Entry header with timestamp
lines.append(f'## {time_str} UTC')
if entry.title:
lines.append(f'{entry.title}')
lines.append('\n\n')
# Tags
if entry.tags:
tag_str = ' '.join(f'#{tag}' for tag in entry.tags)
lines.append(f'{tag_str}\n\n')
# Content
lines.append(entry.content.strip())
lines.append('\n\n---\n\n')
content = ''.join(lines)
try:
with open(log_file, 'a', encoding='utf-8') as f:
f.write(content)
logger.info('Log entry created: %s', log_file)
return LogResponse(
success=True,
file_path=str(log_file.relative_to(VAULT_PATH)),
timestamp=now.isoformat(),
message=f'Log entry added to {date_str}.md'
)
except Exception as e:
logger.error('Failed to write log: %s', e)
raise HTTPException(status_code=500, detail=f'Failed to write log: {e}')
@router.post('/capture/upload', response_model=UploadResponse)
async def upload_document(
file: UploadFile = File(...),
folder: str = Form(default='uploads'),
tags: Optional[str] = Form(default=None),
):
"""
Upload a document to the vault. Supports markdown, text, and PDF files.
- folder: Subfolder in vault (default: uploads)
- tags: Comma-separated tags to add as frontmatter (for .md files)
"""
if not file.filename:
raise HTTPException(status_code=400, detail='No filename provided')
# Sanitize inputs
safe_folder = sanitize_filename(folder)
safe_filename = sanitize_filename(file.filename)
# Allowed extensions
allowed_ext = {'.md', '.txt', '.pdf', '.json', '.yaml', '.yml', '.csv'}
ext = Path(safe_filename).suffix.lower()
if ext not in allowed_ext:
raise HTTPException(
status_code=400,
detail=f'File type {ext} not allowed. Allowed: {", ".join(allowed_ext)}'
)
# Target directory
target_dir = VAULT_PATH / safe_folder
target_dir.mkdir(parents=True, exist_ok=True)
target_path = target_dir / safe_filename
# Handle duplicates by adding timestamp
if target_path.exists():
stem = Path(safe_filename).stem
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
safe_filename = f'{stem}_{timestamp}{ext}'
target_path = target_dir / safe_filename
try:
content = await file.read()
# If markdown and tags provided, prepend frontmatter
if ext == '.md' and tags:
tag_list = [t.strip() for t in tags.split(',') if t.strip()]
if tag_list:
frontmatter = '---\n'
frontmatter += f'tags: [{", ".join(tag_list)}]\n'
frontmatter += f'uploaded: {datetime.now(timezone.utc).isoformat()}\n'
frontmatter += '---\n\n'
content = frontmatter.encode('utf-8') + content
with open(target_path, 'wb') as f:
f.write(content)
logger.info('File uploaded: %s (%d bytes)', target_path, len(content))
return UploadResponse(
success=True,
file_path=str(target_path.relative_to(VAULT_PATH)),
filename=safe_filename,
size_bytes=len(content),
message=f'File uploaded to {safe_folder}/'
)
except Exception as e:
logger.error('Upload failed: %s', e)
raise HTTPException(status_code=500, detail=f'Upload failed: {e}')
@router.get('/capture/logs')
async def list_logs(limit: int = 10):
"""List recent log files."""
logs_dir = VAULT_PATH / 'logs'
if not logs_dir.exists():
return {'logs': [], 'total': 0}
log_files = sorted(logs_dir.glob('*.md'), reverse=True)[:limit]
logs = []
for lf in log_files:
stat = lf.stat()
logs.append({
'date': lf.stem,
'file_path': str(lf.relative_to(VAULT_PATH)),
'size_bytes': stat.st_size,
'modified': datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat()
})
return {'logs': logs, 'total': len(logs)}
@router.get('/capture/log/{date}')
async def get_log(date: str):
"""Get contents of a specific log file by date (YYYY-MM-DD)."""
# Validate date format
if not re.match(r'^\d{4}-\d{2}-\d{2}$', date):
raise HTTPException(status_code=400, detail='Invalid date format. Use YYYY-MM-DD')
log_file = VAULT_PATH / 'logs' / f'{date}.md'
if not log_file.exists():
raise HTTPException(status_code=404, detail=f'No log found for {date}')
content = log_file.read_text(encoding='utf-8')
return {
'date': date,
'file_path': str(log_file.relative_to(VAULT_PATH)),
'content': content
}

@ -0,0 +1,281 @@
'use client';
import { useState, useRef } from 'react';
import { Send, Upload, Check, AlertCircle, FileText, Plus } from 'lucide-react';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
interface LogResponse {
success: boolean;
file_path: string;
timestamp: string;
message: string;
}
interface UploadResponse {
success: boolean;
file_path: string;
filename: string;
size_bytes: number;
message: string;
}
export default function CapturePage() {
// Log state
const [logContent, setLogContent] = useState('');
const [logTitle, setLogTitle] = useState('');
const [logTags, setLogTags] = useState('');
const [logStatus, setLogStatus] = useState<'idle' | 'sending' | 'success' | 'error'>('idle');
const [logMessage, setLogMessage] = useState('');
// Upload state
const [uploadStatus, setUploadStatus] = useState<'idle' | 'uploading' | 'success' | 'error'>('idle');
const [uploadMessage, setUploadMessage] = useState('');
const [uploadFolder, setUploadFolder] = useState('documents');
const [uploadTags, setUploadTags] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
const handleLogSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!logContent.trim()) return;
setLogStatus('sending');
setLogMessage('');
try {
const tags = logTags.split(',').map(t => t.trim()).filter(Boolean);
const res = await fetch(`${API_BASE}/api/v1/capture/log`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: logContent,
title: logTitle || undefined,
tags: tags.length > 0 ? tags : undefined,
}),
});
if (!res.ok) throw new Error(`Failed: ${res.status}`);
const data: LogResponse = await res.json();
setLogStatus('success');
setLogMessage(data.message);
setLogContent('');
setLogTitle('');
setLogTags('');
// Reset status after 3s
setTimeout(() => setLogStatus('idle'), 3000);
} catch (err) {
setLogStatus('error');
setLogMessage(err instanceof Error ? err.message : 'Failed to save log');
}
};
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploadStatus('uploading');
setUploadMessage('');
try {
const formData = new FormData();
formData.append('file', file);
formData.append('folder', uploadFolder);
if (uploadTags) {
formData.append('tags', uploadTags);
}
const res = await fetch(`${API_BASE}/api/v1/capture/upload`, {
method: 'POST',
body: formData,
});
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.detail || `Upload failed: ${res.status}`);
}
const data: UploadResponse = await res.json();
setUploadStatus('success');
setUploadMessage(`${data.filename} uploaded (${(data.size_bytes / 1024).toFixed(1)} KB)`);
// Reset
if (fileInputRef.current) fileInputRef.current.value = '';
setTimeout(() => setUploadStatus('idle'), 3000);
} catch (err) {
setUploadStatus('error');
setUploadMessage(err instanceof Error ? err.message : 'Upload failed');
}
};
return (
<div className="p-8 max-w-4xl mx-auto">
<h1 className="text-2xl font-bold text-white mb-2">Quick Capture</h1>
<p className="text-slate-400 mb-8">
Add notes to your Second Brain or upload documents for indexing.
</p>
{/* Quick Log Section */}
<div className="bg-slate-800 rounded-xl p-6 mb-8">
<div className="flex items-center gap-2 mb-4">
<FileText size={20} className="text-brain-400" />
<h2 className="text-lg font-semibold text-white">Quick Log</h2>
</div>
<form onSubmit={handleLogSubmit} className="space-y-4">
<div className="flex gap-4">
<input
type="text"
placeholder="Title (optional)"
value={logTitle}
onChange={(e) => setLogTitle(e.target.value)}
className="flex-1 bg-slate-700 border border-slate-600 rounded-lg px-4 py-2 text-white placeholder-slate-400 focus:outline-none focus:border-brain-500"
/>
<input
type="text"
placeholder="Tags (comma-separated)"
value={logTags}
onChange={(e) => setLogTags(e.target.value)}
className="w-64 bg-slate-700 border border-slate-600 rounded-lg px-4 py-2 text-white placeholder-slate-400 focus:outline-none focus:border-brain-500"
/>
</div>
<textarea
placeholder="What's on your mind? Write your note here..."
value={logContent}
onChange={(e) => setLogContent(e.target.value)}
rows={4}
className="w-full bg-slate-700 border border-slate-600 rounded-lg px-4 py-3 text-white placeholder-slate-400 focus:outline-none focus:border-brain-500 resize-none"
/>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm">
{logStatus === 'success' && (
<>
<Check size={16} className="text-green-400" />
<span className="text-green-400">{logMessage}</span>
</>
)}
{logStatus === 'error' && (
<>
<AlertCircle size={16} className="text-red-400" />
<span className="text-red-400">{logMessage}</span>
</>
)}
</div>
<button
type="submit"
disabled={!logContent.trim() || logStatus === 'sending'}
className="flex items-center gap-2 bg-brain-600 hover:bg-brain-500 disabled:bg-slate-600 disabled:cursor-not-allowed text-white px-5 py-2 rounded-lg font-medium transition-colors"
>
{logStatus === 'sending' ? (
<>Saving...</>
) : (
<>
<Send size={16} />
Save Log
</>
)}
</button>
</div>
</form>
</div>
{/* Upload Section */}
<div className="bg-slate-800 rounded-xl p-6">
<div className="flex items-center gap-2 mb-4">
<Upload size={20} className="text-brain-400" />
<h2 className="text-lg font-semibold text-white">Upload Document</h2>
</div>
<div className="space-y-4">
<div className="flex gap-4">
<div className="flex-1">
<label className="block text-sm text-slate-400 mb-1">Folder</label>
<select
value={uploadFolder}
onChange={(e) => setUploadFolder(e.target.value)}
className="w-full bg-slate-700 border border-slate-600 rounded-lg px-4 py-2 text-white focus:outline-none focus:border-brain-500"
>
<option value="documents">documents</option>
<option value="uploads">uploads</option>
<option value="notes">notes</option>
<option value="research">research</option>
</select>
</div>
<div className="flex-1">
<label className="block text-sm text-slate-400 mb-1">Tags (for .md files)</label>
<input
type="text"
placeholder="e.g. project, research"
value={uploadTags}
onChange={(e) => setUploadTags(e.target.value)}
className="w-full bg-slate-700 border border-slate-600 rounded-lg px-4 py-2 text-white placeholder-slate-400 focus:outline-none focus:border-brain-500"
/>
</div>
</div>
<div className="border-2 border-dashed border-slate-600 rounded-xl p-8 text-center hover:border-brain-500 transition-colors">
<input
ref={fileInputRef}
type="file"
accept=".md,.txt,.pdf,.json,.yaml,.yml,.csv"
onChange={handleFileUpload}
className="hidden"
id="file-upload"
/>
<label
htmlFor="file-upload"
className="cursor-pointer flex flex-col items-center gap-3"
>
<div className="w-12 h-12 bg-slate-700 rounded-full flex items-center justify-center">
<Plus size={24} className="text-slate-400" />
</div>
<div>
<span className="text-brain-400 font-medium">Click to upload</span>
<span className="text-slate-400"> or drag and drop</span>
</div>
<span className="text-slate-500 text-sm">
Supports: .md, .txt, .pdf, .json, .yaml, .csv
</span>
</label>
</div>
{uploadStatus !== 'idle' && (
<div className="flex items-center gap-2 text-sm">
{uploadStatus === 'uploading' && (
<span className="text-slate-400">Uploading...</span>
)}
{uploadStatus === 'success' && (
<>
<Check size={16} className="text-green-400" />
<span className="text-green-400">{uploadMessage}</span>
</>
)}
{uploadStatus === 'error' && (
<>
<AlertCircle size={16} className="text-red-400" />
<span className="text-red-400">{uploadMessage}</span>
</>
)}
</div>
)}
</div>
</div>
{/* Tips */}
<div className="mt-8 p-4 bg-slate-800/50 rounded-lg border border-slate-700">
<h3 className="text-sm font-medium text-slate-300 mb-2">💡 Tips</h3>
<ul className="text-sm text-slate-400 space-y-1">
<li> Quick logs are saved to daily files (logs/YYYY-MM-DD.md)</li>
<li> Uploaded documents will be indexed automatically</li>
<li> Use tags to organize and find content faster</li>
<li> Markdown files support frontmatter for metadata</li>
</ul>
</div>
</div>
);
}

@ -2,11 +2,12 @@
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Brain, Search, MessageSquare, Tag, GitGraph, Home } from 'lucide-react';
import { Brain, Search, MessageSquare, Tag, GitGraph, Home, PenSquare } from 'lucide-react';
import clsx from 'clsx';
const NAV_ITEMS = [
{ href: '/', label: 'Home', icon: Home },
{ href: '/capture', label: 'Capture', icon: PenSquare },
{ href: '/search', label: 'Search', icon: Search },
{ href: '/chat', label: 'Chat', icon: MessageSquare },
{ href: '/tags', label: 'Tags', icon: Tag },

Loading…
Cancel
Save

Powered by TurnKey Linux.