Skip to content

Architecture ​

Tankōbon is an Electron app (Vite + Electron Forge) for managing and reading digital comics (CBZ, CBR, PDF). This page is hand-written: it explains why the code is shaped the way it is. The what (signatures, types, IPC channels, database schema) is generated from the code, under Reference. Structural decisions are recorded as ADRs.

Processes and boundaries ​

The app follows the standard Electron three-process split, with a strict boundary: src/main.ts creates the window with contextIsolation: true, nodeIntegration: false and sandbox: true (see ADR 0001).

LayerFilesRole
Main processsrc/main.ts, src/main/Owns the window, the SQLite database, the dialogs and the filesystem walk. Never parses a comic file.
Decoder processsrc/main/decoder/, src/main/services/An Electron utilityProcess that opens archives, decodes pages and makes thumbnails (see Decoder process).
Preloadsrc/preload.tsThe only bridge between renderer and main. Exposes a single window.tankobon object via contextBridge.
Renderersrc/renderer.tsx, src/routes/, src/hooks/, src/components/, src/lib/React 19 UI; talks to main exclusively through window.tankobon.
Shared typessrc/shared/Types used on both sides of the IPC boundary (ComicInfo, LibraryEntry, AppSettings…).

The window is also locked to the app's own page: src/main.ts cancels will-navigate to any other URL and denies every window.open/middle-click new window, and src/renderer.tsx cancels the default of dragover/drop on the document (the components' own drop handlers still run), so dropping a PDF or an HTML file on the window can't replace the UI. The CSP in index.html adds base-uri 'none', form-action 'none' and object-src 'none'.

src/main.ts opens the database once, builds the repositories and the thumbnail cache, registers the IPC handlers of src/main/ipc/*.ts, then creates the window.

One instance at a time: main.ts takes app.requestSingleInstanceLock() first; a second launch quits at once and the running instance (second-instance) restores and focuses its window, so two processes never write the same database file or run two decoders on the same library. The connection also sets PRAGMA busy_timeout (configureConnection(), schema.ts, 5 s), so a write that meets a lock held by something else (a sync tool, a second machine's copy) waits instead of failing with "database is locked". The journal mode is left at SQLite's default: WAL is unsafe on the synced folders the database location may point at.

Start-up loader: index.html ships a static spinner inside #root (inline CSS, which the CSP's style-src 'unsafe-inline' allows; light/dark through prefers-color-scheme, since the settings aren't loaded yet). The window shows it while src/renderer.tsx waits for the settings and the language, and React's first root.render() replaces it, so there is nothing to hide.

IPC channels ​

Channel-name constants (e.g. COMIC_CHANNELS, LIBRARY_CHANNELS) live in the main-process IPC files, but the preload can't import them (different process and build target), so the strings are duplicated in src/preload.ts. Both sides must be kept in sync by hand; npm run docs:gen fails if they diverge, and generates the IPC reference.

The shape of window.tankobon is declared up front as explicit interfaces (TankobonApi, then one per namespace: ComicApi, LibraryApi…), and the api object only implements them. The method descriptions live on those interfaces, once: TypeDoc renders them as the API reference, and npm run docs:gen reads the same comments for the IPC reference's description column. A new method needs its signature and TSDoc in the interface, then its implementation in api.

Handlers that open a native file or folder dialog go through openDialogFor()/saveDialogFor() (src/main/ipc/dialogs.ts), which make it modal to the window that sent the request when it still exists.

Everything is invoke/handle (request/response), except three main → renderer push channels: library:scan-progress (see Folder scanning), library:image-scan-progress (see Page sizes) and data:changed (see Renderer data store).

The "À propos" section gets the app's version (app.getVersion(), main process only) and the runtime versions from app:get-info, as an AppInfo (src/shared/app.ts). Its links go through app:open-link, which takes a key of APP_LINKS (repository, documentation, releases, issues, license), never a URL: the main process only ever passes those fixed pages to shell.openExternal, so a compromised renderer can't make it open anything else. The license link opens the MIT LICENSE of the repository, which forge.config.ts also copies into the packaged app's resources (extraResource), since the license asks for its text to ship with every copy.

Update check: app:check-update (checkForUpdate(), src/main/services/update-checker.ts) asks GitHub's API for the latest release of the repository from the main process (the renderer's CSP allows no connection, and getJson() gives the translated errors) and compares its tag with app.getVersion() through the pure isNewerVersion() (src/shared/app.ts); the answer is an UpdateCheckResult, a failure being an error member, not a throw. The root layout's UpdateToast calls it once per window when AppSettings.checkUpdatesOnStartup is on and offers the releases link; Paramètres › À propos has the setting and a manual check. Nothing is downloaded by the app.

Opening a file from the OS: comicPathFromArgv() (src/main/launch-files.ts, pure) reads the supported comic from a command line, and main.ts feeds it three sources: this launch's process.argv, the second-instance event's argv (with its working directory) and macOS's open-file event (which can fire before the app is ready, hence a small queue). Each path goes to LaunchFiles.offer() (src/main/ipc/launch-files.ts), which registers it in the PickedFiles shared with comic:pick-file (picked-files.ts: the token store extracted from comic.ts, 20 tokens) and keeps the latest token. The renderer never receives a path: useLaunchFile() (root layout) calls app:take-launch-file at start-up and again on each app:launch-file push (an announcement without payload, so a window still loading loses nothing), then navigates to /reader?book=<token>. The token is registered with addToLibrary = false, so the addOpenedBooksToLibrary setting decides. File types are declared for packages by forge.config.ts (mimeType of the deb/rpm makers, extendInfo.CFBundleDocumentTypes on macOS); Squirrel (Windows) can't register types.

The app:get-system-languages channel returns the OS's preferred languages, so the renderer resolves the system language setting against the same list as the main process (see Interface language).

Arguments are validated in the main process (ADR 0009): every handler is registered with handle(channel, parse, fn) (src/main/ipc/handle.ts, ADR 0011), where parse is built with args() from the guards of src/main/ipc/validate.ts and gives fn its typed arguments (a refusal, including a wrong argument count, rejects with an IpcArgumentError, never shown to the user); scripts/gen-reference.mjs only counts a channel as handled through handle(). Settings are checked key by key by SETTING_VALIDATORS (src/shared/settings.ts), shared by settings:set and the JSON import; the renderer never names a filesystem path: comic:pick-file returns an opaque token for the file picked in the dialog and comic:open takes that token or a library entry id. settings:get-all returns PublicSettings, without the metadata API keys, which the Métadonnées page reads through settings:get-api-keys. A new setting therefore needs its validator, and a new handler its guards.

The sender is checked too: handle() also calls assertTrustedSender() (src/main/ipc/trusted-sender.ts), which refuses a call that doesn't come from the main frame of the application's own page (the dev server's origin, or the packaged index.html, declared once by main.ts through setAppUrl() before the handlers are registered). The window can't navigate away from that page already, so this is defence in depth: a foreign page or a sub-frame that ever loaded couldn't reach the main process. Until setAppUrl() is called (the unit tests of a single handler) nothing is checked.

Errors that the user should see come back as a { status: 'error', message } member of a result union (OpenComicResult, ImportResult, ExportResult, ClearLibraryResult, DatabaseLocationResult, MetadataSearchResult, ReadingListResult, ResyncResult) rather than as a thrown error: an ipcMain.handle rejection reaches the renderer wrapped in "Error invoking remote method …".

ArchiveInfo is what ComicService knows about an opened archive before it's matched to a library entry; the comic:open handler (src/main/ipc/comic.ts) merges it with the library entry to produce the ComicInfo sent to the renderer. A file that can't be opened (moved, deleted, corrupted, unsupported) comes back as an OpenComicResult error, translated by openErrorMessage() (src/main/services/open-error.ts: a dedicated message for ENOENT, otherwise the underlying reason), which useComic() shows in the reader. If the book was opened in the decoder process but recording the opening fails afterwards (read-only database, file gone), the handler closes that archive instead of leaving it open until the app quits. The tokens of comic:pick-file are kept for the last 20 picks only. When the failed book is a library entry id (found in useLibrary()), the reader's empty state also offers to remove that entry (useConfirm(), then library.remove()), then returns to the library.

Interface language ​

The UI is translated with i18next (and react-i18next in the renderer), in English and French (ADR 0008).

  • One module, one instance per process: src/shared/i18n.ts creates an i18next instance loaded synchronously with every locale (they are bundled: nothing is fetched, t() works from the first line). The main process and the renderer each get their own copy of the module, hence their own instance, switched by applyLanguage(). It starts in English (FALLBACK_LANGUAGE), which is also what a string missing from a locale falls back to.
  • Locales split by area: src/locales/<language>/<namespace>.ts, one namespace per area of the app (library, lists, achievements, reader, settings, metadata, bookEdit, book for field and role labels, nav, common, and the main process's errors and dialogs), gathered by src/locales/<language>/index.ts. Keys are typed (CustomTypeOptions, from the English locale): a mistyped key fails npm run typecheck. Each French namespace is typed Translation<typeof en> (src/locales/types.ts), so a key missing from or added to one locale only fails too; i18n.test.ts also checks that both locales use the same . Plurals use i18next's _one/_other suffixes with a count, so French and English each get their own rule (French singular for 0 and 1).
  • Which language: AppSettings.language is system (the default) or a supported language. resolveLanguage() (pure, tested) returns the forced language, else the first of the OS's preferred languages whose base (fr in fr-CA) is supported, else English; an unsupported stored value counts as system, and the import turns it back into system. The main process applies it at start-up and whenever the setting is written or imported (applyMainLanguage(), src/main/language.ts, with app.getPreferredSystemLanguages()). The renderer applies it before its first render, from the settings and the same OS list (app:get-system-languages), then on each change of the language setting, whoever makes it (the picker, a data import), through keepInterfaceLanguageInSync() (applyInterfaceLanguage(), which also sets <html lang>); components using useTranslation() re-render on their own.
  • Application menu: src/main/app-menu.ts builds a minimal native menu (only roles that do something here; DevTools in development mode only) with labels from the menu namespace, and main.ts rebuilds it through onMainLanguageApplied(), so it follows the language.
  • Messages from the main process (result-union errors, rejected IPC calls, native dialog titles) are translated there, in the language last applied, so the renderer shows them as they come.
  • Formatting follows the current language: file sizes (formatFileSize(): units and decimal separator), language names (formatLanguage(), through Intl.DisplayNames), tag sorting. Pure helpers in src/lib/ call t() directly rather than taking it as a parameter; their tests switch the language with applyLanguage(), and the main-process tests that check user-facing messages run in French.
  • Flags: languageCountry() (src/lib/language-flag.ts, pure) maps a language code to the country whose flag stands for it (fr → FR, en → GB; unknown codes give null and fall back to the name). LanguageFlag (src/components/language-flag.tsx) draws it as an inline SVG from country-flag-icons (named imports, so only the listed flags are bundled; emoji flags aren't drawn on Windows). Native <option>s can't hold an SVG, so the pickers show the flag of the selected value before the <select>; the library rows show it in place of the language name.
  • Data stays untranslated: what the user stores is never translated. The quick tags are stored as Lu/À lire in every language (READ_TAG, TO_READ_TAG; isFinished() relies on Lu), and only shown through tagLabel().

Local database (src/main/db/) ​

Persistence uses Node's built-in node:sqlite (DatabaseSync): no native module to compile, and nothing stored in the cloud or in Chromium's localStorage/IndexedDB (ADR 0002). The generated schema reference lists the tables and columns.

  • Repositories take a DatabaseSync in their constructor instead of opening it themselves, which makes them testable with new DatabaseSync(':memory:') without touching Electron (app.getPath).
  • Migrations (ADR 0010): schema.ts holds an ordered MIGRATIONS list; migrate() applies those newer than the database's PRAGMA user_version, each in a withTransaction() together with the version bump. A schema change is a new entry at the end, never an edit of a shipped one; a database from a newer release (higher version) is left alone. Migration 1 is everything that predates versioning, and stays idempotent so installed databases (version 0) go through it harmlessly; migration 2 adds idx_credits_person_id; migration 3 adds library_folders; migration 4 adds the nullable avg_page_width/avg_page_height columns of library; migration 5 adds library.finished_at and the reading_sessions table (see Reading statistics); migration 6 adds achievements; migration 7 adds the users (see Users); migration 8 gives library_folders an id (see Library folders as roots). Within it, CREATE TABLE IF NOT EXISTS is used for tables; columns added after the initial release (file_count, file_size, rating, tags, then title_locked, series, volume, release_date, language, and reading_lists.position) go through addColumnIfMissing(), which checks PRAGMA table_info before an ALTER TABLE … ADD COLUMN. Existing installed databases pick them up without a destructive migration. A new column goes through addColumnIfMissing() inside a new migration.
  • Transactions: withTransaction(db, fn) (transaction.ts) wraps BEGIN/COMMIT/ROLLBACK around fn. Every write that spans several statements uses it (LibraryRepository's upsert, setCredits and remove; ReadingListRepository's upsert, reorderLists, removeEntry, remove and the position writes), so an interrupted remove() can't leave orphan credits. The same goes for clear() of both repositories and updateMetadata() (the columns and the credits are one write). updateProgress() clamps the page to [0, page_count - 1] in SQL. Calls nest: inside an open transaction (db.isTransaction) fn just joins it, and only the outermost call commits or rolls back. applyImport() takes the DatabaseSync for that reason: the whole JSON import is one transaction, so a failure midway leaves the database untouched, and it avoids one disk commit per statement (about 7.6 s for 2 000 books, 0.2 s in a transaction).
  • Tests run the real schema: schema.ts holds migrate() on its own, away from database.ts (which imports electron), so the repository tests (*.test.ts in src/main/db/) build their :memory: databases with the same migrate() as the app, and can't drift from it. schema.test.ts covers a fresh database, the upgrade of one from the initial release and the version bookkeeping.

Library (library-repository.ts) ​

One row per comic ever opened or scanned. The books and their metadata are common to every user; rating, tags, progress and last opening are the active user's (see Users). Three writers, with deliberately different rules:

MethodUsed byExisting entryrating/tags, looked-up metadata, credits
touch()comic:open (unless anonymous, see below)Refreshes title (unless title_locked), page count, file count/size; bumps the active user's last opening and clamps their current_page if the page count shrankNever touched
register()folder scanLeft completely alone (a scan is not a read)Never touched
upsert()JSON importOverwritten with the snapshot's valuesOverwritten (restoring a backup is what the user asked for)

Anonymous reading: with AppSettings.addOpenedBooksToLibrary off, comic:open (which then takes the SettingsRepository) skips touch(), the data:changed notification and the cover for a book whose path isn't in the library (hasPath()) and whose token came from the reader's picker; comic:pick-file takes an addToLibrary flag (true from the library page's "add a file"), remembered with the token, and such a token always adds the book, and returns libraryId: null, resumePage: 0. The renderer saves progress only when libraryId is set; a book already in the library behaves as usual.

updateRating()/updateTags()/updateProgress()/updateMetadata() are the dedicated writers for user-set fields.

  • updateMetadata() writes only the fields present in its MetadataUpdate (the ones the user accepted in the lookup dialog). Writing a title sets title_locked, which makes touch() keep it instead of putting the file name back; comic:open returns the library title, so the reader header shows it too.

  • The manual edit form ("Modifier la fiche", BookEditDialog in src/components/book-edit-dialog.tsx) writes through the same updateMetadata(), with no lookup involved. Its logic is pure (src/lib/book-edit.ts): validateForm() refuses an empty title, a release date that isn't a real YYYY/YYYY-MM/YYYY-MM-DD, and a credit with a first name but no last name; formToUpdate() keeps only the fields that differ from the entry (so saving an untouched form locks nothing) and, unlike a lookup's review, compares names exactly. Since people matches names with COLLATE NOCASE, a case-only rename finds the existing person and keeps its spelling.

  • The file's format (CBZ/CBR/PDF) is not stored: comicFormat() (src/shared/comic.ts) derives it from the path's extension, the same one openArchive() picks the decoder from, and the library row shows it.

  • fileCount is the archive's total entry count (all files, not just image pages); fileSize is the file's size on disk, computed with fs.stat in the IPC layer, not in ComicArchive.

  • tags is a JSON-encoded string[] column: no normalized tags table, a personal library doesn't need one.

  • People and credits are normalized, unlike tags, because people are meant to get their own page later (nationality, notes): people holds each person once, unique on (first_name, last_name) with COLLATE NOCASE; credits links a library entry, a person and a CreditRole, with a position keeping the source's order. PeopleRepository.findOrCreate() matches people by name, so two homonyms would be merged. Credits are always written as a whole list (setCredits(), from updateMetadata() and upsert()); list() loads them all in one extra query rather than one per entry. Removing an entry deletes its credits explicitly (on top of the ON DELETE CASCADE) and keeps its people.

  • volume is TEXT, not a number: issue numbers can be 12.1, HS, 1/2. release_date keeps whatever precision the source has (YYYY, YYYY-MM or YYYY-MM-DD).

  • lastOpenedPath() feeds the open dialog's defaultPath (src/main/ipc/comic.ts), falling back to the OS default when there is no history or that directory no longer exists.

Users (user-repository.ts) ​

See ADR 0019. Several people share one installation and one library. users holds each user (id, nickname, an optional avatar BLOB with its avatar_type, created_at and last_active_at); there is no password. UserRepository creates, renames and switches users and stores the picture, and activeId() is the user the app is switched to: the one with the latest last_active_at, so the app reopens on the last user (switchTo() stamps it, strictly after every earlier switch).

  • What is shared and what is not: library, people, credits, library_folders and the shared settings are common. Everything personal is keyed by user: user_books (progress current_page, rating, tags, finished_at and last_opened_at, one row per user and book, absent until the user touches the book), reading_lists.user_id, reading_sessions.user_id, user_achievements and user_settings. The columns of library that used to hold the personal values (current_page, rating, tags, finished_at; last_opened_at is now only the date added) are no longer read, since SQLite can't drop them without rebuilding the table. A book the user never touched reads as unrated, untagged, on page 0 and "last opened" when it was added (ENTRY_SELECT, library-repository.ts).
  • Repositories take a user getter: LibraryRepository, ReadingListRepository, SettingsRepository, StatsRepository and AchievementRepository get userId: () => string as a second constructor argument (default: the first user, so the tests that don't care don't pass one) and scope every personal read and write to it; main.ts passes () => userRepo.activeId(). A list or a setting of another user can't be reached by id (get() checks the owner). Removing a book deletes everyone's user_books row; LibraryRepository.clear() empties them all, ReadingListRepository.clear() only the active user's lists.
  • Migration 7 creates the tables, makes the first user (id default, nickname from the OS session name) and moves into it everything that existed: user_books from library, user_settings from settings (all of the rows; the shared keys keep being read from settings), user_achievements from achievements, and the user_id of existing lists and sessions defaults to it.
  • Shared settings: SHARED_SETTINGS (src/shared/settings.ts: the library's folders and organization, the metadata sources and keys, the resynchronization and update checks, addOpenedBooksToLibrary) are stored in settings; every other AppSettings key is per user in user_settings. SettingsRepository routes a key by isSharedSetting() and ignores a row stored on the wrong side. A new setting is personal unless it is added to SHARED_SETTINGS.
  • Profile picture: picked in a native dialog by the main process (users:pick-avatar returns the file's bytes after checking the format: the renderer never names a path), cropped to a centered square and shrunk to 256 px WebP by the renderer (shrinkAvatar(), src/lib/avatar.ts; squareCrop() is the pure part), then stored by users:set-avatar, which checks the format again from the bytes (avatarType()) and the size (MAX_AVATAR_BYTES, 256 KiB). The renderer shows it through a blob: URL (UserAvatar), like the covers.
  • IPC: users:list, users:create, users:switch, and for the active user only users:rename, users:pick-avatar, users:set-avatar, users:remove-avatar and users:remove. UserRepository.remove() deletes the user in one transaction with everything personal they own (user_books, user_settings, user_achievements, reading_sessions, their lists and list items) and refuses the last remaining user; the library is untouched, and when the active user is deleted the app moves to the user active before them (language applied as for a switch). Switching also applies the new user's language in the main process. Each write sends data:changed with { scope: 'users' }.
  • Choice screen: AppSettings.askUserOnStartup (shared, off by default) makes the root layout's UserChooser (src/components/user-chooser.tsx) cover the window once per launch (a module flag, so a route change doesn't bring it back) with the users to pick from; it switches through users:switch and its "don't ask again" box writes the setting.
  • Renderer: the users slice of the data store (useUsers(), useActiveUser()). When the active user changed, reloadUsers() drops the library, reading lists and achievements (null, "not loaded") and reloads them and the settings, ignoring any answer requested before the switch (an epoch counter), so no view shows or counts the previous user's data. The root layout remounts the achievement tracker and toasts for each user. UserMenu (sidebar) switches users and goes back to /, since the page shown (a book being read, a list) belonged to the previous one; /profile edits the nickname and picture.
  • Import / export work on the active user: an imported list whose id is owned by another user gets a new id (isForeign()).

Library folders as roots (path-roots.ts, library-folder-repository.ts) ​

See ADR 0020. library.path does not hold a machine's absolute path when the file is inside a library folder: it holds root:<folder id>/<path relative to the folder> (forward slashes). PathRoots (src/main/db/path-roots.ts) is the codec: encode() picks the deepest folder containing the file, decode() joins it with the folder's path on this machine. Where each folder id is lives in <userData>/library-roots.json (fileRootsStore()), never in the database, which may be shared between machines. A file in no folder keeps its absolute path; a stored path whose folder has no local path decodes to itself, so the book reads as missing until the folder is relinked.

  • Only LibraryRepository knows the stored form: it takes the codec as its third constructor argument, encodes every path it writes or compares in SQL and decodes every one it returns, so the rest of the code, the renderer and the thumbnails (keyed by the decoded path) only see absolute paths. adoptRoots() rewrites the absolute paths that are now inside a folder; main.ts runs it at start-up, and the IPC handlers after a folder is added, picked as the organized folder or relinked.
  • library_folders (migration 8) holds id, the path the folder had where it was added (a hint), added_at and watched. LibraryFolderRepository.list() returns the watched folders that have a path here; remove() only unwatches, since books are recorded relative to it; syncRoots() links at start-up the folders whose recorded path exists on this machine; unlinked() lists the others, and library:list-unlinked-folders / library:relink-folder (the main process opens the dialog; the renderer only names the id) let Paramètres › Bibliothèque say where they are.
  • A resynchronization never removes the books under an unlinked folder: they aren't under a walked folder, and their parent directory isn't readable.

Reading lists (reading-list-repository.ts) ​

reading_lists holds each list (id, name, created_at, and its position among the lists); reading_list_items links a list to library entries with a position, unique per (list, entry). A list holds at most MAX_READING_LIST_SIZE (50) books, enforced by addEntry(). Refusals (full list, empty name, unknown list, stale order) come back as a ReadingListResult error.

  • Order of the lists: list() sorts on position, then created_at. A new list (create(), or a new one from upsert()) goes after MAX(position); lists created before the column existed all share position = 0, so they keep their creation order until the first reorder. The sidebar's drag and drop (ReadingListsNav in src/routes/__root.tsx, through the pure moveItem()) sends the whole new order to reorderLists(), which, like reorder() for books, refuses an order that doesn't hold exactly the current lists (ReadingListOrderResult). The import applies the snapshot's order (its lists first, then the local-only ones), the export's readingLists being in list() order.

  • Adding from the library by drag and drop: the library page's rows carry it on a grip handle (GripVertical, at the left of the row, highlighted on hover; the drag image is the whole row) and nowhere else, so the rest of the row can open the book on click and the stars, buttons and tag field keep their mouse handling. They carry the book under the custom LIBRARY_ENTRY_DRAG_TYPE (src/lib/reading-list.ts). ReadingListsNav only accepts a drop on a list when the drag holds that type, which tells it from one of its own lists being reordered and from files dragged in from outside; it calls addEntry() (already in the list is a no-op there) and shows dropFeedback()'s message under the lists for a few seconds.

  • Adding several books (a series, or the library page's selection): addEntries() appends them in the given order in one transaction and answers a ReadingListAddResult counting the books added, alreadyIn, left out once the list is full (overLimit) and no longer in the library (missing); a full list is not an error there, it takes what fits. The renderer goes through bulkAddRequest() first (src/lib/reading-list.ts), which sends only the books the list doesn't hold yet, cut to the room left, so a selection of thousands of books stays a short call, and bulkAddFeedback() turns both into the message. seriesEntries() lists a book's series (names compared ignoring case and accents, natural volume order). The selection itself is page state (toggleSelection(), selectedEntries() in src/lib/library-selection.ts): ids kept across filter changes, Shift-click ranges over the rows shown, sent in screen order then library order for the hidden ones. A drag from the grip of a selected book carries the whole selection (draggedEntries()): the drag data is always an array (encodeDraggedEntries()/decodeDraggedEntries()), and ReadingListsNav sends one book through addEntry() and dropFeedback(), several through bulkAddRequest(), addEntries() and bulkAddFeedback().

  • "Finished" is derived, never stored: isFinished() (src/lib/reading-list.ts) is true once the last page was reached or the entry carries the Lu tag. A list's progress, its next book (nextToRead()) and the greyed-out rows are all computed in the renderer from the library:list entries, so they can't drift from the reading progress.

  • Display of a list's page: AppSettings.readingListView (a BookRowView, separate from libraryView) picks how much each book row shows, through the same ViewModePicker, BookDetails, BookRating and BookTags as the library page (see Library search & filters). Only a row's grip is draggable for reordering, as in the library, so the tag field and the click that opens the book don't start a drag.

  • Reordering rule: finished books are pinned. moveUnfinished() only swaps unfinished books among the slots they occupy, each finished book keeping its index; the repository's reorder() only checks that the new order holds exactly the list's current entries (a stale order is refused rather than dropping or adding books).

  • Deletions: like credits, items are deleted explicitly (LibraryRepository.remove(), ReadingListRepository.remove()), since PRAGMA foreign_keys is off and the ON DELETE CASCADE doesn't apply. Removing a library entry leaves a gap in the positions, which is why addEntry() appends after MAX(position) rather than at the list's length.

  • Loading lists with the library: the two list pages and the reader's next-book lookup read both from the data store (useReadingLists(), useLibrary()) and recompute when either changes; nothing fetches them itself.

  • Next book in the reader: the list pages open the reader with a list search param; useNextInList() then finds the list's first unfinished book other than the current one (whose progress may not be saved yet), shown on the last page. It shows nothing when the current book isn't in that list (another file opened from the reader).

Reading statistics (stats-repository.ts) ​

The statistics page (/stats, src/routes/stats.tsx) shows the library's totals and the reading history by month or year.

  • What is stored: user_books.finished_at (ISO timestamp, per user), set once by LibraryRepository when updateProgress() reaches the last page or updateTags() adds Lu (READ_TAG, src/shared/library.ts) and never changed afterwards, and reading_sessions (user id, library id, local day, seconds), one row per report; stats:get and the completion dates (user_books.finished_at) are the active user's. There is no backfill for the books read before migration 4: their completion date and reading time are unknown, so they count in the totals (derived from the data store with isFinished()) but not in the charts.
  • Reading time: useReadingTime() (src/hooks/use-reading-time.ts), called by the reader route with the book's library id (null for an anonymous read), checks every 5 s that the document is visible and focused and feeds createReadingTimeAccumulator() (src/lib/reading-time.ts, pure), which calls stats:add-reading-time about once a minute and when the reader closes. The handler accepts 1 to 3 600 seconds per call and StatsRepository.addReadingTime() stamps them with the local day.
  • Reading the history: stats:get returns a ReadingStats (src/shared/stats.ts): the completion timestamps and the seconds per day, oldest first. The page fetches it when it opens and when the library slice changes. It is not in the data store (only this page shows it), so the two stats channels send no data:changed. buildBuckets() (src/lib/reading-stats.ts, pure and tested) groups it by local month (the last 12) or year, keeping empty periods, and BarChart (src/components/stats/bar-chart.tsx) draws the bars in SVG, with no charting dependency.
  • Profile history: stats:history (StatsRepository.history()) returns the active user's reading_sessions grouped by day and book (ReadingHistoryEntry, most recent first, READING_HISTORY_LIMIT lines, the title null for a book removed since) for the profile page, which reads it when it opens (not in the data store); stats:clear-history (clearHistory()) deletes that user's sessions only, so the statistics' reading time goes too, but finished_at stays.
  • Lifecycle: LibraryRepository.remove() keeps the book's sessions (the time was spent), clear() empties reading_sessions. The history is not exported (buildExport() / applyImport() don't touch it).

Settings (settings-repository.ts) ​

A generic key/value store (JSON-encoded values) for AppSettings, merged with DEFAULT_SETTINGS on read, so a new setting key needs no migration. Most keys are per user (user_settings); SHARED_SETTINGS are common to everyone (settings), see Users. Each stored row is checked on read (isSettingKey(), SETTING_VALIDATORS): an unreadable, unknown or invalid row counts as not stored, so a damaged value can't stop the app from starting.

Achievements (achievement-repository.ts) ​

See ADR 0014. user_achievements holds one row per user and earned achievement (user_id, id, unlocked_at; the table achievements of migration 6 is read only by migration 7); AchievementRepository.unlock() is INSERT OR IGNORE, so a date never changes and it returns only the newly earned ones. achievements:unlock (src/main/ipc/achievements.ts) validates every id against ACHIEVEMENT_IDS (src/shared/achievements.ts), stores them and notifies { scope: 'achievements', unlocked }. Nothing is deleted when a book or a list goes, data:clear-library keeps them, and they are not exported.

Database location (db-location.ts) ​

The location can't be an AppSettings entry, since the settings are stored in the database. It is a tiny db-location.json pointer file that always stays in userData (ADR 0003).

  • A missing or malformed pointer silently falls back to userData rather than throwing, so a hand-edited (or sync-mangled) file can't stop the app from starting.
  • The functions are pure (they take the userData path, never call app.getPath), which makes them testable; database.ts is the only bridge to Electron.
  • Changing the location only rewrites the pointer: the existing file is not moved, because pointing at a directory that already holds a tankobon.db (a synced folder, another machine) must keep that database. The open database stays open until the next start (database:relaunch).

Export / import (export-service.ts, import-service.ts) ​

  • buildExport() snapshots library, reading lists and settings as { version: 1, exportedAt, library, readingLists, settings }, written by data:export through a save dialog, whose default file name (exportFileName()) carries the local date and time. The library entries carry their looked-up metadata and credits; reading lists reference their books by path. The format stays version: 1, since those fields are only additions (an older export comes back with them empty). The settings include the metadata API keys.
  • parseExport() validates a user-picked file: anything that isn't a version: 1 export is rejected; broken library entries are dropped (including those whose path is not an absolute path of a supported comic: an edited export must not name arbitrary files, which are later opened and, by the library organization, moved), as are credits without a last name or with an unknown role; only known settings keys whose value type matches the default are kept. An export is a file the user can edit, so nothing in it is trusted.
  • applyImport() merges it: entries match on path, not id (ids aren't stable across machines); the snapshot wins for the entries it contains; entries only present locally are untouched; settings are replaced wholesale. Reading lists match on id (random UUIDs, so the same list keeps its id on every machine) and are replaced by the snapshot's; their paths are resolved to the local entries after the library is merged, and a path with no entry is dropped.
  • Cover thumbnails are not exported: after applyImport(), data:import starts rebuildThumbnails() (ThumbnailCache.rebuild() in the decoder process) in the background (see Cover thumbnails) and returns without waiting for it.
  • Clearing the library (data:clear-library): ReadingListRepository.clear() deletes every list, then LibraryRepository.clear() empties library, credits, people and reading_list_items (people only exist through credits, so none would be left referenced); ThumbnailCache.prune([]) then deletes every thumbnail. Settings, the database location and the files on disk are left alone. The renderer asks for confirmation, showing the counts from its data store; the handler's data:changed events make the library page and the sidebar drop what was deleted.

Decoder process ​

See ADR 0012. Parsing a comic file (ZIP, RAR through wasm, PDF through pdf.js) and decoding its images (@napi-rs/canvas) happens on untrusted input, so it runs in an Electron utilityProcess rather than in the main process: a file that crashes a native decoder or exhausts memory stops that process, not the app and its database.

  • Layout: src/main/decoder/decoder-worker.ts is the process's entry (its own bundle, decoder-worker.cjs, built from a second target: 'main' entry in forge.config.ts with vite.decoder.config.mts); decoder-handlers.ts holds what each call does (a ComicService and a ThumbnailCache, free of Electron so it can be tested); protocol.ts types the messages, DecoderMethods being the list of calls; decoder-client.ts is the main side; spawn-decoder.ts is the only file that touches utilityProcess and MessageChannelMain.
  • Calls: open, readPage, close (the reader), inspect (the scan), measure (the page size measure), and the thumbnail calls (thumbnail, removeThumbnail, pruneThumbnails, rebuildThumbnails). Each request carries an id, the answer the same id with a result or the error message; requests run concurrently in the process, so a library page asking for covers doesn't wait behind a page render. The IPC handlers of src/main/ipc/ call DecoderClient and nothing else touches archives.
  • Lifecycle: started on the first call, and told the thumbnails directory and the current language (init; later language, sent by applyMainLanguage() through onMainLanguageApplied()). When it exits, calls in flight reject with errors:decoder.crashed ("Fichier illisible…") and the next call starts a new one. The reader's archives die with it: the next page request fails with "unknown archive" until the book is reopened. It is killed on will-quit.
  • Hangs and leaks (ADR 0018): open, inspect and measure are bounded by OPEN_TIMEOUT_MS (60 s); one still unanswered kills the process like a crash (the hung call fails with errors:decoder.timeout, the others with errors:decoder.crashed) and the next call starts a new one. The comic:open handler records which window opened each archive (ArchiveOwners) and closes the ones left open when that window is destroyed; ComicService keeps at most MAX_OPEN_ARCHIVES (8), closing the oldest first.
  • Pages between processes: a page's bytes are copied by structured clone (a utility process can only transfer ports), then once more by ipcMain.handle to the renderer.
  • Errors keep their language: the decoder process has its own i18next instance, so its messages (errors:archive.*) are translated there, in the language the main process last sent.

Comic archives (src/main/services/) ​

ComicArchive (comic-archive.ts) is the format-agnostic interface (pages, fileCount, readPage(), close()), implemented by CbzArchive (node-stream-zip), CbrArchive (node-unrar-js) and PdfArchive (pdfjs-dist).

  • fileCount counts every non-directory entry; pages is filtered and naturally sorted down to image entries (hidden files and __MACOSX/ resource forks excluded). They commonly differ (a ComicInfo.xml sidecar counts in fileCount); for PDFs they are always equal.
  • openArchive() (comic-service.ts) picks the implementation from the file extension.
  • ComicService keeps opened archives alive between IPC calls, addressed by an opaque randomUUID(), distinct from the persistent library id. It lives in the decoder process, like everything on this page.

CBZ: closing while reading ​

CbzArchive.close() waits for in-flight readPage() calls to settle before closing the zip, and readPage() refuses to start once closing has begun. node-stream-zip closes its file descriptor immediately, and a read caught mid-way fails with EBADF on an internal stream whose error never reaches the entryData() promise: it becomes an uncaught exception in the decoder process. This happens routinely from the renderer (switching books while a page loads, leaving continuous mode while preloads run); see cbz-archive.test.ts.

CBR: the wasm file ​

node-unrar-js's Emscripten glue locates its .wasm relative to its own __dirname, which breaks once Vite bundles it into decoder-worker.cjs. vite.decoder.config.mts copies unrar.wasm next to the bundle (vite-plugin-static-copy), and cbr-archive.ts reads it itself and passes it as wasmBinary. A CBR is read fully into memory when opened.

CBR: one wasm module for every archive ​

All node-unrar-js extractors share a single wasm module, and each createExtractorFromData() points that module at the extractor just created. An extractor kept from open() and reused later would, once another CBR has been opened (a second book, a folder scan), read the other archive's bytes: File is not RAR archive, then a TypeError in getFiles. So CbrArchive keeps only the file's bytes, and every operation (open()'s listing, each readPage()) creates a fresh extractor and drains it synchronously, through a module-level queue so that no other creation can slip in between; see cbr-archive.test.ts.

PDF rendering ​

See ADR 0004. PdfArchive rasterizes each page on demand at a fixed RENDER_SCALE of 200/72 (~200 DPI) through pdfjs-dist's legacy build, returning PNG bytes.

  • Page cache and prefetch: rendering a page is the slow part of turning a PDF page (a CBZ page is just extracted). PdfArchive.readPage() keeps the PAGE_CACHE_SIZE (5) most recently used renders, as promises so that concurrent requests share one render, and after each read renders the next and previous pages in the background (errors ignored, nothing started once close() has begun), so the usual page turn finds its page ready.
  • Page size ceiling: a PDF can declare a page of thousands of points per side in a few hundred bytes, and the continuous mode renders several pages at once. pageRenderScale() therefore lowers the scale below RENDER_SCALE so that a page never exceeds MAX_PAGE_PIXELS (40 megapixels), computed from the page's size at scale 1. Likewise CbzArchive.readPage() and CbrArchive.readPage() refuse an entry whose declared size (the ZIP entry's size, the RAR header's unpSize) is above MAX_ENTRY_SIZE (200 MB, comic-archive.ts) with a translated error, before extracting anything.
  • Image dimensions ceiling (CBZ/CBR): a PNG of 30 000 × 30 000 px compresses to a few kilobytes, passes the MAX_ENTRY_SIZE check, and would allocate about 3.6 GB when decoded (loadImage() in renderThumbnail(), then the renderer's <img>). readImageSize() (src/main/services/image-size.ts) therefore reads the size from the PNG/JPEG/GIF/WebP header, without decoding, and CbzArchive/CbrArchive.readPage() refuse (through checkedPage()) a page above MAX_IMAGE_PIXELS (100 megapixels) with a translated error; renderThumbnail() applies the same check. A format whose header isn't read (AVIF, BMP) is let through.
  • pdf.js references DOMMatrix/Path2D/ImageData/Image as bare globals; pdf-archive.ts installs @napi-rs/canvas's implementations on globalThis once.
  • page.render() is given the canvas object itself, not just a canvasContext.
  • pdf.js must believe it runs in Node. pdf.js sets isNodeJS once, at module load, and says no in an Electron process whose process.type isn't browser: a utilityProcess (utility) is one. It then takes the browser paths and every PDF fails with No "GlobalWorkerOptions.workerSrc" specified. Only the packaged app showed it (Vitest runs in plain Node). pdf-archive.ts therefore hides process.type while it imports pdf.js (importPdfjs()).
  • pdfjs-dist's package directory is found with the plain CommonJS require.resolve('pdfjs-dist/package.json'), not createRequire(import.meta.url): the main bundle is CommonJS, where Rollup rewrites import.meta.url to undefined, which would crash at module load time (the app would not start at all).

Cover thumbnails ​

See ADR 0007. ThumbnailCache (thumbnail-cache.ts) keeps a WebP of each book's first page, shrunk by renderThumbnail() (@napi-rs/canvas) to fit in 240 × 360 px, as a file in <userData>/thumbnails/ named after a hash of the book's path (thumbnailKey()). It is a pure cache: nothing in the database points at it, it stays in userData even when the database is moved, and any missing file is regenerated.

  • Where: in the decoder process; the main process only calls DecoderClient's thumbnail methods.
  • Writers: the folder scan, through the decoder's inspect (which opens the archive, counts its pages and caches the cover from it: storeFromArchive()); comic:open, in the background, from the reader's archive (ensure(path, readFirstPage)), so opening a book never waits for its cover; library:thumbnail, on demand, opening the file itself when the thumbnail is missing; rebuild() after a JSON import.
  • One queue: every generation (and prune()) is serialized through a promise chain, one book at a time, so a library page asking for hundreds of covers or a rebuild never opens archives in parallel; concurrent requests for the same book share one generation. rebuild() awaits each book in turn, so the library page's requests interleave with it instead of waiting for the end.
  • A file that couldn't be opened is remembered for the session (until the next rebuild()), so a missing file isn't reopened every time the library is shown. A failure of a given page reader (the reader closed the book meanwhile) is not remembered.
  • Files are written aside then renamed. prune() deletes everything that isn't the thumbnail of a library book, temporary files included; it runs at start-up, in rebuild() and, with an empty list, when the library is cleared. library:remove deletes the book's thumbnail, through the same queue as the generations: a removal during a generation of that book waits for it, so no orphan file is left behind.
  • Renderer side: BookCover (src/components/book-cover.tsx) asks library.thumbnail(id) only once its row comes within 400 px of the viewport, and shows the bytes through a blob: URL (allowed by the CSP's img-src), revoked on unmount.

Metadata lookup (src/main/services/metadata-service.ts) ​

The "Rechercher les infos" action of the library looks a book up in public APIs (ADR 0005). The requests are made by the main process (metadata:search, src/main/ipc/metadata.ts), never by the renderer: the API keys stay out of the renderer, there is no CORS to deal with, and the renderer's CSP (index.html) still allows no connection beyond 'self'.

  • searchMetadata() queries the enabled sources in parallel (ComicVineClient only when it has an API key, GoogleBooksClient always when enabled) and normalizes their results to MetadataCandidate. A failing source adds its message (in the interface language) to errors next to the other source's results; status: 'error' is only for nothing configured, an empty query, or every source failing. rankCandidates() puts the candidates matching the requested volume first.
  • Comic Vine (comic-vine.ts): with a volume number, it searches the series (resources=volume) then that issue number in the top 3 series (/issues/?filter=volume:…,issue_number:…), else (or when that finds nothing) runs a plain issue search. The search results don't include credits, so each of the (at most 5) issues gets a detail request. All requests are sequential, since Comic Vine flags bursts. Its role names map to CreditRole (penciler → artist; editor is dropped). It needs a non-generic User-Agent, hence Tankobon/<version>.
  • Google Books (google-books.ts): one full-text search. It has no series field and doesn't tell writers from artists, so series and volume are read from the title when it spells them out (splitSeriesTitle()), and every author gets the generic author role.
  • Bédéthèque (bedetheque.ts, ADR 0006): not a search source. A pasted album-page link (isBedethequeAlbumUrl(), src/shared/metadata.ts) goes through metadata:from-page → fetchMetadataPage(), which downloads the page and parses only its main album block (<section class="bdt-ah">) with regular expressions: series from the <h1>, number and title from the bdt-ah-sub subtitle, credits from liste-auteurs (role labels mapped to CreditRole, placeholders such as <N&B> dropped), the legal deposit as release date (the exact day only when the page spells it out, since its datePublished meta pads an unknown day with 01). Placeholders carry raw </> in their title attribute, so tag patterns skip quoted attribute values as a whole.
  • http-json.ts holds the network helpers: a JSON GET (getJson()) and an HTML GET (getHtml()), with a timeout and "<Source>: …" errors in the interface language (unreachable, timeout, exhausted quota, missing page, the API's own message). The IPC layer passes it Electron's net.fetch, which goes through Chromium's network stack and honours the system proxy; tests pass a fake fetch. A body is read through a size cap (MAX_RESPONSE_BYTES, 5 MB: refused from its Content-Length, or while streaming), and a redirect that ends on another site than the requested one (www. ignored) is refused, since the sources' addresses are fixed.
  • The pure text helpers are shared with the renderer (src/shared/title-parsing.ts): guessQueryFromTitle() prefills the dialog's search from a file name (bracketed tags dropped, volume from "T03"/"Vol. 2"/"#12" or a standalone 1–3 digit number, so a bare year isn't taken for a volume), splitPersonName() turns "Jean Van Hamme" into first/last name (particles stay with the last name, a single word is a last name).
  • Renderer side: MetadataDialog (src/components/metadata-dialog.tsx) runs search → pick a candidate → review → library.updateMetadata(); a Bédéthèque link in the search field calls metadata.fromPage() instead and goes straight to the review. The review logic is pure (src/lib/metadata-review.ts): buildReview() lists only the fields the candidate knows, pre-accepted when they'd change something, plus the current credits (kept) and the candidate's new ones (added); reviewToUpdate() turns the accepted rows into a MetadataUpdate (an emptied field is cleared, an emptied title is ignored, credits become exactly the accepted rows). Cover thumbnails are loaded straight from the sources, which is why the CSP in index.html allows img-src from comicvine.gamespot.com, books.google.com and www.bedetheque.com (and nothing else).

Build gotchas ​

These are the non-obvious constraints of the main-process bundle. Breaking one usually doesn't fail the build, only the packaged app.

ConstraintWhereWhat breaks otherwise
package.json's main is .vite/build/main.cjs, and src/main.ts loads preload.cjspackage.json, src/main.tsForge 8's Vite plugin emits the main and preload bundles as .cjs: electron-forge package refuses a main ending in .js, and a stale preload path leaves window.tankobon undefined.
node:sqlite listed in build.rollupOptions.externalvite.main.config.mtsIt isn't in Node's builtinModules yet, so Vite bundles an empty stub: DatabaseSync is silently undefined at runtime.
unrar.wasm copied next to decoder-worker.cjsvite.decoder.config.mts, cbr-archive.tsCBR files can't be opened.
pdfjs-dist and @napi-rs/canvas externalvite.decoder.config.mts@napi-rs/canvas is a native .node binary Rollup can't inline; pdf.js's legacy build is a foreign webpack bundle Rollup can't safely re-bundle. Being real packages also ships pdf.js's standard_fonts/cmaps for free.
hooks.packageAfterCopy copies pdfjs-dist, @napi-rs/canvas and the installed @napi-rs/canvas-<platform>-<arch>forge.config.tsThe Forge Vite plugin only packages its build output plus package.json, never node_modules: PDFs fail in the packaged app. Only the platform package matching the machine that ran npm install exists, so a package must be built on its target platform.
AutoUnpackNativesPluginforge.config.tsThe native binary would stay inside the asar archive, which can't be dlopened.
packagerConfig.icon (assets/icon, without extension), extraResource: ['assets/icon.png'], the deb/rpm options.icon and Squirrel's setupIconforge.config.ts, src/main.tsNo app icon: Packager picks .ico/.icns by platform, but Linux has no embedded icon, so the window icon is the PNG copied next to the app and passed to BrowserWindow (windowIcon), and the deb/rpm menu entry needs its own icon option. The sources are assets/icon.svg and the files generated by npm run icons.
packagerConfig.executableName is tankobon on Linuxforge.config.tsPackager names the Linux binary after productName (Tankōbon), while the deb and rpm makers look for name (tankobon): npm run make fails on Linux with "could not find the Electron app binary".

Renderer ​

Routing (src/routes/) ​

File-based routes via TanStack Router, with an in-memory history (createMemoryHistory in src/renderer.tsx). The app starts on the home page (/, src/routes/index.tsx, with five blocks: "continue reading" (continueReading(), src/lib/continue-reading.ts) one that lists the RECENT_BOOKS_COUNT (5) most recently added books from useLibrary() through the pure recentlyAdded(), src/lib/recent-books.ts, each linking to /reader?book=<id>, and one that suggests five random unread books, drawn by the pure pickRandomUnread() in src/lib/home-picks.ts and kept in page state until the user redraws; and one listing the five lowest-rated books (lowestRated(), src/lib/second-chance.ts: rated 1 to LOW_RATING_MAX (2), worst first), drawn as covers with a BookRating under each so the rating can be changed in place; and "favorites", the 5 best-rated books through the pure topRated(), src/lib/home-favorites.ts, shown with their stars; more blocks will be added). The blocks are the HOME_SECTIONS of src/shared/settings.ts; their order and visibility are AppSettings.homeSections, resolved by src/lib/home-sections.ts (a block added by a later release shows up at the end, shown), and AppSettings.homeEnabled off removes the sidebar entry and makes / redirect to /library; both are edited in settings/home.tsx. Each HomeSectionSetting also carries an optional count (HOME_SECTION_MIN_COUNT 3 to HOME_SECTION_MAX_COUNT 20, default 5, checked by isHomeSections()) and hideWhenEmpty (default per block in HOME_SECTION_HIDE_WHEN_EMPTY_DEFAULT: on for the blocks that had no empty state before); resolveHomeSections() fills in what a stored block lacks, so no migration is needed, and gives the blocks a ResolvedHomeSection. The selection helpers take that count, and BookCarousel (src/components/book-carousel.tsx) lays a block out as one non-wrapping row scrolled by arrows. A new block is an id in HOME_SECTIONS, a component in SECTION_VIEWS (src/routes/index.tsx) and its label under settings:home.sections. The library is /library; the active user's profile is /profile (see Users). src/routeTree.gen.ts is generated by @tanstack/router-plugin on npm start/build: never edit it by hand. It is committed, because npm run typecheck (and CI) need it without running Vite. validateSearch on a route needs no manual wiring in it. The reader route (/reader) accepts an optional book search param (a library entry id or a comic:pick-file token, never a path), used by the library page to open a specific book, and an optional list (a reading-list id, see Reading lists). The reading lists are /lists (lists/index.tsx) and /lists/$listId (lists/$listId.tsx, which sets remountDeps on its params: the router would otherwise keep the page mounted when only the list changes, and a rename in progress would be applied to the newly selected list).

Settings sections ​

src/routes/settings.tsx is a layout route (title + <Outlet/>); settings/reading.tsx, settings/appearance.tsx, settings/home.tsx, settings/library.tsx (folders, resynchronization), settings/metadata.tsx (lookup sources and API keys), settings/data.tsx and settings/about.tsx ("À propos") are the sections; settings/index.tsx only redirects to the first one. The section list is SETTINGS_SECTIONS (src/lib/settings-nav.ts), shared with the sidebar so the two can't drift. Adding a section means a file in src/routes/settings/, a line in SETTINGS_SECTIONS and its label under settings:sections in both locales. settings/appearance.tsx also holds the interface language picker.

In the sidebar (src/routes/__root.tsx), SettingsNav unfolds those sections: clicking "Paramètres" while folded unfolds and navigates; while unfolded it only folds (it preventDefault()s the Link). The unfolded state is transient component state. ReadingListsNav does the same for the reading lists, with one difference: /lists is a real page, so its label always navigates (and unfolds) and only the separate chevron button folds. Its lists come from useReadingLists() (src/hooks/use-reading-lists.ts), a slice of the data store: every write to the lists makes the main process send data:changed, so the sidebar and the /lists pages never show a stale name and no view has to signal anything. AppSettings.sidebarCollapsed (a persisted setting) switches the whole sidebar to an icon-only rail, which hides the sub-entries.

Renderer data store ​

See ADR 0013. DataStore (src/lib/data-store.ts, pure, unit-tested with a fake DataSource) holds the library, the reading lists, the achievements, the users and the settings; its single instance appData (src/lib/app-data.ts) is started by src/renderer.tsx before the first render (the settings are awaited, for the language; the other two load in the background and read null until then). Views never fetch these themselves: they use useLibrary(), useReadingLists(), useSettings() and useUsers() (src/hooks/), built on useSyncExternalStore, one subscription list per slice. What the hooks return is shared and immutable.

  • data:changed (DATA_CHANGE_CHANNELS, src/main/ipc/data-changes.ts) is pushed by broadcastDataChange() to every window. The IPC handlers receive it as a NotifyDataChange parameter and call it right after a write, so the repositories stay Electron-free. The payload (DataChange, src/shared/data-changes.ts) is a scope plus, for the library, the upserted entries and removed ids (rating, tags, progress, metadata, opening a book, removing a book) or nothing for a bulk write (folder scan, import, clearing), which reloads the slice; the reading lists always reload; a setting carries its written values (never an API key, which stays out of the store like it stays out of settings:get-all: the slice holds PublicSettings), and an import reloads them all. Adding a write path means adding its notify(…) call, nothing in the views.
  • applyLibraryChange() applies a targeted change and keeps the lastOpenedAt DESC order of library:list: an entry whose opening date didn't change is replaced in place, one that did (a book just opened) or a new one goes where its date puts it.
  • Optimistic writes: patchEntry() (rating, tags) and updateSetting() change the store at once, then persist. While such a write is in flight its key is pending and the data:changed echo for it is ignored (the event reaches the renderer before the call's reply), so a slider being dragged is never moved back by an older value. If the write is refused (the call rejects), the slice is reloaded so the optimistic value doesn't outlive it, and the error is rethrown (the views ignore it). Other writes (list changes, removals, metadata saves) simply wait for their event.
  • Language: the store knows nothing about i18n; keepInterfaceLanguageInSync() (src/hooks/use-settings.ts) subscribes to the settings slice and applies the language setting when it changes.

Achievements (renderer) ​

The rules are pure and tested (src/lib/achievements.ts): computeStats() counts finished books, library size, formats, folders, lists, ratings, tags, documented books and languages from the data store; ACHIEVEMENT_RULES gives each achievement its group, target and counter, and earnedFromStats() lists those reached. The few earned by an event (nightOwl, earlyBird, rightToLeft) have no counter: eventAchievements() decides them when a book is opened. useAchievementTracker() (root layout) sends the newly reached ones to achievements:unlock; useOpeningAchievements() (reader) does the same on each opened book; the achievements slice of the store receives data:changed with the new ones, and AchievementToasts announces what appears after the first load. The folder count comes from library:list-folders, re-read whenever the library changes. Both are counted from the library as it was when it last stayed unchanged for a second (useSettledValue(), src/hooks/use-settled-value.ts): every page turned in the reader saves the progress and so replaces the whole library, and counting 20 000 books (about 25 ms) plus an IPC call at each page would be a hiccup per turn for nothing, since no achievement is time-critical. Badges are inline SVG (AchievementIcon), the accent being --achievement (src/index.css). A new achievement is an id in ACHIEVEMENT_IDS, a rule, a drawing in AchievementIcon and its achievements:items.<id> texts in both locales.

Confirmations ​

Destructive actions are confirmed in an in-app modal, never with window.confirm() (a native window that ignores the app's theme and language). useConfirm() (src/components/confirm-dialog.tsx) returns confirm({ title, message, action }), which resolves true on confirmation and false on cancel, Escape or a click on the backdrop, and a dialog element to render once in the view; it is built on the shared Dialog. Used for removing a book from the library and deleting a reading list.

Library search & filters (src/lib/library-filter.ts) ​

The whole library is in the data store and is filtered client-side, in memory; the target is 20 000 books or more, and moving the filters into SQL is the next step beyond that, not needed yet. The logic is pure (unit-tested under Vitest's node environment, no DOM):

  • search matches the title, the file path (so a series folder name finds its albums), the series and the credited people's names, normalized with NFD + diacritic stripping ("pokemon" matches "Pokémon"). Each entry's normalized text is cached in a WeakMap keyed by the entry object (entries are replaced, never mutated, so it can't go stale), and the search text is normalized once per filtering, not once per entry;
  • read/unread is a tag filter: Lu/À lire are the library page's QUICK_TAGS. availableTags() keeps them pinned in front even before anything carries them; selecting several tags requires all of them;
  • the rating filter is a minimum (3 stars keeps 3–5).

AppSettings.libraryView (a LibraryView: full/medium/compact, or grid, persisted like any setting) picks how much each row shows, or the grid of covers (a reading list's readingListView stays a BookRowView, its picker not offering the grid); the toolbar's ViewModePicker writes it through useSettings(), and the virtualizer's estimated row height depends on it (rows are measured anyway). The parts of a row that depend on it live in src/components/book-row.tsx, shared with a reading list's page: ViewModePicker, BookDetails (cover, title, details and status line), BookRating and BookTags (which write the rating and tags themselves, optimistically through appData.patchEntry(); toggleTag() and QUICK_TAGS are in src/lib/reading-list.ts). Each page keeps its own row layout and actions around them.

Several formats of one book: editionGroups() (src/lib/library-editions.ts, pure and tested) links the entries that are the same book in different formats (editionKey(): series and volume when both are known, else the normalized title; comicFormat() tells the formats apart, two files of one format are never linked), computed from the whole library; collapseEditions() then keeps one row per book among the filtered entries, before sorting or grouping. It is display only: each file stays its own entry, with its own user_books row, and the row offers one open button per edition. No schema change.

AppSettings.libraryGroupBySeries (off by default, the toolbar's "Regrouper par série" toggle) groups the filtered entries by series: groupBySeries() (src/lib/library-groups.ts, pure and tested) turns them into a flat list of LibraryRows, a SeriesHeaderRow (series name, count, finished count through isFinished()) followed by the series' books, so the virtualizer still sees one list of rows of varying height. Series are matched case-insensitively on the trimmed name (seriesKey()), sorted with a numeric Intl.Collator, the books without a series last; within a series, books follow their volume in natural order, then their title. Which series are folded is page state (a Set of keys), not a setting; a folded series keeps only its heading row. With grouping off, ungroupedRows() lays out the entries as sortEntries() (src/lib/library-sort.ts, pure and tested) orders them, following AppSettings.librarySort (one of LIBRARY_SORTS, src/shared/settings.ts; the toolbar's SortPicker): lastOpened keeps the lastOpenedAt DESC order of the store, the others sort a copy by addedAt, releaseDate (compared as strings, so 2003 < 2003-05 < 2003-05-12; books without one last both ways) or title (numeric Intl.Collator), each both ways, ties going to the title. The grouped view ignores the setting (the picker is disabled), its order being part of the grouping.

In the grid, gridLines() (src/lib/library-groups.ts, pure and tested) cuts the same rows into CardsRows of as many books as fit the width (useGridColumns(), from a ResizeObserver on the scroll area, cards at least 150 px wide), a series heading keeping its own line, so the virtualizer still sees one list of lines of varying height; BookCard (src/components/library/book-card.tsx) draws a book.

The route (src/routes/library.tsx) keeps the page state and the virtualized rows; the toolbar and its parts (LibraryToolbar, SortPicker, RatingFilterPicker), the SelectionBar, the ScanProgressBar and the SeriesHeader are components of src/components/library/.

The library page (src/routes/library.tsx, at /library) draws only the rows near the viewport (@tanstack/react-virtual, which measures each row since their height varies with wrapping tags and the optional metadata line), inside its own scroll container below the toolbar; it filters with useDeferredValue so the search field never waits for the list, and scrolls back to the top when the filters change. A row scrolled out of view is unmounted, with its local state (a half-typed tag).

Folder scanning ​

library:add-folder opens a directory picker, then scanIntoLibrary() (src/main/services/library-scanner.ts) walks it recursively for supported files. Unreadable directories are skipped; symlinks are never followed (Dirent.isDirectory() is false for them, which also rules out cycles). Learning a page count means opening each file (in the decoder process, through inspect: the scanner only owns the walk and the database), which is the slow part: progress is pushed file by file over library:scan-progress. The renderer subscribes via library.onScanProgress() for the whole life of the library page, not around each call, because progress starts as soon as the directory is picked. A file that won't open only counts as failed.

The folder dialog opens in the previous scan's directory: library:add-folder stores it in the lastScanFolder setting (empty until a first scan) and passes it as defaultPath through existingDirectory() (src/main/ipc/dialogs.ts), which drops a directory that no longer exists, as the open-file dialog does with the last opened book's directory.

Adding a single file (library:add-file, "Ajouter un fichier") goes through the same path as one scanned file: the file dialog opens in the last opened book's directory, inspect in the decoder process, then register(), never touch(); it does not open the reader. The result (AddFileResult) says added, exists (left untouched) or error (translated message), and an added file makes the main process push a library reload.

Scanned files go through register(), never touch() (see the table above), and hasPath() lets the scanner skip known files without opening them. Each new file's cover thumbnail is cached by inspect, while its archive is still open (see Cover thumbnails). New rows get last_opened_at = added_at, which puts a fresh batch at the top of the list.

Library organization ​

See ADR 0015 ADR 0016 and ADR 0017. AppSettings.libraryOrganization (off/ask/always) and libraryOrganizationFolder decide whether added comics are moved into a folder the app lays out, and libraryStructure and libraryFileName how (organizedPaths(), src/main/services/library-organizer.ts, given an OrganizationTarget: the folder, the structure and the file name template).

  • Default layout (empty libraryStructure): one subfolder per comic, <folder>/<name without extension> (<library id>)/<file name>, the name cut to fit MAX_NAME_BYTES.

  • Levels: otherwise, one to three LibraryLevels (isLibraryStructure(), src/shared/settings.ts: distinct, letter/year first only, letter followed by another level). structureFolders() names one folder per level from the entry (year from releaseDate, author from the first writer else the first credit as "Last First", series else the title, album the title, letter the first character of the next folder through letterFolderName()), each cleaned by safeFolderName(); a missing value gives library:organizationUnknown. The file goes straight in the last folder under its original name, or <name> (<id>).<ext> when that is taken; both count as in place. The settings page edits it with three <select>s (levelChoices()/setLevel()/structureExample(), src/lib/library-structure.ts, pure), keeping an incomplete tree as a local draft. Changing it moves nothing: unorganizedIds() then lists the comics to move for "Organiser toute la bibliothèque…".

  • File name: an empty libraryFileName keeps the original name; otherwise it is a template of {series}, {volume}, {title}, {year} and {author} fields (src/shared/file-name-template.ts: isFileNameTemplate() refuses /, \, a brace outside a known field and more than 200 characters; fillFileNameTemplate() substitutes). templatedStem() fills it from the entry (year and author as for the levels, a missing value giving library:organizationUnknown), cleans it with safeFolderName() and cuts it to leave room for the extension, which is always kept. The name is used in both layouts, including the per-comic subfolder of the default one. The settings page saves the field as it is typed while it is valid (an invalid one stays a local draft with its error) and shows fileNameExample(). Like the structure, changing it renames nothing until "Organiser toute la bibliothèque…".

  • Who decides: createLibraryOrganizer() (src/main/services/library-organization.ts, built once in main.ts, with createResynchronizer() and the run lock: Electron-free, so library-organization.test.ts covers the tokens, the modes and the lock; src/main/ipc/library.ts only registers the handlers that call it) is called by library:add-file, library:add-folder and the resynchronizer with the entries just created (ScanSummary.addedEntries, ResyncSummary.addedEntries). planOrganization() (pure) moves everything with always; with ask it moves only the comics already inside the organized folder and keeps the others behind a random token, returned as organization.pending (OrganizationOutcome, part of AddFileResult, ScanResult and ResyncResult). The renderer asks (useOrganizationOffer(), src/hooks/use-organization-offer.ts, on the library page and Paramètres › Bibliothèque) and sends the token back through library:organize; tokens work once and only the last 20 are kept. The renderer never names a path: the folder is set by library:pick-organization-folder (a dialog), and settings:set refuses libraryOrganizationFolder (MAIN_PROCESS_SETTINGS, src/shared/settings.ts). The JSON import still restores it, like any setting.

  • Moving: organizeEntries() moves one comic at a time with moveFile() (no overwrite; copy then delete across drives, the copy deleted again if the original can't be), then LibraryRepository.updatePath(); if the database refuses the path, the file goes back. The subfolders of the organized folder left empty by a move are deleted, up to the organized folder itself; nothing outside it is. Only supported comics are moved (an entry naming any other file counts as failed and is not offered by unorganizedIds()). Moved entries lose their cached thumbnail (keyed by path) and the library slice reloads.

  • The whole library: library:count-unorganized counts the entries not in place yet (unorganizedIds(), which checks the file against its organizedPaths()), for the confirmation, and library:organize-all moves them, reporting over library:scan-progress. It and the resynchronizer share a LibraryRunLock (organizer.lock): one run over the files at a time, since moving a file while a walk registers files could register it twice; the loser gets an error result.

  • Watching: the chosen folder goes into library_folders, and LibraryOrganizer.watchedFolders() adds it to the resynchronization's folders while the organization is on. A missing organized folder (unplugged drive) moves nothing and counts the comics as failed.

Page sizes (background measure) ​

LibraryEntry.avgPageWidth/avgPageHeight hold the simple average (rounded) of the pages' dimensions, null until measured. Opening every archive during an import would make it much slower, so it is a separate pass, run after the import and never awaited by it.

  • Measuring: the decoder's measure call opens the file and measureArchive() (src/main/services/image-stats.ts) reads each page's size from its image header (readImageSize(), no decoding, so a page of absurd dimensions costs nothing); a PDF answers from its page boxes through the optional ComicArchive.pageSize(), at the scale it is rendered at. A page that can't be read, or whose format has no header reader (AVIF, BMP), is left out of the average. CBR pages still go through readPage(), which makes a CBR the slow case.
  • Scanner: createImageStatsScanner() (image-stats-scanner.ts, in the main process) measures the entries LibraryRepository.unmeasured() lists (avg_page_width IS NULL), one at a time, and writes the result with updateImageStats(). kick() is called after library:add-file, library:add-folder, a resynchronization, a JSON import, the first opening of a book, and at start-up; a call during a run does nothing, since the run looks for new entries again before it ends. A book that can't be measured keeps its columns null and is skipped until the next start, so it isn't reopened on every kick(). Updated entries are announced to the data store in batches (data:changed, at most every 500 ms); touch() and register() never write these columns, upsert() (the import) does, a snapshot without them resetting them to null so they are measured again.
  • Progress: library:image-scan-progress pushes an ImageScanProgress (running, processed, total) to every window, and library:image-scan-status returns the current one, for a window that loaded after the start (useImageScan(), src/hooks/use-image-scan.ts). The root layout shows ImageScanBar at the bottom of the window while running (hidden in fullscreen reading); imageScanPercent() and formatPageSize() (src/lib/image-scan.ts) are the pure parts.

Resynchronization ​

library:add-folder also remembers the directory in library_folders (migration 3, LibraryFolderRepository), a table of the main process only: the renderer can list the folders (library:list-folders) and forget one (library:remove-folder) but never sets one, so it can't make the app walk an arbitrary path. library:resync (and, when AppSettings.resyncOnStartup is on, the start-up in main.ts, in the background) goes through createResynchronizer() (src/main/services/library-organization.ts), which allows one run at a time, calls resyncLibrary() (src/main/services/library-sync.ts), stores AppSettings.lastResyncAt and notifies the views (library reload, readingLists when something was removed, the settings value). Progress goes over library:scan-progress for a manual run. With the library organization on, it also walks the organized folder and hands the comics it added to the organizer, which may move them.

For each folder, resyncLibrary() runs scanIntoLibrary() (new files added) and then removes the entries under that folder whose file is gone. Because a removal loses the user's progress, rating and tags, it is conservative: a folder that isn't a readable directory is skipped (unreachable), and an entry is removed only when stat says ENOENT, not merely when the walk didn't see it (it skips the subfolders it can't read). Entries outside every folder (files added on their own) are checked afterwards, one by one: removed when stat says ENOENT and their parent directory is still a readable directory, so a vanished drive or folder keeps them; the files on disk are never touched. The removed entries' thumbnails are deleted like those of library:remove. The folder list is not exported, and data:clear-library keeps it.

Reader (src/routes/reader.tsx) ​

Layout of the code. The route (reader.tsx) only reads the search params, wires useComic(), useReaderFullscreen() and useNextInList(), and picks a reader. The pieces live in src/components/reader/: SinglePageReader and ContinuousReader (with ContinuousPage), and the parts both modes share (ReaderHeader, ReadingProgress, FullscreenButton, NextInListButton). Reader-specific hooks are in src/hooks/ (use-reader-fullscreen.ts, use-resume-scroll.ts). The page-turning rules are pure and tested in src/lib/reader-navigation.ts: directionalControls() binds advance/retreat to next/previous page for the reading direction, and disabledControls() says which click zone has nowhere to go (isPrevDisabled/isNextDisabled).

AppSettings.readingMode picks SinglePageReader or ContinuousReader. They are separate components, each owning its hooks, so switching modes mounts/unmounts cleanly instead of sharing refs and effects.

Opening and closing. useComic owns the archive's lifetime. openFile() isn't cancellable, so each call takes a request number and the hook keeps a mounted flag: a comic:open that answers after the reader was left, or after a newer open started, closes the archive it just got (comic.close(id)) instead of leaving it in ComicService until the app quits (a CBR stays in memory, a CBZ leaks a file descriptor). useComic({ loadPages: false }) skips the single-page fetch; the reader passes it in continuous mode, which loads its own pages, so the resume page isn't rendered twice (costly for PDFs).

Reading direction. AppSettings.readingDirection (ltr/rtl) swaps the two actions bound to the physical controls: advance is next in LTR and prev in RTL, retreat the opposite. useComic's next()/prev() always mean page index ±1. In single-page mode advance is bound to →, PageDown, Space and the right click zone; retreat to ←, PageUp and the left zone.

Single page. Zoom (fit, 50–200 %; a zoomed page uses m-auto in a scrolling flex container, so it is centered while smaller than the viewport and scrolls from its top-left corner once larger), optional AI upscaling (below), and wheel-to-turn-page. The wheel only turns the page when there is nothing left to scroll in that direction: always in "fit" zoom, and only at the top/bottom edge when zoomed in. AppSettings.scrollDirection (standard/inverted) decides whether scrolling down calls advance() or retreat(). createWheelPager() (src/lib/wheel-pager.ts, pure and unit-tested) collapses one trackpad swipe's many wheel events, including macOS's inertia tail, into one page turn: a gesture ends after ~200 ms without events, or when a delta jumps well above the previous one (inertia only decays). The pager is held in useState, because the wheel effect re-runs after every page turn (next/prev change identity each render) and a fresh pager would forget the gesture in progress. After a turn, SinglePageReader scrolls the zoomed page to its top (forward) or bottom (backward), as told by scrollEdgeAfterTurn(); the edge is applied once the new image's size is known, since pageUrl arrives after page changes. Panning events are fed to it too, so a swipe that pans to the edge doesn't turn the page with its leftover inertia.

Testing note: a JS-dispatched WheelEvent fires listeners but does not scroll the element. Verifying real scroll behavior needs CDP's Input.dispatchMouseEvent with type: 'mouseWheel'.

AI upscaling. useImageUpscaler (src/hooks/use-image-upscaler.ts) runs the page through an ESRGAN model (UpscalerJS on TensorFlow.js) in the renderer, when the page is displayed beyond its native resolution and the user enabled it. The model and TensorFlow.js are loaded with a dynamic import() on first use only; the weights are served from the app's own files (vite.renderer.config.mts copies them), never from a CDN, so it works offline. TensorFlow.js runs on the renderer's main thread, so inference is tiled in 64 px patches with awaitNextFrame (it yields to the browser between patches: the UI no longer freezes on a large page) and an AbortSignal aborted when the page changes or the option is unchecked. The upscaled pages (data URLs, tens of MB each) are kept in a 4-entry LRU cache (createLruCache(), src/lib/lru-cache.ts) keyed by the page, <archive id>:<page index> (useComic().pageKey, which names the page pageUrl really shows, since page moves on before the new image arrives), not by the page's blob: URL, which is made anew at each load: coming back to a page, or toggling the zoom, reuses the result instead of running the model again.

Continuous scroll. ContinuousReader renders one ContinuousPage per page, each with three IntersectionObservers: one with a large rootMargin (800 px) that lazily fetches the page, one with a wider one (3 000 px) that releases a loaded page once it is farther than that (image and blob: URL; its placeholder keeps the height the page had, so nothing below it moves), so the renderer's memory follows what is near the reader rather than every page read (a long PDF at ~200 DPI would otherwise hold gigabytes), one with rootMargin: '-50% 0px -50% 0px' (a line across the middle of the viewport) that reports the active page: the page crossing that line, since a visibility threshold would never be reached by a page taller than twice the viewport. It reports it for the header and for saving progress (debounced ~400 ms through createTrailingDebounce(), src/lib/trailing-debounce.ts, and flushed when the reader unmounts so a quick close still records the last page and the book as finished; it calls library.updateProgress directly, not useComic's goTo, which would also start a single-page fetch). A page that fails to load shows "Unreadable page" instead of its spinner. A page's image is only shown once decoded (the placeholder stays until then), so the page changes height in one step; when a page starting above the top of the viewport does, ContinuousPage adds the difference to the scroll position, except while the resumed page is pinned (useResumeScroll returns a ref saying so, the pinning already corrects it). The container has overflow-anchor: none, so the browser's own anchoring doesn't also move it. Without this, scrolling back up into the previous page after resuming a book let its image replace the placeholder and grow downward, leaving the reader too high in the page instead of at its bottom. Upscaling and RTL don't apply; the gap is AppSettings.pageSpacing px. On open, useResumeScroll scrolls to comic.resumePage in a layout effect (before the observers' first callbacks, so page 0 is never reported over the saved progress), then keeps it pinned with a ResizeObserver while preloaded pages above change height, until the user's first wheel/pointer/touch/key input. The header's zoom menu sets the width of the page column (WIDTH_OPTIONS, 50–200 % of the scroll area, 100 % = fit to width; local state, not persisted), the scroll container scrolling both ways; on a change the scroll position is restored as a fraction of the scrollable size so the reader stays on the same page. ContinuousReader is keyed by comic.id, so opening another book remounts it.

Progress and pace. useReadingPace (src/hooks/use-reading-pace.ts) computes the header's "X % · ~Y min" for both modes (ReadingProgress component). The pace is for the current session only (nothing persisted): when sessionKey (the archive's comic.id) changes, it resets by calling setState during render (React's "adjust state while rendering" pattern), so the first render of a new book is already right. The estimate stays hidden until MIN_ELAPSED_MINUTES has elapsed and at least one page was turned.

Theme. AppSettings.theme (system/light/dark) is applied by keepThemeInSync() (src/hooks/use-settings.ts, called by renderer.tsx before the first render): it toggles the dark class and color-scheme on <html>, which switch the tokens of src/index.css, from the pure isDarkTheme() (src/lib/theme.ts) and prefers-color-scheme while the setting is system. It is independent of the reader's background.

Background. AppSettings.readerBackground (#rrggbb, default black) is an inline backgroundColor on the page area of both modes; presets are READER_BACKGROUND_PRESETS (their names are translated under settings:appearance.backgrounds). The header keeps a fixed dark chrome, and page-area placeholders use a mid grey so they stay readable on any background.

Fullscreen. useFullscreen (src/hooks/use-fullscreen.ts) wraps the HTML Fullscreen API on document.documentElement: in Electron that makes the window itself fullscreen, and Chromium handles Escape, so no IPC is involved. The reader (useReaderFullscreen) toggles it with the header button or F/F11 and exits it when the comic is closed or the route unmounts. While fullscreen, the root layout hides the sidebar (without touching sidebarCollapsed) and ReaderHeader becomes an overlay shown when the mouse reaches the top edge.