Browse Source

Add tagging feature with BookDetails view

- Add DatabaseManager methods for tag CRUD operations (add_tag_to_book,
  remove_tag_from_book, get_tags_for_book) with tags and book_tags tables
- Create BookDetails component showing book metadata, cover, and tags
- Implement tag chips with remove buttons in FlowBox
- Add back navigation from details view to shelf
- Store book_id for safe tag operations independent of pointer
- Escape markup in title/author to prevent injection
- Update Flatpak manifest app ID to eu.bernardomagri.bibliotheca
- Add PLAN.md with development roadmap and Feature-Tagging.md design doc

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Bernardo Magri 1 month ago
parent
commit
f4aebf1150

+ 268 - 0
PLAN.md

@@ -0,0 +1,268 @@
+# Bibliotheca Development Plan
+
+## Overview
+
+Bibliotheca is a GTK4-based desktop ebook library manager written in C++17. This plan documents existing issues to fix and new features to implement.
+
+**Current Version**: 0.1.0
+**Technology Stack**: GTK4/gtkmm-4, SQLite3, OpenSSL, libzip, tinyxml2, poppler-glib
+
+---
+
+## Phase 1: Critical Bug Fixes
+
+### 1.1 Complete BookDetails Tag Implementation
+**Files**: `src/BookDetails.cpp`, `src/BookDetails.hpp`
+**Status**: COMPLETED
+
+Implemented:
+- [x] Add tag removal buttons (X icons) to each tag in FlowBox
+- [x] Create `on_remove_tag_clicked(tag_name)` handler
+- [x] Call `m_db.remove_tag_from_book()` and update UI
+- [x] Handle edge cases (empty tag name validation)
+- [x] Store book_id instead of relying solely on pointer
+- [x] Use `refresh_tags()` to reload from DB (efficient, no full list reload)
+- [x] Escape markup in title/author to prevent injection
+
+Remaining (moved to Phase 2):
+- [ ] Add visual feedback (toast/notification) for tag operations
+
+### 1.2 Fix Null Pointer Safety in BookDetails
+**Files**: `src/BookDetails.cpp`
+**Status**: Not Started
+**Location**: `src/BookDetails.cpp:57-89`
+
+Current behavior:
+- `on_open_button_clicked()` already has `if (m_book)` check (line 58)
+- `on_add_tag_button_clicked()` already has `if (m_book)` check (line 81)
+- BUT: `m_book` pointer could become dangling if book is deleted from list
+
+Tasks:
+- [ ] Handle case where book is deleted while details view is open
+- [ ] Subscribe to BookList signals to detect book removal
+- [ ] Clear details view or navigate back when displayed book is removed
+- [ ] Consider storing book ID instead of pointer and looking up fresh data
+
+### 1.3 Fix Const-Correctness in DatabaseManager
+**Files**: `src/DatabaseManager.cpp`, `src/DatabaseManager.hpp`
+**Status**: Review Needed
+
+Current state:
+- Header declares `get_book()` and `load_all_books()` as non-const (correct)
+- Methods need mutex lock, so non-const is acceptable
+- This may actually be correctly designed - needs review
+
+Tasks:
+- [ ] Review if const-correctness is actually an issue
+- [ ] If needed: use `mutable` keyword for mutex to allow const methods
+- [ ] Document thread-safety guarantees in header comments
+
+### 1.4 Fix Inefficient Tag Updates
+**Files**: `src/BookDetails.cpp`, `src/BookList.cpp`
+**Status**: Not Started
+**Location**: `src/BookDetails.cpp:85`
+
+Current behavior:
+- `on_add_tag_button_clicked()` calls `m_book_list.loadAll()` after adding tag
+- This reloads ALL books from database, losing selection/scroll state
+- Very inefficient for large libraries
+
+Tasks:
+- [ ] Add `BookList::updateBookTags(book_id)` method for incremental update
+- [ ] Or: Directly update the Book object's tags and emit `signalBookUpdated`
+- [ ] Refresh only the displayed BookDetails, not entire list
+- [ ] Preserve scroll position and selection state in BookShelf
+
+---
+
+## Phase 2: Core Feature Improvements
+
+### 2.1 Implement Tag-Based Search
+**Files**: `src/BookShelf.cpp`
+**Status**: Not Started
+**Location**: `src/BookShelf.cpp:67-76` (`book_score()` function)
+
+Current behavior:
+- `book_score()` searches: title, author, filePath, id
+- Tags are NOT searched despite being loaded into Book objects
+
+Tasks:
+- [ ] Add tag matching loop in `book_score()`:
+  ```cpp
+  for (const auto& tag : book.tags())
+    best = std::max(best, field_score(tag, pattern));
+  ```
+- [ ] Support `tag:tagname` search syntax for exact tag filtering
+- [ ] Consider tag chips as quick filters in search UI (Phase 3)
+
+### 2.2 Add Error Handling and User Feedback
+**Files**: `src/BibliothecaWindow.cpp`, `src/BookDetails.cpp`, `src/BookImport.cpp`
+**Status**: Not Started
+
+- [ ] Add toast notifications for import success/failure
+- [ ] Show dialog when book file is missing/deleted
+- [ ] Display metadata extraction warnings
+- [ ] Add loading indicator during import
+
+### 2.3 Preserve Search State Across Views
+**Files**: `src/BibliothecaWindow.cpp`
+**Status**: Not Started
+
+- [ ] Save search query when switching to details view
+- [ ] Restore search query when returning to shelf
+- [ ] Maintain filter state across navigation
+
+### 2.4 Improve Cover Handling
+**Files**: `src/BookImport.cpp`, `src/BookTile.cpp`
+**Status**: Not Started
+
+- [ ] Validate cover image format before saving
+- [ ] Add cache clearing mechanism for placeholder textures
+- [ ] Handle missing/corrupted cover files gracefully
+
+---
+
+## Phase 3: New Features
+
+### 3.1 Duplicate Book Detection
+**Files**: `src/BookList.cpp`, `src/BibliothecaWindow.cpp`
+**Status**: Not Started
+
+- [ ] Detect re-import of same file (by hash)
+- [ ] Prompt user: update existing or skip
+- [ ] Show notification for already-imported books
+
+### 3.2 Batch Operations
+**Files**: New files required
+**Status**: Not Started
+
+- [ ] Multi-select mode in BookShelf
+- [ ] Batch tag assignment
+- [ ] Batch delete
+- [ ] Batch metadata edit
+
+### 3.3 Tag Management UI
+**Files**: New files required
+**Status**: Not Started
+
+- [ ] Dedicated tag browser/manager panel
+- [ ] Rename tags
+- [ ] Merge duplicate tags
+- [ ] Delete unused tags
+- [ ] Tag usage statistics
+
+### 3.4 Settings Panel
+**Files**: New files required
+**Status**: Not Started
+
+- [ ] Grid tile size adjustment
+- [ ] Default import folder
+- [ ] Cover quality settings
+- [ ] Theme selection (if GTK allows)
+
+### 3.5 File Change Detection
+**Files**: `src/DatabaseManager.cpp`, `src/BookList.cpp`
+**Status**: Not Started
+
+- [ ] Track file modification time (already in schema)
+- [ ] Detect moved/renamed files
+- [ ] Option to re-scan library
+- [ ] Remove entries for deleted files
+
+---
+
+## Phase 4: Polish and Accessibility
+
+### 4.1 Keyboard Navigation
+**Files**: `src/BibliothecaWindow.cpp`, `src/BookShelf.cpp`
+**Status**: Not Started
+
+- [ ] Arrow key navigation in grid
+- [ ] Enter to open selected book
+- [ ] Escape to clear selection/search
+- [ ] Tab navigation through UI elements
+
+### 4.2 Accessibility Labels
+**Files**: All UI files
+**Status**: Not Started
+
+- [ ] Add ARIA labels to all buttons
+- [ ] Screen reader support for book tiles
+- [ ] High contrast mode support
+- [ ] Focus indicators
+
+### 4.3 Documentation Updates
+**Files**: `docs/*.md`
+**Status**: Not Started
+
+- [ ] Update README with current features
+- [ ] Document BookDetails component
+- [ ] Add user guide/manual
+- [ ] Update architecture diagrams
+
+---
+
+## Phase 5: Advanced Features (Future)
+
+### 5.1 Built-in Reader
+- Embedded EPUB/PDF viewer
+- Reading progress tracking
+- Annotations and bookmarks
+
+### 5.2 Full-Text Search
+- Index book contents
+- Search within books
+- Highlight search results
+
+### 5.3 Cloud Sync
+- Sync library across devices
+- Backup to cloud storage
+
+### 5.4 Folder Watching
+- Auto-import from watched folders
+- Background monitoring daemon
+
+---
+
+## Implementation Priority
+
+| Priority | Task | Estimated Complexity |
+|----------|------|---------------------|
+| P0 | 1.1 Complete tag removal UI | Low |
+| P0 | 1.2 Fix null pointer safety | Low |
+| P0 | 1.4 Fix inefficient tag updates | Medium |
+| P1 | 1.3 Fix const-correctness | Low |
+| P1 | 2.1 Tag-based search | Medium |
+| P1 | 2.2 Error handling/feedback | Medium |
+| P2 | 2.3 Preserve search state | Low |
+| P2 | 2.4 Cover handling improvements | Medium |
+| P2 | 3.1 Duplicate detection | Medium |
+| P3 | 3.2-3.5 New features | High |
+| P4 | 4.1-4.3 Polish | Medium |
+
+---
+
+## Current Working Files
+
+### Modified (uncommitted)
+- `meson.build` - Added BookDetails.cpp
+- `src/BibliothecaWindow.cpp` - Added details view
+- `src/BibliothecaWindow.hpp` - Added members
+- `src/Book.hpp` - Added tags vector
+- `src/DatabaseManager.cpp` - Added tag tables/methods
+- `src/DatabaseManager.hpp` - Added tag signatures
+- `src/main.cpp` - Pass DB to window
+
+### New (untracked)
+- `src/BookDetails.cpp` - Details pane (incomplete)
+- `src/BookDetails.hpp` - Details pane header
+- `docs/Feature-Tagging.md` - Tagging design doc
+
+---
+
+## Notes
+
+- All changes should maintain thread safety (mutex usage)
+- Follow existing code style (GTK4 patterns, RAII)
+- Test on both X11 and Wayland
+- Flatpak manifest may need updates for new dependencies

+ 61 - 0
docs/Feature-Tagging.md

@@ -0,0 +1,61 @@
+# Feature: Tagging
+
+This document outlines the implementation of a tagging feature for Bibliotheca.
+
+## 1. Database Schema
+
+The database schema will be updated to support tags.
+
+- A `tags` table will be created to store all available tags.
+  ```sql
+  CREATE TABLE IF NOT EXISTS tags (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    name TEXT NOT NULL UNIQUE
+  );
+  ```
+
+- A `book_tags` table will be created to associate tags with books.
+  ```sql
+  CREATE TABLE IF NOT EXISTS book_tags (
+    book_id TEXT NOT NULL,
+    tag_id INTEGER NOT NULL,
+    FOREIGN KEY (book_id) REFERENCES books(id) ON DELETE CASCADE,
+    FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE,
+    PRIMARY KEY (book_id, tag_id)
+  );
+  ```
+
+## 2. Model
+
+- The `Book` class in `Book.hpp` will be extended to include a list of tags.
+  ```cpp
+  class Book {
+    // ... existing members
+    std::vector<std::string> m_tags;
+  };
+  ```
+
+- The `DatabaseManager` will be updated with methods to:
+  - `add_tag_to_book(book_id, tag_name)`
+  - `remove_tag_from_book(book_id, tag_name)`
+  - `get_tags_for_book(book_id)`
+  - `load_all_books` will be updated to also load the tags for each book.
+
+## 3. View
+
+- A new `BookDetails` widget will be created. This widget will:
+  - Display the book's cover, title, author, and other metadata.
+  - Display the tags associated with the book.
+  - Provide a way to add new tags to the book.
+  - Provide a way to remove existing tags from the book.
+  - Have a button to open the book file.
+
+- The `BibliothecaWindow` will be modified to:
+  - Show the `BookDetails` widget when a book is activated.
+  - The `onBookActivated` method will be updated to show the `BookDetails` widget instead of opening the book file directly.
+
+## 4. User Interaction
+
+- Users will be able to add tags to a book from the `BookDetails` view.
+- Users will be able to remove tags from a book from the `BookDetails` view.
+- The search functionality will be extended to allow searching for books by tag.

+ 2 - 2
org.example.bibliotheca.json → eu.bernardomagri.bibliotheca.json

@@ -1,7 +1,7 @@
 {
-    "app-id": "org.example.bibliotheca",
+    "app-id": "eu.bernardomagri.bibliotheca",
     "runtime": "org.gnome.Platform",
-    "runtime-version": "46",
+    "runtime-version": "49",
     "sdk": "org.gnome.Sdk",
     "command": "bibliotheca",
     "finish-args": [

+ 1 - 1
meson.build

@@ -11,7 +11,7 @@ poppler  = dependency('poppler-glib')
 
 src = ['src/main.cpp', 'src/BibliothecaWindow.cpp', 'src/Book.cpp',
 'src/DatabaseManager.cpp', 'src/Sha256.cpp', 'src/BookList.cpp',
-'src/BookShelf.cpp', 'src/BookTile.cpp', 'src/BookImport.cpp']
+'src/BookShelf.cpp', 'src/BookTile.cpp', 'src/BookImport.cpp', 'src/BookDetails.cpp']
 
 executable('bibliotheca',
   src,

+ 23 - 20
src/BibliothecaWindow.cpp

@@ -12,8 +12,9 @@
 #include <thread>
 #include <iostream>
 
-BibliothecaWindow::BibliothecaWindow(BookList& bookList)
-  : m_bookList(bookList) {
+BibliothecaWindow::BibliothecaWindow(DatabaseManager& db, BookList& bookList)
+  : m_db(db),
+    m_bookList(bookList) {
 
   set_title("Bibliotheca");
   set_default_size(900, 650);
@@ -94,8 +95,17 @@ BibliothecaWindow::BibliothecaWindow(BookList& bookList)
   m_stack.set_vexpand(true);
   m_stack.add(m_placeholder, "empty");
   m_stack.add(*m_shelf, "shelf");
+  m_bookDetails = std::make_unique<BookDetails>(m_db, m_bookList);
+  m_stack.add(*m_bookDetails, "details");
   m_stack.add(m_noResults, "noresults");
 
+  m_stack.property_visible_child().signal_changed().connect([this]() {
+    auto visible_child = m_stack.get_visible_child_name();
+    m_backButton.set_visible(visible_child == "details");
+    m_searchButton.set_visible(visible_child != "details");
+    m_addBookButton.set_visible(visible_child != "details");
+  });
+
   m_mainBox.set_spacing(0);
   m_mainBox.append(m_searchBar);
   m_mainBox.append(m_stack);
@@ -117,6 +127,15 @@ BibliothecaWindow::BibliothecaWindow(BookList& bookList)
 }
 
 void BibliothecaWindow::buildHeaderBar() {
+  m_backButton.set_icon_name("go-previous-symbolic");
+  m_backButton.set_has_frame(false);
+  m_backButton.add_css_class("flat");
+  m_backButton.set_tooltip_text("Back");
+  m_backButton.signal_clicked().connect([this]() {
+    m_stack.set_visible_child("shelf");
+  });
+  m_headerBar.pack_start(m_backButton);
+
   m_searchButton.set_icon_name("system-search-symbolic");
   m_searchButton.set_has_frame(false);
   m_searchButton.add_css_class("flat");
@@ -293,22 +312,6 @@ void BibliothecaWindow::onAddBookClicked() {
 }
 
 void BibliothecaWindow::onBookActivated(const Book& book) {
-  const auto& path = book.filePath();
-  if (path.empty()) {
-    std::cerr << "Activated book has no file path: " << book.title() << "\n";
-    return;
-  }
-  try {
-    auto file = Gio::File::create_for_path(path);
-    if (!file->query_exists()) {
-      std::cerr << "Book file is missing: " << path << "\n";
-      return;
-    }
-    // Ask the desktop to open the file with the user's default application.
-    const auto uri = Glib::filename_to_uri(path);
-    Gio::AppInfo::launch_default_for_uri(uri);
-  } catch (const Glib::Error& e) {
-    std::cerr << "Failed to open book '" << book.title() << "': "
-              << e.what() << "\n";
-  }
+  m_bookDetails->set_book(&book);
+  m_stack.set_visible_child("details");
 }

+ 6 - 1
src/BibliothecaWindow.hpp

@@ -13,10 +13,12 @@
 
 #include "BookList.hpp"
 #include "BookShelf.hpp"
+#include "BookDetails.hpp"
 
 // Forward-declare the hash helper you already have.
 std::string sha256_file(const std::string& path);
 
+
 /**
  * @brief Top-level GTK window that wires the data model to the visual shelf.
  *
@@ -25,7 +27,7 @@ std::string sha256_file(const std::string& path);
  */
 class BibliothecaWindow : public Gtk::Window {
 public:
-  explicit BibliothecaWindow(BookList& bookList);
+  explicit BibliothecaWindow(DatabaseManager& db, BookList& bookList);
 
 private:
   // UI setup helpers
@@ -42,11 +44,13 @@ private:
   void onSearchModeChanged();
 
   // Model (not owned)
+  DatabaseManager& m_db;
   BookList& m_bookList;
 
   // UI
   Gtk::HeaderBar m_headerBar;
   Gtk::Button    m_addBookButton;
+  Gtk::Button    m_backButton;
   Gtk::ToggleButton m_searchButton;
 
   Gtk::Box       m_mainBox {Gtk::Orientation::VERTICAL};
@@ -63,5 +67,6 @@ private:
 
   // Owned shelf view
   std::unique_ptr<BookShelf> m_shelf;
+  std::unique_ptr<BookDetails> m_bookDetails;
   std::string m_lastQuery;
 };

+ 3 - 0
src/Book.hpp

@@ -45,7 +45,9 @@ public:
   const std::string& author()    const noexcept { return m_author; }
   const std::string& filePath()  const noexcept { return m_filePath; }
   const std::string& coverPath() const noexcept { return m_coverPath; }
+  const std::vector<std::string>& tags() const noexcept { return m_tags; }
 
+  void set_tags(const std::vector<std::string>& tags) { m_tags = tags; }
   // -----------------------------------------------------------------------
   // Cover management
   // -----------------------------------------------------------------------
@@ -77,6 +79,7 @@ private:
   std::string m_author;    ///< Author name
   std::string m_filePath;  ///< Absolute path to the file on disk
   std::string m_coverPath; ///< Path to the cover image (optional)
+  std::vector<std::string> m_tags; ///< Tags associated with the book
 
   Glib::RefPtr<Gdk::Texture> m_cover; ///< Cached cover image for UI rendering
 };

+ 140 - 0
src/BookDetails.cpp

@@ -0,0 +1,140 @@
+#include "BookDetails.hpp"
+#include <giomm/appinfo.h>
+#include <giomm/file.h>
+#include <iostream>
+
+BookDetails::BookDetails(DatabaseManager& db, BookList& book_list) :
+  Gtk::Box(Gtk::Orientation::VERTICAL, 12),
+  m_db(db),
+  m_book_list(book_list) {
+  set_margin_top(24);
+  set_margin_bottom(24);
+  set_margin_start(24);
+  set_margin_end(24);
+
+  m_cover.set_pixel_size(200);
+  append(m_cover);
+
+  m_title.set_markup("<span weight='bold' size='large'></span>");
+  append(m_title);
+
+  m_author.set_markup("<span size='medium'></span>");
+  append(m_author);
+
+  m_open_button.set_label("Open Book");
+  m_open_button.signal_clicked().connect(sigc::mem_fun(*this, &BookDetails::on_open_button_clicked));
+  append(m_open_button);
+
+  auto& tags_label = *Gtk::make_managed<Gtk::Label>("Tags:");
+  tags_label.set_halign(Gtk::Align::START);
+  tags_label.set_margin_top(12);
+  append(tags_label);
+
+  m_tags_box.set_selection_mode(Gtk::SelectionMode::NONE);
+  m_tags_box.set_homogeneous(false);
+  m_tags_box.set_max_children_per_line(10);
+  append(m_tags_box);
+
+  auto& tag_entry_box = *Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 6);
+  m_tag_entry.set_placeholder_text("New tag...");
+  tag_entry_box.append(m_tag_entry);
+  m_add_tag_button.set_label("Add Tag");
+  m_add_tag_button.signal_clicked().connect(sigc::mem_fun(*this, &BookDetails::on_add_tag_button_clicked));
+  tag_entry_box.append(m_add_tag_button);
+  append(tag_entry_box);
+}
+
+Gtk::Widget* BookDetails::create_tag_chip(const std::string& tag_name) {
+  auto& chip = *Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 4);
+  chip.add_css_class("tag-chip");
+  chip.set_margin_top(2);
+  chip.set_margin_bottom(2);
+  chip.set_margin_start(2);
+  chip.set_margin_end(2);
+
+  auto& label = *Gtk::make_managed<Gtk::Label>(tag_name);
+  chip.append(label);
+
+  auto& remove_btn = *Gtk::make_managed<Gtk::Button>();
+  remove_btn.set_icon_name("window-close-symbolic");
+  remove_btn.add_css_class("flat");
+  remove_btn.add_css_class("circular");
+  remove_btn.set_tooltip_text("Remove tag");
+  remove_btn.signal_clicked().connect([this, tag_name]() {
+    on_remove_tag_clicked(tag_name);
+  });
+  chip.append(remove_btn);
+
+  return &chip;
+}
+
+void BookDetails::refresh_tags() {
+  // Clear existing tags
+  while (auto child = m_tags_box.get_child_at_index(0)) {
+    m_tags_box.remove(*child);
+  }
+
+  // Reload tags from database
+  auto tags = m_db.get_tags_for_book(m_book_id);
+  for (const auto& tag : tags) {
+    m_tags_box.append(*create_tag_chip(tag));
+  }
+}
+
+void BookDetails::set_book(const Book* book) {
+  m_book = book;
+  if (m_book) {
+    m_book_id = m_book->id();
+    m_cover.set(m_book->cover());
+    m_title.set_markup("<span weight='bold' size='large'>" + Glib::Markup::escape_text(m_book->title()) + "</span>");
+    m_author.set_markup("<span size='medium'>" + Glib::Markup::escape_text(m_book->author()) + "</span>");
+    refresh_tags();
+  } else {
+    m_book_id.clear();
+  }
+}
+
+void BookDetails::on_open_button_clicked() {
+  if (m_book) {
+    const auto& path = m_book->filePath();
+    if (path.empty()) {
+      std::cerr << "Activated book has no file path: " << m_book->title() << "\n";
+      return;
+    }
+    try {
+      auto file = Gio::File::create_for_path(path);
+      if (!file->query_exists()) {
+        std::cerr << "Book file is missing: " << path << "\n";
+        return;
+      }
+      // Ask the desktop to open the file with the user's default application.
+      const auto uri = Glib::filename_to_uri(path);
+      Gio::AppInfo::launch_default_for_uri(uri);
+    } catch (const Glib::Error& e) {
+      std::cerr << "Failed to open book '" << m_book->title() << "': "
+                << e.what() << "\n";
+    }
+  }
+}
+
+void BookDetails::on_add_tag_button_clicked() {
+  if (m_book_id.empty()) return;
+
+  auto tag_name = m_tag_entry.get_text();
+  if (tag_name.empty()) return;
+
+  // Trim whitespace
+  auto trimmed = Glib::ustring(tag_name).lowercase();
+  if (trimmed.empty()) return;
+
+  m_db.add_tag_to_book(m_book_id, std::string(tag_name));
+  m_tag_entry.set_text("");
+  refresh_tags();
+}
+
+void BookDetails::on_remove_tag_clicked(const std::string& tag_name) {
+  if (m_book_id.empty()) return;
+
+  m_db.remove_tag_from_book(m_book_id, tag_name);
+  refresh_tags();
+}

+ 38 - 0
src/BookDetails.hpp

@@ -0,0 +1,38 @@
+#pragma once
+
+#include <gtkmm/box.h>
+#include <gtkmm/label.h>
+#include <gtkmm/image.h>
+#include <gtkmm/button.h>
+#include <gtkmm/flowbox.h>
+#include <gtkmm/entry.h>
+#include <string>
+#include "Book.hpp"
+#include "DatabaseManager.hpp"
+#include "BookList.hpp"
+
+class BookDetails : public Gtk::Box {
+public:
+  BookDetails(DatabaseManager& db, BookList& book_list);
+  void set_book(const Book* book);
+
+private:
+  void on_open_button_clicked();
+  void on_add_tag_button_clicked();
+  void on_remove_tag_clicked(const std::string& tag_name);
+  void refresh_tags();
+  Gtk::Widget* create_tag_chip(const std::string& tag_name);
+
+  DatabaseManager& m_db;
+  BookList& m_book_list;
+  const Book* m_book = nullptr;
+  std::string m_book_id;
+
+  Gtk::Image m_cover;
+  Gtk::Label m_title;
+  Gtk::Label m_author;
+  Gtk::Button m_open_button;
+  Gtk::FlowBox m_tags_box;
+  Gtk::Entry m_tag_entry;
+  Gtk::Button m_add_tag_button;
+};

+ 69 - 3
src/DatabaseManager.cpp

@@ -52,6 +52,17 @@ void DatabaseManager::ensure_schema() {
     " added_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))"
     ");"
     "CREATE INDEX IF NOT EXISTS idx_books_title ON books(title);"
+    "CREATE TABLE IF NOT EXISTS tags ("
+    " id INTEGER PRIMARY KEY AUTOINCREMENT,"
+    " name TEXT NOT NULL UNIQUE"
+    ");"
+    "CREATE TABLE IF NOT EXISTS book_tags ("
+    " book_id TEXT NOT NULL,"
+    " tag_id INTEGER NOT NULL,"
+    " FOREIGN KEY (book_id) REFERENCES books(id) ON DELETE CASCADE,"
+    " FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE,"
+    " PRIMARY KEY (book_id, tag_id)"
+    ");"
     "COMMIT;";
   char* err = nullptr;
   if (sqlite3_exec(db_, ddl, nullptr, nullptr, &err) != SQLITE_OK) {
@@ -100,7 +111,7 @@ bool DatabaseManager::remove_book(const std::string& id) {
   return ok;
 }
 
-std::optional<Book> DatabaseManager::get_book(const std::string& id) const {
+std::optional<Book> DatabaseManager::get_book(const std::string& id) {
   std::lock_guard lk(mtx_);
   static const char* sql =
     "SELECT id,title,author,file_path,cover_path FROM books WHERE id = ?;";
@@ -123,7 +134,7 @@ std::optional<Book> DatabaseManager::get_book(const std::string& id) const {
   return out;
 }
 
-std::vector<Book> DatabaseManager::load_all_books() const {
+std::vector<Book> DatabaseManager::load_all_books() {
   std::lock_guard lk(mtx_);
   static const char* sql =
     "SELECT id,title,author,file_path,cover_path FROM books ORDER BY title,author,id;";
@@ -133,14 +144,69 @@ std::vector<Book> DatabaseManager::load_all_books() const {
   if (sqlite3_prepare_v2(db_, sql, -1, &st, nullptr) != SQLITE_OK) return rows;
 
   while (sqlite3_step(st) == SQLITE_ROW) {
-    rows.emplace_back(
+    Book b(
       reinterpret_cast<const char*>(sqlite3_column_text(st,0)),
       reinterpret_cast<const char*>(sqlite3_column_text(st,1)),
       reinterpret_cast<const char*>(sqlite3_column_text(st,2)),
       reinterpret_cast<const char*>(sqlite3_column_text(st,3)),
       reinterpret_cast<const char*>(sqlite3_column_text(st,4))
     );
+    b.set_tags(get_tags_for_book(b.id()));
+    rows.push_back(std::move(b));
+  }
+  sqlite3_finalize(st);
+  return rows;
+}
+
+bool DatabaseManager::add_tag_to_book(const std::string& book_id, const std::string& tag_name) {
+  std::lock_guard lk(mtx_);
+  static const char* sql =
+    "INSERT OR IGNORE INTO tags (name) VALUES (?);"
+    "INSERT OR IGNORE INTO book_tags (book_id, tag_id) VALUES (?, (SELECT id FROM tags WHERE name = ?));";
+  sqlite3_stmt* st = nullptr;
+  if (sqlite3_prepare_v2(db_, sql, -1, &st, nullptr) != SQLITE_OK) return false;
+
+  sqlite3_bind_text(st, 1, tag_name.c_str(), -1, SQLITE_TRANSIENT);
+  sqlite3_bind_text(st, 2, book_id.c_str(), -1, SQLITE_TRANSIENT);
+  sqlite3_bind_text(st, 3, tag_name.c_str(), -1, SQLITE_TRANSIENT);
+
+  bool ok = (sqlite3_step(st) == SQLITE_DONE);
+  sqlite3_finalize(st);
+  return ok;
+}
+
+bool DatabaseManager::remove_tag_from_book(const std::string& book_id, const std::string& tag_name) {
+  std::lock_guard lk(mtx_);
+  static const char* sql =
+    "DELETE FROM book_tags WHERE book_id = ? AND tag_id = (SELECT id FROM tags WHERE name = ?);";
+  sqlite3_stmt* st = nullptr;
+  if (sqlite3_prepare_v2(db_, sql, -1, &st, nullptr) != SQLITE_OK) return false;
+
+  sqlite3_bind_text(st, 1, book_id.c_str(), -1, SQLITE_TRANSIENT);
+  sqlite3_bind_text(st, 2, tag_name.c_str(), -1, SQLITE_TRANSIENT);
+
+  bool ok = (sqlite3_step(st) == SQLITE_DONE);
+  sqlite3_finalize(st);
+  return ok;
+}
+
+std::vector<std::string> DatabaseManager::get_tags_for_book(const std::string& book_id) {
+  std::lock_guard lk(mtx_);
+  static const char* sql =
+    "SELECT name FROM tags WHERE id IN (SELECT tag_id FROM book_tags WHERE book_id = ?);";
+  sqlite3_stmt* st = nullptr;
+  std::vector<std::string> rows;
+
+  if (sqlite3_prepare_v2(db_, sql, -1, &st, nullptr) != SQLITE_OK) return rows;
+
+  sqlite3_bind_text(st, 1, book_id.c_str(), -1, SQLITE_TRANSIENT);
+
+  while (sqlite3_step(st) == SQLITE_ROW) {
+    rows.emplace_back(
+      reinterpret_cast<const char*>(sqlite3_column_text(st,0))
+    );
   }
   sqlite3_finalize(st);
   return rows;
 }
+

+ 10 - 4
src/DatabaseManager.hpp

@@ -31,16 +31,22 @@ public:
   /// Remove a book row by id; returns false if the row did not exist.
   bool remove_book(const std::string& id);
   /// Retrieve a single book by id.
-  std::optional<Book> get_book(const std::string& id) const;
+  std::optional<Book> get_book(const std::string& id);
   /// Load every book currently stored in the database.
-  std::vector<Book>   load_all_books() const;
+  std::vector<Book>   load_all_books();
+  /// Add a tag to a book.
+  bool add_tag_to_book(const std::string& book_id, const std::string& tag_name);
+  /// Remove a tag from a book.
+  bool remove_tag_from_book(const std::string& book_id, const std::string& tag_name);
+  /// Get all tags for a book.
+  std::vector<std::string> get_tags_for_book(const std::string& book_id);
 
 private:
   static int busy_handler(void* /*opaque*/, int prev_sleep_ms);
-  void prepare_statements();                   
+  void prepare_statements();
 
   std::string db_path_;
-  mutable std::mutex mtx_;
+  std::mutex mtx_;
   sqlite3* db_ = nullptr;
 
 };

+ 1 - 1
src/main.cpp

@@ -43,5 +43,5 @@ int main(int argc, char** argv) {
   BookList bookList{db};
   bookList.loadAll();
 
-  return app->make_window_and_run<BibliothecaWindow>(argc, argv, bookList);
+  return app->make_window_and_run<BibliothecaWindow>(argc, argv, db, bookList);
 }