Implements complete TOC feature for document navigation with bilingual support.
## TOC Detection & Extraction
- Pattern-based TOC detection with 3 regex patterns
- Heuristic validation (30%+ match ratio, 5+ entries, sequential pages)
- Hierarchical section key parsing (e.g., "4.1.2" → level 3, parent "4.1")
- Database schema with parent-child relationships
- Automatic extraction during OCR post-processing
- Server-side LRU caching (200 entries, 30min TTL)
## UI Components
- TocSidebar: Collapsible sidebar (320px) with auto-open on TOC presence
- TocEntry: Recursive component for hierarchical rendering
- Flex layout: Sidebar + PDF viewer side-by-side
- Active page highlighting with real-time sync
- localStorage persistence for sidebar state
## Navigation Features
- Click TOC entry → PDF jumps to page
- Deep link support: URL hash format #p=12
- Page change events: navidocs:pagechange custom event
- URL hash updates on all navigation (next/prev/goTo/TOC)
- Hash change listener for external navigation
- Page clamping and validation
## Search Integration
- "Jump to section" button in search results
- Shows when result has section field
- Navigates to document with page number and hash
## Accessibility
- ARIA attributes: role, aria-label, aria-expanded, aria-current
- Keyboard navigation: Enter/Space on entries, Tab focus
- Screen reader support with aria-live regions
- Semantic HTML with proper list/listitem roles
## Internationalization (i18n)
- Vue I18n integration with vue-i18n package
- English and French translations
- 8 TOC-specific translation keys
- Language switcher component in document viewer
- Locale persistence in localStorage
## Error Handling
- Specific error messages for each failure case
- Validation before processing (doc exists, has pages, has OCR)
- Non-blocking TOC extraction (doesn't fail OCR jobs)
- Detailed error returns: {success, error, entriesCount, pages}
## API Endpoints
- GET /api/documents/:id/toc?format=flat|tree
- POST /api/documents/:id/toc/extract
- Cache invalidation on re-extraction
## Testing
- Smoke test script: 9 comprehensive tests
- E2E testing guide with 5 manual scenarios
- Tests cover: API, caching, validation, navigation, search
## Database
- Migration 002: document_toc table
- Fields: id, document_id, title, section_key, page_start, level, parent_id, order_index
- Foreign keys with CASCADE delete
## Files Changed
- New: TocSidebar.vue, TocEntry.vue, LanguageSwitcher.vue
- New: toc-extractor.js, toc.js routes, i18n setup
- Modified: DocumentView.vue (sidebar, deep links, events)
- Modified: SearchView.vue (Jump to section button)
- Modified: ocr-worker.js (TOC post-processing)
- New: toc-smoke-test.sh, TOC_E2E_TEST.md
Generated with Claude Code (https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
126 lines
3.3 KiB
JavaScript
126 lines
3.3 KiB
JavaScript
/**
|
|
* NaviDocs Backend API
|
|
* Express server with SQLite + Meilisearch
|
|
*/
|
|
|
|
import express from 'express';
|
|
import helmet from 'helmet';
|
|
import cors from 'cors';
|
|
import rateLimit from 'express-rate-limit';
|
|
import dotenv from 'dotenv';
|
|
import { fileURLToPath } from 'url';
|
|
import { dirname, join } from 'path';
|
|
import logger, { loggers } from './utils/logger.js';
|
|
import { requestLogger } from './middleware/requestLogger.js';
|
|
|
|
// Load environment variables
|
|
dotenv.config();
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const PORT = process.env.PORT || 3001;
|
|
const NODE_ENV = process.env.NODE_ENV || 'development';
|
|
|
|
// Create Express app
|
|
const app = express();
|
|
|
|
// Security middleware
|
|
app.use(helmet({
|
|
contentSecurityPolicy: {
|
|
directives: {
|
|
defaultSrc: ["'self'"],
|
|
scriptSrc: ["'self'", "'unsafe-inline'"],
|
|
styleSrc: ["'self'", "'unsafe-inline'"],
|
|
imgSrc: ["'self'", 'data:', 'blob:'],
|
|
connectSrc: ["'self'"],
|
|
fontSrc: ["'self'"],
|
|
objectSrc: ["'none'"],
|
|
mediaSrc: ["'self'"],
|
|
frameSrc: ["'none'"]
|
|
}
|
|
},
|
|
crossOriginEmbedderPolicy: false
|
|
}));
|
|
|
|
// CORS
|
|
app.use(cors({
|
|
origin: NODE_ENV === 'production' ? process.env.ALLOWED_ORIGINS?.split(',') : '*',
|
|
credentials: true
|
|
}));
|
|
|
|
// Body parsing
|
|
app.use(express.json({ limit: '10mb' }));
|
|
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
|
|
|
// Request logging
|
|
app.use(requestLogger);
|
|
|
|
// Rate limiting
|
|
const limiter = rateLimit({
|
|
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000'), // 15 minutes
|
|
max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100'),
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
message: 'Too many requests, please try again later'
|
|
});
|
|
|
|
app.use('/api/', limiter);
|
|
|
|
// Health check
|
|
app.get('/health', async (req, res) => {
|
|
try {
|
|
// TODO: Check database, Meilisearch, queue
|
|
res.json({
|
|
status: 'ok',
|
|
timestamp: Date.now(),
|
|
uptime: process.uptime()
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
status: 'error',
|
|
error: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
// Import route modules
|
|
import uploadRoutes from './routes/upload.js';
|
|
import quickOcrRoutes from './routes/quick-ocr.js';
|
|
import jobsRoutes from './routes/jobs.js';
|
|
import searchRoutes from './routes/search.js';
|
|
import documentsRoutes from './routes/documents.js';
|
|
import imagesRoutes from './routes/images.js';
|
|
import statsRoutes from './routes/stats.js';
|
|
import contextRoutes from './routes/context.js';
|
|
import tocRoutes from './routes/toc.js';
|
|
|
|
// API routes
|
|
app.use('/api/upload/quick-ocr', quickOcrRoutes);
|
|
app.use('/api/upload', uploadRoutes);
|
|
app.use('/api/jobs', jobsRoutes);
|
|
app.use('/api/search', searchRoutes);
|
|
app.use('/api/documents', documentsRoutes);
|
|
app.use('/api/stats', statsRoutes);
|
|
app.use('/api/context', contextRoutes);
|
|
app.use('/api', tocRoutes); // Handles /api/documents/:id/toc paths
|
|
app.use('/api', imagesRoutes);
|
|
|
|
// Error handling
|
|
app.use((err, req, res, next) => {
|
|
console.error('Error:', err);
|
|
|
|
res.status(err.status || 500).json({
|
|
error: err.message || 'Internal server error',
|
|
...(NODE_ENV === 'development' && { stack: err.stack })
|
|
});
|
|
});
|
|
|
|
// Start server
|
|
app.listen(PORT, () => {
|
|
logger.info(`NaviDocs API server started`, {
|
|
port: PORT,
|
|
environment: NODE_ENV,
|
|
healthCheck: `http://localhost:${PORT}/health`
|
|
});
|
|
});
|
|
|
|
export default app;
|