This document describes the technical architecture of KindleHub, a Vue 3 SPA for managing Kindle highlights.
KindleHub follows a clean layered architecture:
┌─────────────────────────────────────────────────────────────┐
│ Pages (Views) │
│ index.vue | library.vue | import.vue | export.vue | ... │
├─────────────────────────────────────────────────────────────┤
│ Components │
│ BookCard | ClippingCard | DataTable | ExportPanel | ... │
├─────────────────────────────────────────────────────────────┤
│ Composables │
│ useDataEditor | useSearch │
├─────────────────────────────────────────────────────────────┤
│ Pinia Stores │
│ clippings.ts | books.ts | settings.ts │
├─────────────────────────────────────────────────────────────┤
│ Services │
│ parser.service | export.service | db.service │
├─────────────────────────────────────────────────────────────┤
│ External Libraries │
│ kindle-tools-ts | Dexie.js | Fuse.js │
└─────────────────────────────────────────────────────────────┘
File-based routing with unplugin-vue-router. Each file in src/pages/ becomes a route.
| Page | Route | Description |
|---|---|---|
index.vue |
/ |
Dashboard with stats and quick actions |
library.vue |
/library |
Grid of all imported books |
books/[id].vue |
/books/:id |
Book detail with all clippings |
import.vue |
/import |
File import with drag & drop |
batch/[id].vue |
/batch/:id |
Batch review and editing before import |
batch/index.vue |
/batch |
Batch history list |
export.vue |
/export |
Export panel with format picker |
editor.vue |
/editor |
Editable data table |
search.vue |
/search |
Global search with filters |
settings.vue |
/settings |
User preferences |
Organized by feature domain:
components/
├── batch/
│ ├── BatchClippingCard.vue # Inline editing card for batch clippings
│ ├── BatchActions.vue # Floating bar for bulk actions
│ └── BatchWarnings.vue # Panel for parser warnings/errors
├── books/
│ ├── BookCard.vue # Card with gradient cover, stats
│ └── BookList.vue # Grid layout with empty state
├── clippings/
│ ├── ClippingCard.vue # Type-colored card with content
│ └── ClippingList.vue # Scrollable list
├── editor/
│ └── DataTable.vue # Editable table with bulk actions
├── export/
│ ├── ExportPanel.vue # Format picker + preview + download
│ └── FormatPicker.vue # Visual format selector
├── layout/
│ ├── AppHeader.vue # Navigation + dark mode toggle
│ ├── AppFooter.vue # Footer links
│ └── MobileMenu.vue # Slide-over navigation for mobile
├── stats/
│ ├── StatCard.vue # Metric card with icon and value
│ ├── ActivityChart.vue # Line chart (ECharts)
│ ├── TopBooksChart.vue # Horizontal bar chart (ECharts)
│ ├── TypeDistributionChart.vue # Donut chart (ECharts)
│ └── InsightsPanel.vue # Smart insights list
└── ui/
├── EmptyState.vue # Empty state with icon and CTA
├── Skeleton.vue # Loading placeholder
├── Toast.vue # Notification toast
├── Tooltip.vue # Hover tooltip
└── ConfirmModal.vue # Confirmation dialog
Reusable stateful logic following Vue Composition API patterns.
Global toast notification system:
addToast(message, type, duration)- Show notification- Types:
success,error,warning,info - Auto-dismiss with configurable duration
Centralized error handling:
handleError(error)- Process and display errors- Integrates with
AppErrorclass for typed errors - Shows toast notifications for user feedback
Global keyboard shortcuts:
Ctrl+K/Cmd+K- Quick searchCtrl+F/Cmd+F- Navigate to searchEscape- Close/cancel actions
Manages editable table state:
initializeClippings(items)- Load data into editable stateselectedIds- Set of selected row IDseditingId- Currently editing rowtoggleSelect(id)/selectAll()- Selection managementstartEdit(id)/saveEdit()/cancelEdit()- Inline editingdeleteSelected()/duplicateSelected()- Bulk actionsaddClipping(bookId)- Create new row
Full-text search with Fuse.js:
query- Search stringfilters- Active filters (book, type, dateRange)results- Filtered and highlighted resultshighlightMatches(text, indices)- HTML highlightingsetFilter(key, value)- Filter management
Dashboard statistics and insights:
totalClippings,totalBooks,totalAuthors,yearsReading- Basic metricstypeDistribution- For donut chart (highlights/notes/bookmarks)topBooks- Top 10 books by clipping counttimelineData- Monthly activity for line chartinsights- Smart insights with i18n keys (peak month, preferences, etc.)hasData,isLoading- State management
Global state management with persistence.
state: {
clippings: StoredClipping[]
isLoading: boolean
error: string | null
}
getters: {
highlights: StoredClipping[] // type === 'highlight'
notes: StoredClipping[] // type === 'note'
bookmarks: StoredClipping[] // type === 'bookmark'
}
actions: {
loadAllClippings()
loadClippingsForBook(bookId)
loadStats()
clearClippings()
}state: {
books: Book[]
selectedBook: Book | null
isLoading: boolean
}
getters: {
totalBooks: number
totalClippings: number
}
actions: {
loadBooks()
selectBook(id)
clearSelection()
}state: {
exportPreferences: ExportPreferences
language: 'en' | 'es'
}
actions: {
updateExportPreferences(prefs)
setLanguage(lang)
resetToDefaults()
}
// Persists to localStorage automaticallyManages temporary batch state for pre-import editing:
state: {
currentBatch: Batch | null // In-memory batch being edited
batchHistory: BatchHistoryEntry[] // Processed batches metadata
isProcessing: boolean
}
computed: {
clippingsArray: BatchClipping[] // All clippings as array
booksArray: BatchBook[] // Books grouped by title::author
selectedClippings: BatchClipping[]
selectionCount: number
hasBatch: boolean
}
actions: {
createBatch(clippings, fileName, fileSize, stats)
updateClipping(id, updates) // Single clipping edit
bulkUpdateClippings(ids, updates) // Bulk author/title change
deleteClippings(ids)
toggleSelection(id) / selectAll() / deselectAll()
toggleBookExpanded(bookKey)
commitToDatabase() // Save to IndexedDB
discardBatch() // Discard without saving
}Pure functions in src/utils/:
formatDate(date)- Returns relative time (Today, Yesterday, X days ago, etc.)
generateCoverColor(title)- Generates consistent gradient colors from strings
Centralized error handling in src/types/error.types.ts:
type ErrorCode = 'DB_READ_ERROR' | 'DB_WRITE_ERROR' | 'PARSE_ERROR' |
'EXPORT_ERROR' | 'NETWORK_ERROR' | 'UNKNOWN_ERROR'
class AppError extends Error {
code: ErrorCode
context?: Record<string, unknown>
}Business logic wrappers that isolate external dependencies.
Wraps kindle-tools-ts importers:
parseContent(content: string, format: 'txt' | 'csv' | 'json')
→ { books: ProcessedBook[], clippings: ProcessedClipping[] }
detectFormat(filename: string) → 'txt' | 'csv' | 'json'Wraps kindle-tools-ts exporters:
exportClippings(clippings, format, options)
→ { content: string, filename: string } | { files: ExportFile[] }
previewExport(clippings, format, options) → string
downloadExport(result) → void
getFormatInfo(format) → { name, description, extension, icon }Supported formats:
markdown- Single .md filejson- Structured JSONcsv- Spreadsheet compatiblehtml- Standalone HTML pageobsidian- Multiple .md files with YAML frontmatterjoplin- .jex archive for Joplin import
Utility functions for batch management:
generateBatchId() → string // Unique batch ID (uuid)
generateClippingId() → string // Unique clipping ID (uuid)
createBookKey(title, author) → string // "title::author" key
formatBatchDate(date) → string // Human-readable date
formatFileSize(bytes) → string // "1.2 MB" formatIndexedDB operations via Dexie. This service layer decouples the rest of the app from direct database access:
// Books
getAllBooks() → Promise<Book[]>
getBookById(id) → Promise<Book | undefined>
saveClippings(books, clippings) → Promise<void>
// Clippings - CRUD operations
getAllClippings() → Promise<StoredClipping[]>
getClippingsByBookId(bookId) → Promise<StoredClipping[]>
getClippingById(id) → Promise<StoredClipping | undefined>
addClipping(clipping) → Promise<number>
addClippings(clippings) → Promise<void>
updateClipping(id, data) → Promise<void>
deleteClippings(ids) → Promise<void>
// Stats & Maintenance
getStats() → Promise<{ books, clippings, highlights, notes }>
clearAllData() → Promise<void>Note: Composables like useDataEditor use this service instead of accessing db directly, making them easier to test and maintaining loose coupling.
Using Dexie.js (IndexedDB wrapper) defined in src/db/schema.ts.
interface Book {
id?: number // Auto-increment primary key
title: string
author: string
coverColor?: string // Generated gradient color
clippingCount: number
lastReadDate: Date
createdAt: Date
updatedAt: Date
}
// Indexes: ++id, title, author, lastReadDateinterface StoredClipping {
id?: number // Auto-increment primary key
bookId: number // Foreign key to books
originalId: string // Hash from kindle-tools-ts
type: 'highlight' | 'note' | 'bookmark'
content: string
location?: string
page?: number
date: Date
note?: string // Linked note content
tags?: string[]
createdAt: Date
updatedAt: Date
}
// Indexes: ++id, bookId, originalId, type, date, [bookId+type]User drops file
│
▼
import.vue reads file content
│
▼
parser.service.parseContent()
│
├─► TxtImporter / CsvImporter / JsonImporter
│
├─► processClippings() (deduplication, linking)
│
▼
batchesStore.createBatch()
│
├─► Creates BatchClipping[] with metadata
│
├─► Groups by book (title::author)
│
├─► Detects warnings (empty content, etc.)
│
▼
Navigate to /batch/:id
│
▼
User reviews, edits, selects
│
├─► Inline edit: updateClipping()
│
├─► Bulk edit: bulkUpdateClippings()
│
├─► Delete: deleteClippings()
│
▼
User decides:
│
├─► "Import to Library" → commitToDatabase()
│ │
│ ├─► db.service.saveClippings()
│ │
│ ├─► Add to batchHistory
│ │
│ └─► Navigate to /library
│
├─► "Export Only" → Navigate to /export with batch data
│
└─► "Discard" → discardBatch() + navigate away
User selects format + options
│
▼
export.service.previewExport()
│
▼
Preview displayed in ExportPanel
│
▼
User clicks Download
│
▼
export.service.exportClippings()
│
├─► Single file: Blob download
│
└─► Multi-file (Obsidian/Joplin): ZIP or individual downloads
User types query
│
▼
useSearch.query (debounced 300ms)
│
▼
Fuse.js search with options:
- keys: ['content', 'note', 'book.title', 'book.author']
- threshold: 0.3
- includeMatches: true
│
▼
Apply filters (book, type, dateRange)
│
▼
highlightMatches() adds <mark> tags
│
▼
results displayed with highlighting
All data stays in the browser (IndexedDB). No backend, no accounts, no data sent anywhere.
The app is a showcase for kindle-tools-ts. All parsing and export logic comes from the library. The services layer provides Vue-friendly wrappers.
Using unplugin-vue-router for automatic route generation from src/pages/ structure. Reduces boilerplate and makes navigation intuitive.
All logic uses Vue 3 Composition API with <script setup>. Complex reusable logic is extracted to composables.
No base component library (BaseButton, etc.). Tailwind utilities used directly for rapid development. Headless UI for accessible primitives (menus, dialogs).
Single source of truth for books, clippings, and settings. Settings store persists to localStorage.
Instead of importing files directly to IndexedDB, the app uses an intermediate "batch" state:
Why batches?
- Users can review and edit clippings before committing
- Detect and handle parser warnings (empty content, duplicates)
- Bulk edit author/title across multiple clippings
- Export without saving to database ("Export Only" workflow)
- Maintain history of processed batches
Data structure:
interface Batch {
id: string
fileName: string
fileSize: number
status: 'pending' | 'imported' | 'exported' | 'discarded'
clippings: Map<string, BatchClipping> // In-memory, editable
books: Map<string, BatchBook> // Grouped by title::author
stats: BatchStats
warnings: BatchWarning[]
}
interface BatchClipping extends Clipping {
batchClippingId: string // Temporary ID for editing
isSelected: boolean
isModified: boolean
warnings: string[] // Warning IDs
}Key features:
rebuildBookGroupings()- Reorganizes books when title/author changes, preserves expansion staterecalculateStats()- Updates stats after deletions- Multi-select with floating action bar
- Warning panel for parser issues
Located in tests/unit/. All tests centralized, no tests in src/.
Current coverage: ~60% (14 files, 120 tests)
tests/unit/
├── components/
│ ├── AppHeader.spec.ts
│ ├── books/BookCard.spec.ts
│ └── clippings/ClippingCard.spec.ts
├── composables/
│ ├── useDataEditor.spec.ts
│ └── useSearch.spec.ts
├── db/
│ └── schema.spec.ts
├── services/
│ ├── batch.service.spec.ts
│ ├── db.service.spec.ts
│ ├── export.service.spec.ts
│ └── parser.service.spec.ts
└── stores/
├── batches.spec.ts
├── books.spec.ts
├── clippings.spec.ts
└── settings.spec.ts
Testing patterns:
// Composable test
const { selectedIds, toggleSelect } = useDataEditor()
toggleSelect(1)
expect(selectedIds.value.has(1)).toBe(true)
// Store test (with Pinia)
setActivePinia(createPinia())
const store = useClippingsStore()
await store.loadAllClippings()
expect(store.clippings.length).toBeGreaterThan(0)Would use Playwright for full user flows:
- Import → Library → Book detail
- Search → Filter → Export
Key plugins in vite.config.ts:
@vitejs/plugin-vue- Vue SFC supportunplugin-vue-router- File-based routingunplugin-vue-components- Auto component importsunplugin-auto-import- Auto API imports (Vue, VueUse, Pinia)
.github/workflows/deploy.yml:
- Checkout code
- Setup Node 20 + pnpm
- Install dependencies
- Run linting
- Run tests
- Build production bundle
- Deploy to GitHub Pages
- Node.js >= 20
- pnpm >= 9
- TypeScript strict mode
- ESLint with Vue + TypeScript rules
- XSS Prevention: DOMPurify sanitizes all user content before rendering
- Input Validation: File uploads validated by type and content
- No Secrets: No API keys, no backend, no sensitive data
- CSP Ready: Static assets can use strict Content Security Policy
Target: <300KB gzipped
- Tree-shaking enabled for all dependencies
- Only used Lucide icons are bundled
- kindle-tools-ts is the largest dependency
- Fuse.js is initialized once with all clippings
- Debounced input (300ms) prevents excessive searches
- Target: <500ms for 1000+ clippings
- IndexedDB handles large datasets efficiently
- Compound indexes for common queries
- Batch operations for imports
Would require:
vite-plugin-pwafor service workermanifest.jsonfor installability- Offline-first IndexedDB access (already in place)
✅ Implemented: vue-i18n with JSON translation files in src/locales/
- Supported languages: EN, ES, IT, DE, FR, PT
- Auto-detection of browser language
- Connected to
settings.language
For very large libraries (1000+ books):
- vue-virtual-scroller for BookList
- Paginated clipping lists
Last updated: 2026-01-23