Kaynağa Gözat

Add built-in reader, folder watching, and metadata editing

Phase 5 features implemented:

Built-in Reader (ReaderView):
- PDF viewing with Poppler/Cairo rendering
- EPUB viewing with libzip/tinyxml2 parsing and Pango text layout
- Chapter-by-chapter navigation for EPUBs
- Reading progress persistence per book
- Keyboard navigation (Left/Right, PageUp/PageDown, Escape)

Folder Watching (FolderScanner):
- Scan folders recursively for EPUB/PDF files
- Watched folder setting in Settings dialog
- Auto-import on application startup

Metadata Editing (BookDetails):
- Edit button to enter edit mode
- Editable fields for title, author, and description
- Save/Cancel buttons for confirming or discarding changes
- Description field added to database schema with migration

Bug fix:
- Fix deadlock in load_all_books() by adding get_tags_for_book_unlocked()
  internal method to avoid recursive mutex acquisition

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Bernardo Magri 4 hafta önce
ebeveyn
işleme
a131ca4124

+ 52 - 8
PLAN.md

@@ -248,25 +248,69 @@ Remaining for future:
 
 ---
 
-## Phase 5: Advanced Features (Future)
+## Phase 5: Advanced Features
 
 ### 5.1 Built-in Reader
-- Embedded EPUB/PDF viewer
-- Reading progress tracking
-- Annotations and bookmarks
+**Files**: `src/ReaderView.cpp`, `src/ReaderView.hpp`
+**Status**: COMPLETED
+
+Implemented:
+- [x] PDF viewer using Poppler/Cairo
+- [x] EPUB viewer using libzip/tinyxml2/Pango
+- [x] Page/chapter navigation (next/prev)
+- [x] Reading progress tracking (saved per book)
+- [x] Keyboard navigation (Left/Right, PageUp/PageDown, Escape to close)
+- [x] "Read" button in BookDetails to open built-in reader
+- [x] Fallback to external reader for unsupported formats
+
+EPUB Implementation Details:
+- Parses container.xml to find OPF file
+- Extracts spine order from OPF manifest
+- Renders text content using Pango for proper layout
+- Chapters loaded lazily for performance
+- Text extracted from XHTML with proper paragraph handling
 
-### 5.2 Full-Text Search
+Remaining for future:
+- [ ] Annotations and bookmarks
+- [ ] Night/dark mode for reading
+- [ ] Font size adjustment
+- [ ] Table of contents navigation
+
+### 5.2 Full-Text Search (Future)
 - Index book contents
 - Search within books
 - Highlight search results
 
-### 5.3 Cloud Sync
+### 5.3 Cloud Sync (Future)
 - Sync library across devices
 - Backup to cloud storage
 
 ### 5.4 Folder Watching
-- Auto-import from watched folders
-- Background monitoring daemon
+**Files**: `src/FolderScanner.cpp`, `src/FolderScanner.hpp`, `src/SettingsDialog.cpp`, `src/BibliothecaWindow.cpp`
+**Status**: COMPLETED
+
+Implemented:
+- [x] FolderScanner class for recursive folder scanning
+- [x] Support for EPUB and PDF file detection
+- [x] Watched folder setting in Settings dialog
+- [x] Auto-import on application startup
+- [x] Duplicate detection (by SHA-256 hash)
+
+Remaining for future:
+- [ ] Real-time file monitoring (inotify/fswatch)
+- [ ] Background monitoring daemon
+
+### 5.5 Metadata Editing
+**Files**: `src/BookDetails.cpp`, `src/BookDetails.hpp`, `src/Book.hpp`, `src/BookList.cpp`, `src/BookList.hpp`, `src/DatabaseManager.cpp`, `src/DatabaseManager.hpp`
+**Status**: COMPLETED
+
+Implemented:
+- [x] Edit button in BookDetails to enter edit mode
+- [x] Editable fields for title, author, and description
+- [x] Save/Cancel buttons for confirming or discarding changes
+- [x] Description field added to database schema (with migration for existing databases)
+- [x] BookList.updateMetadata() method for efficient metadata updates
+- [x] Auto-refresh of display after save via signalBookUpdated
 
 ---
 

+ 1 - 1
meson.build

@@ -12,7 +12,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/BookDetails.cpp',
-'src/TagManagerDialog.cpp', 'src/SettingsDialog.cpp']
+'src/TagManagerDialog.cpp', 'src/SettingsDialog.cpp', 'src/FolderScanner.cpp', 'src/ReaderView.cpp']
 
 executable('bibliotheca',
   src,

+ 136 - 5
src/BibliothecaWindow.cpp

@@ -127,14 +127,25 @@ BibliothecaWindow::BibliothecaWindow(DatabaseManager& db, BookList& bookList)
     // Navigate back to shelf when displayed book is removed
     m_stack.set_visible_child("shelf");
   });
+  m_bookDetails->signalReadRequested().connect(sigc::mem_fun(*this, &BibliothecaWindow::onReadBookClicked));
   m_stack.add(*m_bookDetails, "details");
+
+  m_readerView = std::make_unique<ReaderView>(m_db);
+  m_readerView->signal_close_requested().connect(sigc::mem_fun(*this, &BibliothecaWindow::onReaderCloseRequested));
+  m_stack.add(*m_readerView, "reader");
+
   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");
+    bool in_reader = (visible_child == "reader");
+    bool in_details = (visible_child == "details");
+    m_backButton.set_visible(in_details);
+    m_searchButton.set_visible(!in_details && !in_reader);
+    m_addBookButton.set_visible(!in_details && !in_reader);
+    m_tagsButton.set_visible(!in_reader);
+    m_settingsButton.set_visible(!in_reader);
+    m_searchBar.set_visible(!in_reader);
   });
 
   // Use overlay to show toast on top of content
@@ -166,6 +177,9 @@ BibliothecaWindow::BibliothecaWindow(DatabaseManager& db, BookList& bookList)
   m_keyController->signal_key_pressed().connect(
     sigc::mem_fun(*this, &BibliothecaWindow::onKeyPressed), false);
   add_controller(m_keyController);
+
+  // Scan watched folder for new books on startup
+  scanWatchedFolder();
 }
 
 void BibliothecaWindow::buildHeaderBar() {
@@ -449,6 +463,68 @@ void BibliothecaWindow::onScanLibrary() {
   }
 }
 
+void BibliothecaWindow::scanWatchedFolder() {
+  std::string watchedFolder = m_db.get_setting("watched_folder", "");
+  if (watchedFolder.empty()) return;
+
+  // Scan the folder in a background thread
+  std::thread([this, watchedFolder]() {
+    auto files = FolderScanner::scan(watchedFolder, true);
+    if (files.empty()) return;
+
+    struct ImportedBook {
+      Book book;
+      bool is_duplicate;
+    };
+    std::vector<ImportedBook> imported;
+    imported.reserve(files.size());
+
+    for (const auto& fileInfo : files) {
+      try {
+        const auto& path = fileInfo.path;
+        const auto id = sha256_file(path);
+
+        // Check if this book already exists (by hash)
+        bool is_duplicate = m_bookList.findById(id).has_value();
+        if (is_duplicate) continue; // Skip duplicates silently for auto-import
+
+        auto resOpt = import_book_assets(path, id);
+        ImportResult res = resOpt.value_or(ImportResult{});
+
+        Book b(
+          id,
+          !res.title.empty() ? res.title : Glib::path_get_basename(path),
+          res.author,
+          path,
+          res.coverPngPath
+        );
+        b.load_cover_from_disk();
+
+        imported.push_back({std::move(b), false});
+      } catch (const std::exception& e) {
+        std::cerr << "Auto-import failed for " << fileInfo.path << ": " << e.what() << "\n";
+      }
+    }
+
+    if (imported.empty()) return;
+
+    // Push to model on the GTK main loop
+    Glib::signal_idle().connect_once([this, imported = std::move(imported)]() mutable {
+      std::vector<Book> books_to_add;
+      for (auto& item : imported) {
+        books_to_add.push_back(std::move(item.book));
+      }
+
+      if (!books_to_add.empty()) {
+        m_bookList.upsertMany(books_to_add);
+        updateVisibleView();
+        showToast(Glib::ustring::compose("Auto-imported %1 book%2", books_to_add.size(),
+          books_to_add.size() == 1 ? "" : "s"), false);
+      }
+    });
+  }).detach();
+}
+
 void BibliothecaWindow::applySettings() {
   // Read tile size setting and recreate shelf with new size
   int tileSize = m_db.get_setting_int("tile_size", 180);
@@ -659,12 +735,35 @@ void BibliothecaWindow::onBookActivated(const Book& book) {
   m_stack.set_visible_child("details");
 }
 
+void BibliothecaWindow::onReadBookClicked(const Book& book) {
+  if (m_readerView->open(book)) {
+    m_stack.set_visible_child("reader");
+  } else {
+    // Fallback to external reader for unsupported formats
+    try {
+      const auto uri = Glib::filename_to_uri(book.filePath());
+      Gio::AppInfo::launch_default_for_uri(uri);
+    } catch (const Glib::Error& e) {
+      showToast("Could not open book: " + std::string(e.what()), true);
+    }
+  }
+}
+
+void BibliothecaWindow::onReaderCloseRequested() {
+  m_readerView->close();
+  m_stack.set_visible_child("details");
+}
+
 bool BibliothecaWindow::onKeyPressed(guint keyval, guint /*keycode*/, Gdk::ModifierType /*state*/) {
   const auto visible = m_stack.get_visible_child_name();
 
   switch (keyval) {
     case GDK_KEY_Escape:
-      if (visible == "details") {
+      if (visible == "reader") {
+        // Close reader
+        onReaderCloseRequested();
+        return true;
+      } else if (visible == "details") {
         // Go back to shelf from details
         m_stack.set_visible_child("shelf");
         if (!m_lastQuery.empty()) {
@@ -695,7 +794,11 @@ bool BibliothecaWindow::onKeyPressed(guint keyval, guint /*keycode*/, Gdk::Modif
       break;
 
     case GDK_KEY_BackSpace:
-      if (visible == "details") {
+      if (visible == "reader") {
+        // Close reader
+        onReaderCloseRequested();
+        return true;
+      } else if (visible == "details") {
         // Go back to shelf from details
         m_stack.set_visible_child("shelf");
         if (!m_lastQuery.empty()) {
@@ -706,6 +809,34 @@ bool BibliothecaWindow::onKeyPressed(guint keyval, guint /*keycode*/, Gdk::Modif
       }
       break;
 
+    case GDK_KEY_Left:
+      if (visible == "reader" && m_readerView) {
+        m_readerView->prev_page();
+        return true;
+      }
+      break;
+
+    case GDK_KEY_Right:
+      if (visible == "reader" && m_readerView) {
+        m_readerView->next_page();
+        return true;
+      }
+      break;
+
+    case GDK_KEY_Page_Up:
+      if (visible == "reader" && m_readerView) {
+        m_readerView->prev_page();
+        return true;
+      }
+      break;
+
+    case GDK_KEY_Page_Down:
+      if (visible == "reader" && m_readerView) {
+        m_readerView->next_page();
+        return true;
+      }
+      break;
+
     default:
       break;
   }

+ 9 - 1
src/BibliothecaWindow.hpp

@@ -19,8 +19,10 @@
 #include "BookList.hpp"
 #include "BookShelf.hpp"
 #include "BookDetails.hpp"
+#include "ReaderView.hpp"
 #include "TagManagerDialog.hpp"
 #include "SettingsDialog.hpp"
+#include "FolderScanner.hpp"
 
 // Forward-declare the hash helper you already have.
 std::string sha256_file(const std::string& path);
@@ -56,6 +58,8 @@ private:
   // Callbacks
   void onAddBookClicked();
   void onBookActivated(const Book& book);
+  void onReadBookClicked(const Book& book);
+  void onReaderCloseRequested();
   void onSearchChanged();
   void onSearchToggle();
   void onSearchModeChanged();
@@ -74,6 +78,9 @@ private:
   void applySettings();
   void onScanLibrary();
 
+  // Folder watching
+  void scanWatchedFolder();
+
   // Model (not owned)
   DatabaseManager& m_db;
   BookList& m_bookList;
@@ -106,9 +113,10 @@ private:
   Gtk::Box       m_noResults {Gtk::Orientation::VERTICAL};
   Gtk::Label     m_noResultsLabel;
 
-  // Owned shelf view
+  // Owned views
   std::unique_ptr<BookShelf> m_shelf;
   std::unique_ptr<BookDetails> m_bookDetails;
+  std::unique_ptr<ReaderView> m_readerView;
   std::string m_lastQuery;
 
   // Batch action bar widgets

+ 15 - 10
src/Book.hpp

@@ -40,13 +40,17 @@ public:
   // Accessors
   // -----------------------------------------------------------------------
 
-  const std::string& id()        const noexcept { return m_id; }
-  const std::string& title()     const noexcept { return m_title; }
-  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::string& id()          const noexcept { return m_id; }
+  const std::string& title()       const noexcept { return m_title; }
+  const std::string& author()      const noexcept { return m_author; }
+  const std::string& description() const noexcept { return m_description; }
+  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_title(const std::string& title) { m_title = title; }
+  void set_author(const std::string& author) { m_author = author; }
+  void set_description(const std::string& description) { m_description = description; }
   void set_tags(const std::vector<std::string>& tags) { m_tags = tags; }
   // -----------------------------------------------------------------------
   // Cover management
@@ -74,11 +78,12 @@ private:
   // Data members
   // -----------------------------------------------------------------------
 
-  std::string m_id;        ///< Hex SHA-256 of the file contents
-  std::string m_title;     ///< Book title
-  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::string m_id;          ///< Hex SHA-256 of the file contents
+  std::string m_title;       ///< Book title
+  std::string m_author;      ///< Author name
+  std::string m_description; ///< Book description/synopsis
+  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

+ 175 - 6
src/BookDetails.cpp

@@ -15,16 +15,84 @@ BookDetails::BookDetails(DatabaseManager& db, BookList& book_list) :
   m_cover.set_pixel_size(200);
   append(m_cover);
 
+  // Metadata display/edit container
+  m_metadata_box.set_halign(Gtk::Align::CENTER);
+
+  // Display mode labels
   m_title.set_markup("<span weight='bold' size='large'></span>");
-  append(m_title);
+  m_title.set_selectable(true);
+  m_metadata_box.append(m_title);
 
   m_author.set_markup("<span size='medium'></span>");
-  append(m_author);
-
-  m_open_button.set_label("Open Book");
+  m_author.set_selectable(true);
+  m_metadata_box.append(m_author);
+
+  m_description.set_wrap(true);
+  m_description.set_max_width_chars(60);
+  m_description.set_selectable(true);
+  m_description.set_margin_top(8);
+  m_metadata_box.append(m_description);
+
+  // Edit mode entries (hidden initially)
+  m_title_entry.set_placeholder_text("Title");
+  m_title_entry.set_width_chars(40);
+  m_title_entry.set_visible(false);
+  m_metadata_box.append(m_title_entry);
+
+  m_author_entry.set_placeholder_text("Author");
+  m_author_entry.set_width_chars(40);
+  m_author_entry.set_visible(false);
+  m_metadata_box.append(m_author_entry);
+
+  m_description_scroll.set_min_content_height(100);
+  m_description_scroll.set_max_content_height(200);
+  m_description_scroll.set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
+  m_description_view.set_wrap_mode(Gtk::WrapMode::WORD_CHAR);
+  m_description_view.get_buffer()->set_text("");
+  m_description_scroll.set_child(m_description_view);
+  m_description_scroll.set_visible(false);
+  m_description_scroll.set_margin_top(8);
+  m_metadata_box.append(m_description_scroll);
+
+  append(m_metadata_box);
+
+  // Button row
+  m_button_box.set_halign(Gtk::Align::CENTER);
+  m_button_box.set_margin_top(12);
+
+  m_read_button.set_label("Read");
+  m_read_button.set_icon_name("document-open-symbolic");
+  m_read_button.add_css_class("suggested-action");
+  m_read_button.set_tooltip_text("Read book in built-in reader");
+  m_read_button.signal_clicked().connect(sigc::mem_fun(*this, &BookDetails::on_read_button_clicked));
+  m_button_box.append(m_read_button);
+
+  m_open_button.set_label("Open External");
   m_open_button.set_tooltip_text("Open book in default application");
   m_open_button.signal_clicked().connect(sigc::mem_fun(*this, &BookDetails::on_open_button_clicked));
-  append(m_open_button);
+  m_button_box.append(m_open_button);
+
+  m_edit_button.set_label("Edit");
+  m_edit_button.set_icon_name("document-edit-symbolic");
+  m_edit_button.set_tooltip_text("Edit book metadata");
+  m_edit_button.signal_clicked().connect(sigc::mem_fun(*this, &BookDetails::on_edit_button_clicked));
+  m_button_box.append(m_edit_button);
+
+  m_save_button.set_label("Save");
+  m_save_button.set_icon_name("document-save-symbolic");
+  m_save_button.add_css_class("suggested-action");
+  m_save_button.set_tooltip_text("Save changes");
+  m_save_button.signal_clicked().connect(sigc::mem_fun(*this, &BookDetails::on_save_button_clicked));
+  m_save_button.set_visible(false);
+  m_button_box.append(m_save_button);
+
+  m_cancel_button.set_label("Cancel");
+  m_cancel_button.set_tooltip_text("Discard changes");
+  m_cancel_button.signal_clicked().connect(sigc::mem_fun(*this, &BookDetails::on_cancel_edit_clicked));
+  m_cancel_button.set_visible(false);
+  m_button_box.append(m_cancel_button);
+
+  append(m_button_box);
 
   auto& tags_label = *Gtk::make_managed<Gtk::Label>("Tags:");
   tags_label.set_halign(Gtk::Align::START);
@@ -50,7 +118,7 @@ BookDetails::BookDetails(DatabaseManager& db, BookList& book_list) :
     sigc::mem_fun(*this, &BookDetails::on_book_removed));
   m_conn_updated = m_book_list.signalBookUpdated().connect(
     [this](const Book& book) {
-      if (book.id() == m_book_id) {
+      if (book.id() == m_book_id && !m_editing) {
         refresh_display();
       }
     });
@@ -118,15 +186,34 @@ void BookDetails::refresh_display() {
   m_cover.set(book.cover());
   m_title.set_markup("<span weight='bold' size='large'>" + Glib::Markup::escape_text(book.title()) + "</span>");
   m_author.set_markup("<span size='medium'>" + Glib::Markup::escape_text(book.author()) + "</span>");
+
+  if (book.description().empty()) {
+    m_description.set_markup("<span style='italic' foreground='gray'>No description</span>");
+  } else {
+    m_description.set_text(book.description());
+  }
+
   refresh_tags();
 }
 
 void BookDetails::set_book(const Book* book) {
+  // Exit edit mode if switching books
+  if (m_editing) {
+    set_edit_mode(false);
+  }
+
   if (book) {
     m_book_id = book->id();
     m_cover.set(book->cover());
     m_title.set_markup("<span weight='bold' size='large'>" + Glib::Markup::escape_text(book->title()) + "</span>");
     m_author.set_markup("<span size='medium'>" + Glib::Markup::escape_text(book->author()) + "</span>");
+
+    if (book->description().empty()) {
+      m_description.set_markup("<span style='italic' foreground='gray'>No description</span>");
+    } else {
+      m_description.set_text(book->description());
+    }
+
     refresh_tags();
   } else {
     clear();
@@ -134,10 +221,15 @@ void BookDetails::set_book(const Book* book) {
 }
 
 void BookDetails::clear() {
+  if (m_editing) {
+    set_edit_mode(false);
+  }
+
   m_book_id.clear();
   m_cover.clear();
   m_title.set_markup("<span weight='bold' size='large'></span>");
   m_author.set_markup("<span size='medium'></span>");
+  m_description.set_text("");
   while (auto child = m_tags_box.get_child_at_index(0)) {
     m_tags_box.remove(*child);
   }
@@ -163,6 +255,18 @@ void BookDetails::on_book_list_changed() {
   }
 }
 
+void BookDetails::on_read_button_clicked() {
+  if (m_book_id.empty()) return;
+
+  auto book_opt = m_book_list.findById(m_book_id);
+  if (!book_opt) {
+    std::cerr << "Book no longer exists: " << m_book_id << "\n";
+    return;
+  }
+
+  m_signalReadRequested.emit(*book_opt);
+}
+
 void BookDetails::on_open_button_clicked() {
   if (m_book_id.empty()) return;
 
@@ -213,3 +317,68 @@ void BookDetails::on_remove_tag_clicked(const std::string& tag_name) {
   m_db.remove_tag_from_book(m_book_id, tag_name);
   refresh_tags();
 }
+
+void BookDetails::set_edit_mode(bool editing) {
+  m_editing = editing;
+
+  // Toggle visibility of display vs edit widgets
+  m_title.set_visible(!editing);
+  m_author.set_visible(!editing);
+  m_description.set_visible(!editing);
+
+  m_title_entry.set_visible(editing);
+  m_author_entry.set_visible(editing);
+  m_description_scroll.set_visible(editing);
+
+  // Toggle buttons
+  m_read_button.set_visible(!editing);
+  m_open_button.set_visible(!editing);
+  m_edit_button.set_visible(!editing);
+
+  m_save_button.set_visible(editing);
+  m_cancel_button.set_visible(editing);
+}
+
+void BookDetails::on_edit_button_clicked() {
+  if (m_book_id.empty()) return;
+
+  auto book_opt = m_book_list.findById(m_book_id);
+  if (!book_opt) return;
+
+  const auto& book = *book_opt;
+
+  // Populate edit fields with current values
+  m_title_entry.set_text(book.title());
+  m_author_entry.set_text(book.author());
+  m_description_view.get_buffer()->set_text(book.description());
+
+  set_edit_mode(true);
+  m_title_entry.grab_focus();
+}
+
+void BookDetails::on_save_button_clicked() {
+  if (m_book_id.empty()) return;
+
+  std::string new_title = m_title_entry.get_text();
+  std::string new_author = m_author_entry.get_text();
+  std::string new_description = m_description_view.get_buffer()->get_text();
+
+  // Validate: title cannot be empty
+  if (new_title.empty()) {
+    m_title_entry.grab_focus();
+    return;
+  }
+
+  // Save to database and update BookList
+  if (m_book_list.updateMetadata(m_book_id, new_title, new_author, new_description)) {
+    set_edit_mode(false);
+    // refresh_display() will be called via signalBookUpdated
+  } else {
+    std::cerr << "Failed to save metadata for book: " << m_book_id << "\n";
+  }
+}
+
+void BookDetails::on_cancel_edit_clicked() {
+  set_edit_mode(false);
+  refresh_display();
+}

+ 32 - 0
src/BookDetails.hpp

@@ -6,6 +6,9 @@
 #include <gtkmm/button.h>
 #include <gtkmm/flowbox.h>
 #include <gtkmm/entry.h>
+#include <gtkmm/textview.h>
+#include <gtkmm/scrolledwindow.h>
+#include <gtkmm/stack.h>
 #include <sigc++/connection.h>
 #include <string>
 #include "Book.hpp"
@@ -23,12 +26,20 @@ public:
   // Signal emitted when the displayed book is removed from the list
   sigc::signal<void()>& signalBookRemoved() { return m_signalBookRemoved; }
 
+  // Signal emitted when user wants to read the book
+  sigc::signal<void(const Book&)>& signalReadRequested() { return m_signalReadRequested; }
+
 private:
   void on_open_button_clicked();
+  void on_read_button_clicked();
+  void on_edit_button_clicked();
+  void on_save_button_clicked();
+  void on_cancel_edit_clicked();
   void on_add_tag_button_clicked();
   void on_remove_tag_clicked(const std::string& tag_name);
   void refresh_tags();
   void refresh_display();
+  void set_edit_mode(bool editing);
   Gtk::Widget* create_tag_chip(const std::string& tag_name);
   void on_book_list_changed();
   void on_book_removed(const std::string& id);
@@ -36,16 +47,37 @@ private:
   DatabaseManager& m_db;
   BookList& m_book_list;
   std::string m_book_id;
+  bool m_editing = false;
 
   Gtk::Image m_cover;
+
+  // Display mode widgets (labels)
   Gtk::Label m_title;
   Gtk::Label m_author;
+  Gtk::Label m_description;
+
+  // Edit mode widgets (entries)
+  Gtk::Entry m_title_entry;
+  Gtk::Entry m_author_entry;
+  Gtk::ScrolledWindow m_description_scroll;
+  Gtk::TextView m_description_view;
+
+  // Container to switch between display/edit modes
+  Gtk::Box m_metadata_box{Gtk::Orientation::VERTICAL, 6};
+
+  Gtk::Box m_button_box{Gtk::Orientation::HORIZONTAL, 6};
   Gtk::Button m_open_button;
+  Gtk::Button m_read_button;
+  Gtk::Button m_edit_button;
+  Gtk::Button m_save_button;
+  Gtk::Button m_cancel_button;
+
   Gtk::FlowBox m_tags_box;
   Gtk::Entry m_tag_entry;
   Gtk::Button m_add_tag_button;
 
   sigc::signal<void()> m_signalBookRemoved;
+  sigc::signal<void(const Book&)> m_signalReadRequested;
   sigc::connection m_conn_removed;
   sigc::connection m_conn_updated;
   sigc::connection m_conn_reset;

+ 18 - 0
src/BookList.cpp

@@ -52,6 +52,24 @@ bool BookList::removeById(const std::string& id) {
 	return false;
 }
 
+bool BookList::updateMetadata(const std::string& id, const std::string& title,
+                              const std::string& author, const std::string& description) {
+	if (!m_database.update_book_metadata(id, title, author, description))
+		return false;
+
+	std::lock_guard<std::mutex> lock(m_mutex);
+	int idx = indexOfUnlocked(id);
+	if (idx >= 0) {
+		auto& book = m_books[static_cast<std::size_t>(idx)];
+		book.set_title(title);
+		book.set_author(author);
+		book.set_description(description);
+		m_signalBookUpdated.emit(book);
+		return true;
+	}
+	return false;
+}
+
 std::optional<Book> BookList::findById(const std::string& id) const {
 	std::lock_guard<std::mutex> lock(m_mutex);
 	int idx = indexOfUnlocked(id);

+ 3 - 0
src/BookList.hpp

@@ -32,6 +32,9 @@ public:
 	bool upsert(const Book& book);
 	/// Remove by id; returns false when the book was not present.
 	bool removeById(const std::string& id);
+	/// Update metadata (title, author, description) for a book by id.
+	bool updateMetadata(const std::string& id, const std::string& title,
+	                    const std::string& author, const std::string& description);
 
 	std::optional<Book> findById(const std::string& id) const;
 	int indexOf(const std::string& id) const; // -1 if not found

+ 67 - 16
src/DatabaseManager.cpp

@@ -45,6 +45,7 @@ void DatabaseManager::ensure_schema() {
     " id TEXT PRIMARY KEY,"
     " title TEXT NOT NULL,"
     " author TEXT NOT NULL DEFAULT '',"
+    " description TEXT NOT NULL DEFAULT '',"
     " file_path TEXT NOT NULL,"
     " cover_path TEXT NOT NULL DEFAULT '',"
     " size_bytes INTEGER NOT NULL DEFAULT 0,"
@@ -74,16 +75,23 @@ void DatabaseManager::ensure_schema() {
     sqlite3_free(err);
     throw std::runtime_error("schema failed: " + msg);
   }
+
+  // Migration: Add description column if it doesn't exist
+  static const char* migration =
+    "ALTER TABLE books ADD COLUMN description TEXT NOT NULL DEFAULT '';";
+  // This will fail silently if column already exists
+  sqlite3_exec(db_, migration, nullptr, nullptr, nullptr);
 }
 
 bool DatabaseManager::upsert_book(const Book& b) {
   std::lock_guard lk(mtx_);
   static const char* sql =
-    "INSERT INTO books(id,title,author,file_path,cover_path,size_bytes,mtime_unix)"
-    " VALUES(?,?,?,?,?,?,?)"
+    "INSERT INTO books(id,title,author,description,file_path,cover_path,size_bytes,mtime_unix)"
+    " VALUES(?,?,?,?,?,?,?,?)"
     " ON CONFLICT(id) DO UPDATE SET"
     "  title=excluded.title,"
     "  author=excluded.author,"
+    "  description=excluded.description,"
     "  file_path=excluded.file_path,"
     "  cover_path=excluded.cover_path,"
     "  size_bytes=excluded.size_bytes,"
@@ -91,13 +99,14 @@ bool DatabaseManager::upsert_book(const Book& b) {
   sqlite3_stmt* st = nullptr;
   if (sqlite3_prepare_v2(db_, sql, -1, &st, nullptr) != SQLITE_OK) return false;
 
-  sqlite3_bind_text(st, 1, b.id().c_str(),        -1, SQLITE_TRANSIENT);
-  sqlite3_bind_text(st, 2, b.title().c_str(),     -1, SQLITE_TRANSIENT);
-  sqlite3_bind_text(st, 3, b.author().c_str(),    -1, SQLITE_TRANSIENT);
-  sqlite3_bind_text(st, 4, b.filePath().c_str(),  -1, SQLITE_TRANSIENT);
-  sqlite3_bind_text(st, 5, b.coverPath().c_str(), -1, SQLITE_TRANSIENT);
-  sqlite3_bind_int64(st, 6, file_size(b.filePath()));
-  sqlite3_bind_int64(st, 7, file_mtime(b.filePath()));
+  sqlite3_bind_text(st, 1, b.id().c_str(),          -1, SQLITE_TRANSIENT);
+  sqlite3_bind_text(st, 2, b.title().c_str(),       -1, SQLITE_TRANSIENT);
+  sqlite3_bind_text(st, 3, b.author().c_str(),      -1, SQLITE_TRANSIENT);
+  sqlite3_bind_text(st, 4, b.description().c_str(), -1, SQLITE_TRANSIENT);
+  sqlite3_bind_text(st, 5, b.filePath().c_str(),    -1, SQLITE_TRANSIENT);
+  sqlite3_bind_text(st, 6, b.coverPath().c_str(),   -1, SQLITE_TRANSIENT);
+  sqlite3_bind_int64(st, 7, file_size(b.filePath()));
+  sqlite3_bind_int64(st, 8, file_mtime(b.filePath()));
 
   bool ok = (sqlite3_step(st) == SQLITE_DONE);
   sqlite3_finalize(st);
@@ -118,7 +127,7 @@ bool DatabaseManager::remove_book(const std::string& id) {
 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 = ?;";
+    "SELECT id,title,author,description,file_path,cover_path FROM books WHERE id = ?;";
   sqlite3_stmt* st = nullptr;
   if (sqlite3_prepare_v2(db_, sql, -1, &st, nullptr) != SQLITE_OK) return std::nullopt;
   sqlite3_bind_text(st, 1, id.c_str(), -1, SQLITE_TRANSIENT);
@@ -129,19 +138,41 @@ std::optional<Book> DatabaseManager::get_book(const std::string& id) {
       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))
+      reinterpret_cast<const char*>(sqlite3_column_text(st,4)),
+      reinterpret_cast<const char*>(sqlite3_column_text(st,5))
     );
+    const char* desc = reinterpret_cast<const char*>(sqlite3_column_text(st,3));
+    if (desc) b.set_description(desc);
     out = std::move(b);
   }
   sqlite3_finalize(st);
   return out;
 }
 
+// Internal version that doesn't lock - caller must hold mtx_
+std::vector<std::string> DatabaseManager::get_tags_for_book_unlocked(const std::string& book_id) {
+  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;
+}
+
 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;";
+    "SELECT id,title,author,description,file_path,cover_path FROM books ORDER BY title,author,id;";
   sqlite3_stmt* st = nullptr;
   std::vector<Book> rows;
 
@@ -152,16 +183,36 @@ std::vector<Book> DatabaseManager::load_all_books() {
       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))
+      reinterpret_cast<const char*>(sqlite3_column_text(st,4)),
+      reinterpret_cast<const char*>(sqlite3_column_text(st,5))
     );
-    b.set_tags(get_tags_for_book(b.id()));
+    const char* desc = reinterpret_cast<const char*>(sqlite3_column_text(st,3));
+    if (desc) b.set_description(desc);
+    b.set_tags(get_tags_for_book_unlocked(b.id()));
     rows.push_back(std::move(b));
   }
   sqlite3_finalize(st);
   return rows;
 }
 
+bool DatabaseManager::update_book_metadata(const std::string& id, const std::string& title,
+                                           const std::string& author, const std::string& description) {
+  std::lock_guard lk(mtx_);
+  static const char* sql =
+    "UPDATE books SET title = ?, author = ?, description = ? WHERE id = ?;";
+  sqlite3_stmt* st = nullptr;
+  if (sqlite3_prepare_v2(db_, sql, -1, &st, nullptr) != SQLITE_OK) return false;
+
+  sqlite3_bind_text(st, 1, title.c_str(),       -1, SQLITE_TRANSIENT);
+  sqlite3_bind_text(st, 2, author.c_str(),      -1, SQLITE_TRANSIENT);
+  sqlite3_bind_text(st, 3, description.c_str(), -1, SQLITE_TRANSIENT);
+  sqlite3_bind_text(st, 4, id.c_str(),          -1, SQLITE_TRANSIENT);
+
+  bool ok = (sqlite3_step(st) == SQLITE_DONE && sqlite3_changes(db_) > 0);
+  sqlite3_finalize(st);
+  return ok;
+}
+
 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 =

+ 5 - 0
src/DatabaseManager.hpp

@@ -34,6 +34,9 @@ public:
   std::optional<Book> get_book(const std::string& id);
   /// Load every book currently stored in the database.
   std::vector<Book>   load_all_books();
+  /// Update only the metadata fields (title, author, description) for a book.
+  bool update_book_metadata(const std::string& id, const std::string& title,
+                            const std::string& author, const std::string& description);
   /// 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.
@@ -83,6 +86,8 @@ public:
 private:
   static int busy_handler(void* /*opaque*/, int prev_sleep_ms);
   void prepare_statements();
+  /// Internal version of get_tags_for_book that doesn't acquire mutex (caller must hold it)
+  std::vector<std::string> get_tags_for_book_unlocked(const std::string& book_id);
 
   std::string db_path_;
   std::mutex mtx_;

+ 75 - 0
src/FolderScanner.cpp

@@ -0,0 +1,75 @@
+#include "FolderScanner.hpp"
+#include <filesystem>
+#include <algorithm>
+#include <cctype>
+
+namespace fs = std::filesystem;
+
+namespace {
+
+std::string toLower(const std::string& s) {
+  std::string result = s;
+  std::transform(result.begin(), result.end(), result.begin(),
+    [](unsigned char c) { return std::tolower(c); });
+  return result;
+}
+
+} // namespace
+
+std::vector<std::string> FolderScanner::supportedExtensions() {
+  return {"epub", "pdf"};
+}
+
+bool FolderScanner::isSupportedFormat(const std::string& extension) {
+  std::string ext = toLower(extension);
+  // Remove leading dot if present
+  if (!ext.empty() && ext[0] == '.') {
+    ext = ext.substr(1);
+  }
+  for (const auto& supported : supportedExtensions()) {
+    if (ext == supported) {
+      return true;
+    }
+  }
+  return false;
+}
+
+std::vector<FolderScanner::FileInfo> FolderScanner::scan(
+    const std::string& folder_path, bool recursive) {
+  std::vector<FileInfo> results;
+
+  try {
+    if (!fs::exists(folder_path) || !fs::is_directory(folder_path)) {
+      return results;
+    }
+
+    auto processEntry = [&](const fs::directory_entry& entry) {
+      if (!entry.is_regular_file()) return;
+
+      const auto& path = entry.path();
+      std::string ext = path.extension().string();
+
+      if (isSupportedFormat(ext)) {
+        FileInfo info;
+        info.path = path.string();
+        info.extension = toLower(ext.substr(1)); // Remove the dot
+        results.push_back(std::move(info));
+      }
+    };
+
+    if (recursive) {
+      for (const auto& entry : fs::recursive_directory_iterator(folder_path,
+          fs::directory_options::skip_permission_denied)) {
+        processEntry(entry);
+      }
+    } else {
+      for (const auto& entry : fs::directory_iterator(folder_path)) {
+        processEntry(entry);
+      }
+    }
+  } catch (const fs::filesystem_error& e) {
+    // Silently ignore filesystem errors (permissions, etc.)
+  }
+
+  return results;
+}

+ 31 - 0
src/FolderScanner.hpp

@@ -0,0 +1,31 @@
+#pragma once
+
+#include <string>
+#include <vector>
+#include <functional>
+
+/**
+ * @brief Scans a folder for book files (EPUB, PDF).
+ *
+ * Used for watching folders and auto-importing new books.
+ */
+class FolderScanner {
+public:
+  /// File info returned by scan.
+  struct FileInfo {
+    std::string path;
+    std::string extension;
+  };
+
+  /// Scan a folder recursively for supported book formats.
+  /// @param folder_path Path to the folder to scan.
+  /// @param recursive Whether to scan subdirectories.
+  /// @return List of found book files.
+  static std::vector<FileInfo> scan(const std::string& folder_path, bool recursive = true);
+
+  /// Check if a file extension is a supported book format.
+  static bool isSupportedFormat(const std::string& extension);
+
+  /// Get list of supported extensions (lowercase, without dot).
+  static std::vector<std::string> supportedExtensions();
+};

+ 623 - 0
src/ReaderView.cpp

@@ -0,0 +1,623 @@
+#include "ReaderView.hpp"
+#include <tinyxml2.h>
+#include <glibmm/miscutils.h>
+#include <iostream>
+#include <algorithm>
+#include <regex>
+#include <sstream>
+#include <map>
+
+// Helper to convert path to URI for Poppler
+static std::string to_uri(const std::string& path) {
+  return Glib::filename_to_uri(path);
+}
+
+static std::string lower_ext(const std::string& p) {
+  auto dot = p.find_last_of('.');
+  std::string ext = (dot == std::string::npos) ? "" : p.substr(dot + 1);
+  std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower);
+  return ext;
+}
+
+// Trim whitespace from string
+static std::string trim(const std::string& s) {
+  auto start = s.find_first_not_of(" \t\n\r");
+  if (start == std::string::npos) return "";
+  auto end = s.find_last_not_of(" \t\n\r");
+  return s.substr(start, end - start + 1);
+}
+
+// Collapse multiple whitespace into single space
+static std::string collapse_whitespace(const std::string& s) {
+  std::string result;
+  result.reserve(s.size());
+  bool in_space = false;
+  for (char c : s) {
+    if (std::isspace(static_cast<unsigned char>(c))) {
+      if (!in_space) {
+        result += ' ';
+        in_space = true;
+      }
+    } else {
+      result += c;
+      in_space = false;
+    }
+  }
+  return result;
+}
+
+ReaderView::ReaderView(DatabaseManager& db) :
+  Gtk::Box(Gtk::Orientation::VERTICAL),
+  m_db(db) {
+  build_ui();
+}
+
+ReaderView::~ReaderView() {
+  close();
+}
+
+// EPUB helper: read a file from the EPUB zip archive
+std::string ReaderView::epub_read_file(const std::string& path) {
+  if (!m_epub_zip) return "";
+
+  zip_stat_t st{};
+  if (zip_stat(m_epub_zip, path.c_str(), 0, &st) != 0) {
+    std::cerr << "ReaderView: Failed to stat " << path << " in EPUB\n";
+    return "";
+  }
+
+  zip_file* zf = zip_fopen(m_epub_zip, path.c_str(), 0);
+  if (!zf) {
+    std::cerr << "ReaderView: Failed to open " << path << " in EPUB\n";
+    return "";
+  }
+
+  std::string content(static_cast<std::size_t>(st.size), '\0');
+  zip_fread(zf, content.data(), st.size);
+  zip_fclose(zf);
+  return content;
+}
+
+// Extract text recursively from XML element
+static void extract_text_recursive(tinyxml2::XMLElement* elem, std::ostringstream& out, bool& need_space) {
+  if (!elem) return;
+
+  const char* name = elem->Name();
+
+  // Block elements that should have line breaks
+  bool is_block = (strcmp(name, "p") == 0 || strcmp(name, "div") == 0 ||
+                   strcmp(name, "h1") == 0 || strcmp(name, "h2") == 0 ||
+                   strcmp(name, "h3") == 0 || strcmp(name, "h4") == 0 ||
+                   strcmp(name, "h5") == 0 || strcmp(name, "h6") == 0 ||
+                   strcmp(name, "li") == 0 || strcmp(name, "br") == 0 ||
+                   strcmp(name, "tr") == 0 || strcmp(name, "blockquote") == 0);
+
+  if (is_block && out.tellp() > 0) {
+    out << "\n\n";
+    need_space = false;
+  }
+
+  // Process children
+  for (auto* node = elem->FirstChild(); node; node = node->NextSibling()) {
+    if (auto* text = node->ToText()) {
+      std::string t = trim(text->Value());
+      if (!t.empty()) {
+        if (need_space) out << " ";
+        out << t;
+        need_space = true;
+      }
+    } else if (auto* child = node->ToElement()) {
+      extract_text_recursive(child, out, need_space);
+    }
+  }
+
+  if (is_block) {
+    out << "\n";
+    need_space = false;
+  }
+}
+
+std::string ReaderView::extract_text_from_html(const std::string& html) {
+  tinyxml2::XMLDocument doc;
+  // Try parsing as XML first (XHTML)
+  if (doc.Parse(html.c_str(), html.size()) != tinyxml2::XML_SUCCESS) {
+    // Fallback: strip HTML tags with regex
+    std::string text = std::regex_replace(html, std::regex("<[^>]*>"), " ");
+    text = collapse_whitespace(text);
+    return trim(text);
+  }
+
+  std::ostringstream out;
+  bool need_space = false;
+
+  // Find body element
+  auto* root = doc.RootElement();
+  if (!root) return "";
+
+  tinyxml2::XMLElement* body = nullptr;
+  if (strcmp(root->Name(), "html") == 0) {
+    body = root->FirstChildElement("body");
+  } else if (strcmp(root->Name(), "body") == 0) {
+    body = root;
+  }
+
+  if (body) {
+    extract_text_recursive(body, out, need_space);
+  } else {
+    // No body, try extracting from root
+    extract_text_recursive(root, out, need_space);
+  }
+
+  std::string result = out.str();
+  // Clean up excessive newlines
+  result = std::regex_replace(result, std::regex("\n{3,}"), "\n\n");
+  return trim(result);
+}
+
+void ReaderView::parse_epub_spine() {
+  m_epub_chapters.clear();
+
+  // Read container.xml to find OPF
+  std::string container = epub_read_file("META-INF/container.xml");
+  if (container.empty()) {
+    std::cerr << "ReaderView: Cannot read container.xml\n";
+    return;
+  }
+
+  tinyxml2::XMLDocument containerDoc;
+  if (containerDoc.Parse(container.c_str(), container.size()) != tinyxml2::XML_SUCCESS) {
+    std::cerr << "ReaderView: Cannot parse container.xml\n";
+    return;
+  }
+
+  auto* rootfiles = containerDoc.FirstChildElement("container");
+  if (rootfiles) rootfiles = rootfiles->FirstChildElement("rootfiles");
+  auto* rootfile = rootfiles ? rootfiles->FirstChildElement("rootfile") : nullptr;
+  if (!rootfile) {
+    std::cerr << "ReaderView: No rootfile in container.xml\n";
+    return;
+  }
+
+  const char* opfPath = rootfile->Attribute("full-path");
+  if (!opfPath) {
+    std::cerr << "ReaderView: No full-path in rootfile\n";
+    return;
+  }
+
+  // Get root directory for relative paths
+  std::string opfPathStr = opfPath;
+  auto slash = opfPathStr.find_last_of('/');
+  m_epub_root_dir = (slash == std::string::npos) ? "" : opfPathStr.substr(0, slash + 1);
+
+  // Read and parse OPF
+  std::string opfContent = epub_read_file(opfPath);
+  if (opfContent.empty()) {
+    std::cerr << "ReaderView: Cannot read OPF file\n";
+    return;
+  }
+
+  tinyxml2::XMLDocument opfDoc;
+  if (opfDoc.Parse(opfContent.c_str(), opfContent.size()) != tinyxml2::XML_SUCCESS) {
+    std::cerr << "ReaderView: Cannot parse OPF file\n";
+    return;
+  }
+
+  auto* package = opfDoc.FirstChildElement("package");
+  if (!package) {
+    std::cerr << "ReaderView: No package element in OPF\n";
+    return;
+  }
+
+  // Build manifest map (id -> href)
+  std::map<std::string, std::string> manifest;
+  auto* manifestElem = package->FirstChildElement("manifest");
+  if (manifestElem) {
+    for (auto* item = manifestElem->FirstChildElement("item"); item; item = item->NextSiblingElement("item")) {
+      const char* id = item->Attribute("id");
+      const char* href = item->Attribute("href");
+      if (id && href) {
+        manifest[id] = href;
+      }
+    }
+  }
+
+  // Parse spine to get reading order
+  auto* spine = package->FirstChildElement("spine");
+  if (!spine) {
+    std::cerr << "ReaderView: No spine in OPF\n";
+    return;
+  }
+
+  int chapterNum = 1;
+  for (auto* itemref = spine->FirstChildElement("itemref"); itemref; itemref = itemref->NextSiblingElement("itemref")) {
+    const char* idref = itemref->Attribute("idref");
+    if (!idref) continue;
+
+    auto it = manifest.find(idref);
+    if (it == manifest.end()) continue;
+
+    EpubChapter chapter;
+    chapter.href = m_epub_root_dir + it->second;
+    chapter.title = "Chapter " + std::to_string(chapterNum++);
+    // Content loaded lazily when chapter is viewed
+    m_epub_chapters.push_back(std::move(chapter));
+  }
+
+  std::cout << "ReaderView: Found " << m_epub_chapters.size() << " chapters in EPUB\n";
+}
+
+bool ReaderView::open_epub(const std::string& path) {
+  int err = 0;
+  m_epub_zip = zip_open(path.c_str(), ZIP_RDONLY, &err);
+  if (!m_epub_zip) {
+    std::cerr << "ReaderView: Cannot open EPUB as ZIP (error " << err << ")\n";
+    return false;
+  }
+
+  parse_epub_spine();
+
+  if (m_epub_chapters.empty()) {
+    zip_close(m_epub_zip);
+    m_epub_zip = nullptr;
+    std::cerr << "ReaderView: EPUB has no readable chapters\n";
+    return false;
+  }
+
+  m_is_epub = true;
+  m_total_pages = static_cast<int>(m_epub_chapters.size());
+  return true;
+}
+
+void ReaderView::close_epub() {
+  if (m_epub_zip) {
+    zip_close(m_epub_zip);
+    m_epub_zip = nullptr;
+  }
+  m_epub_chapters.clear();
+  m_epub_root_dir.clear();
+  m_current_chapter_text.clear();
+  m_is_epub = false;
+}
+
+void ReaderView::build_ui() {
+  // Toolbar
+  m_toolbar.set_margin_top(6);
+  m_toolbar.set_margin_bottom(6);
+  m_toolbar.set_margin_start(12);
+  m_toolbar.set_margin_end(12);
+
+  m_close_button.set_icon_name("go-previous-symbolic");
+  m_close_button.set_tooltip_text("Close reader and return to library");
+  m_close_button.signal_clicked().connect([this]() {
+    save_progress();
+    m_signal_close_requested.emit();
+  });
+  m_toolbar.append(m_close_button);
+
+  // Spacer
+  auto& spacer1 = *Gtk::make_managed<Gtk::Box>();
+  spacer1.set_hexpand(true);
+  m_toolbar.append(spacer1);
+
+  m_prev_button.set_icon_name("go-previous-symbolic");
+  m_prev_button.set_tooltip_text("Previous page");
+  m_prev_button.signal_clicked().connect(sigc::mem_fun(*this, &ReaderView::prev_page));
+  m_toolbar.append(m_prev_button);
+
+  m_nav_label.set_margin_start(12);
+  m_nav_label.set_margin_end(12);
+  m_toolbar.append(m_nav_label);
+
+  m_next_button.set_icon_name("go-next-symbolic");
+  m_next_button.set_tooltip_text("Next page");
+  m_next_button.signal_clicked().connect(sigc::mem_fun(*this, &ReaderView::next_page));
+  m_toolbar.append(m_next_button);
+
+  // Spacer
+  auto& spacer2 = *Gtk::make_managed<Gtk::Box>();
+  spacer2.set_hexpand(true);
+  m_toolbar.append(spacer2);
+
+  append(m_toolbar);
+
+  // Loading indicator (hidden by default)
+  m_spinner.set_spinning(false);
+  m_loading_label.set_text("Loading...");
+
+  // Drawing area for page rendering
+  m_canvas.set_draw_func(sigc::mem_fun(*this, &ReaderView::on_draw));
+  m_canvas.set_hexpand(true);
+  m_canvas.set_vexpand(true);
+  m_canvas.set_content_width(600);
+  m_canvas.set_content_height(800);
+
+  m_scroll.set_child(m_canvas);
+  m_scroll.set_policy(Gtk::PolicyType::AUTOMATIC, Gtk::PolicyType::AUTOMATIC);
+  m_scroll.set_vexpand(true);
+  m_scroll.set_hexpand(true);
+  append(m_scroll);
+
+  update_nav_label();
+}
+
+void ReaderView::update_nav_label() {
+  if (m_total_pages > 0) {
+    m_nav_label.set_text(std::to_string(m_current_page + 1) + " / " + std::to_string(m_total_pages));
+    m_prev_button.set_sensitive(m_current_page > 0);
+    m_next_button.set_sensitive(m_current_page < m_total_pages - 1);
+  } else {
+    m_nav_label.set_text("No document");
+    m_prev_button.set_sensitive(false);
+    m_next_button.set_sensitive(false);
+  }
+}
+
+bool ReaderView::open(const Book& book) {
+  close();
+
+  m_file_path = book.filePath();
+  m_book_id = book.id();
+
+  if (m_file_path.empty()) {
+    std::cerr << "ReaderView: Book has no file path\n";
+    return false;
+  }
+
+  std::string ext = lower_ext(m_file_path);
+
+  if (ext == "pdf") {
+    // Open PDF with Poppler
+    GError* gerr = nullptr;
+    m_pdf_doc = poppler_document_new_from_file(to_uri(m_file_path).c_str(), nullptr, &gerr);
+    if (!m_pdf_doc) {
+      std::string msg = "Failed to open PDF";
+      if (gerr) {
+        msg = gerr->message ? gerr->message : msg;
+        g_error_free(gerr);
+      }
+      std::cerr << "ReaderView: " << msg << "\n";
+      return false;
+    }
+
+    m_total_pages = poppler_document_get_n_pages(m_pdf_doc);
+    m_is_epub = false;
+    m_is_open = true;
+
+    // Load saved progress
+    std::string progress_str = m_db.get_setting("reading_progress_" + m_book_id, "0");
+    try {
+      m_current_page = std::stoi(progress_str);
+      if (m_current_page < 0 || m_current_page >= m_total_pages) {
+        m_current_page = 0;
+      }
+    } catch (...) {
+      m_current_page = 0;
+    }
+
+    render_current_page();
+    update_nav_label();
+    return true;
+  } else if (ext == "epub") {
+    if (!open_epub(m_file_path)) {
+      return false;
+    }
+
+    m_is_open = true;
+
+    // Load saved progress
+    std::string progress_str = m_db.get_setting("reading_progress_" + m_book_id, "0");
+    try {
+      m_current_page = std::stoi(progress_str);
+      if (m_current_page < 0 || m_current_page >= m_total_pages) {
+        m_current_page = 0;
+      }
+    } catch (...) {
+      m_current_page = 0;
+    }
+
+    render_current_page();
+    update_nav_label();
+    return true;
+  }
+
+  std::cerr << "ReaderView: Unsupported format: " << ext << "\n";
+  return false;
+}
+
+void ReaderView::close() {
+  if (m_is_open) {
+    save_progress();
+  }
+
+  if (m_pdf_doc) {
+    g_object_unref(m_pdf_doc);
+    m_pdf_doc = nullptr;
+  }
+
+  close_epub();
+
+  m_page_surface.reset();
+  m_is_open = false;
+  m_current_page = 0;
+  m_total_pages = 0;
+  m_book_id.clear();
+  m_file_path.clear();
+  m_page_width = 0;
+  m_page_height = 0;
+
+  update_nav_label();
+  m_canvas.queue_draw();
+}
+
+void ReaderView::go_to_page(int page) {
+  if (!m_is_open || m_total_pages == 0) return;
+
+  page = std::clamp(page, 0, m_total_pages - 1);
+  if (page == m_current_page) return;
+
+  m_current_page = page;
+  render_current_page();
+  update_nav_label();
+}
+
+void ReaderView::next_page() {
+  go_to_page(m_current_page + 1);
+}
+
+void ReaderView::prev_page() {
+  go_to_page(m_current_page - 1);
+}
+
+void ReaderView::render_current_page() {
+  if (!m_is_open) return;
+
+  if (m_is_epub) {
+    render_epub_page();
+  } else {
+    render_pdf_page();
+  }
+}
+
+void ReaderView::render_pdf_page() {
+  if (!m_pdf_doc) return;
+
+  PopplerPage* page = poppler_document_get_page(m_pdf_doc, m_current_page);
+  if (!page) {
+    std::cerr << "ReaderView: Failed to get page " << m_current_page << "\n";
+    return;
+  }
+
+  double wpt = 0, hpt = 0;
+  poppler_page_get_size(page, &wpt, &hpt);
+
+  // Scale to fit a reasonable screen size while maintaining quality
+  // Use 1.5x scale for good readability
+  const double scale = 1.5;
+  int width = std::max(1, static_cast<int>(wpt * scale));
+  int height = std::max(1, static_cast<int>(hpt * scale));
+
+  // Create Cairo surface
+  m_page_surface = Cairo::ImageSurface::create(Cairo::Surface::Format::ARGB32, width, height);
+  auto cr = Cairo::Context::create(m_page_surface);
+
+  // White background
+  cr->set_source_rgb(1.0, 1.0, 1.0);
+  cr->paint();
+
+  // Render page
+  cr->scale(scale, scale);
+  poppler_page_render(page, cr->cobj());
+
+  m_page_surface->flush();
+  m_page_width = width;
+  m_page_height = height;
+
+  // Update canvas size
+  m_canvas.set_content_width(width);
+  m_canvas.set_content_height(height);
+  m_canvas.queue_draw();
+
+  g_object_unref(page);
+}
+
+void ReaderView::render_epub_page() {
+  if (m_current_page < 0 || m_current_page >= static_cast<int>(m_epub_chapters.size())) {
+    return;
+  }
+
+  auto& chapter = m_epub_chapters[m_current_page];
+
+  // Load chapter content if not already loaded
+  if (chapter.content.empty()) {
+    std::string html = epub_read_file(chapter.href);
+    if (!html.empty()) {
+      chapter.content = extract_text_from_html(html);
+    }
+    if (chapter.content.empty()) {
+      chapter.content = "(No content)";
+    }
+  }
+
+  m_current_chapter_text = chapter.content;
+
+  // Render text using Pango
+  const int page_width = 700;
+  const int margin = 40;
+  const int text_width = page_width - 2 * margin;
+
+  // Create a temporary surface to measure text height
+  auto temp_surface = Cairo::ImageSurface::create(Cairo::Surface::Format::ARGB32, 1, 1);
+  auto temp_cr = Cairo::Context::create(temp_surface);
+
+  // Create Pango layout
+  auto layout = Pango::Layout::create(temp_cr);
+  layout->set_width(text_width * PANGO_SCALE);
+  layout->set_wrap(Pango::WrapMode::WORD_CHAR);
+
+  // Set font
+  Pango::FontDescription font("Sans 14");
+  layout->set_font_description(font);
+
+  // Set text
+  layout->set_text(m_current_chapter_text);
+
+  // Get layout dimensions
+  int text_w, text_h;
+  layout->get_pixel_size(text_w, text_h);
+
+  // Calculate total height with margins
+  int page_height = std::max(600, text_h + 2 * margin);
+
+  // Create actual surface
+  m_page_surface = Cairo::ImageSurface::create(Cairo::Surface::Format::ARGB32, page_width, page_height);
+  auto cr = Cairo::Context::create(m_page_surface);
+
+  // White background
+  cr->set_source_rgb(1.0, 1.0, 1.0);
+  cr->paint();
+
+  // Draw text
+  cr->set_source_rgb(0.1, 0.1, 0.1);
+  cr->move_to(margin, margin);
+
+  // Recreate layout with actual context
+  auto render_layout = Pango::Layout::create(cr);
+  render_layout->set_width(text_width * PANGO_SCALE);
+  render_layout->set_wrap(Pango::WrapMode::WORD_CHAR);
+  render_layout->set_font_description(font);
+  render_layout->set_text(m_current_chapter_text);
+
+  render_layout->show_in_cairo_context(cr);
+
+  m_page_surface->flush();
+  m_page_width = page_width;
+  m_page_height = page_height;
+
+  // Update canvas size
+  m_canvas.set_content_width(page_width);
+  m_canvas.set_content_height(page_height);
+  m_canvas.queue_draw();
+}
+
+void ReaderView::on_draw(const Cairo::RefPtr<Cairo::Context>& cr, int width, int height) {
+  // Gray background
+  cr->set_source_rgb(0.9, 0.9, 0.9);
+  cr->paint();
+
+  if (!m_page_surface) return;
+
+  // Center the page
+  double x = (width - m_page_width) / 2.0;
+  double y = (height - m_page_height) / 2.0;
+  if (x < 0) x = 0;
+  if (y < 0) y = 0;
+
+  cr->set_source(m_page_surface, x, y);
+  cr->paint();
+}
+
+void ReaderView::save_progress() {
+  if (m_book_id.empty()) return;
+
+  m_db.set_setting("reading_progress_" + m_book_id, std::to_string(m_current_page));
+}

+ 115 - 0
src/ReaderView.hpp

@@ -0,0 +1,115 @@
+#pragma once
+
+#include <gtkmm/box.h>
+#include <gtkmm/scrolledwindow.h>
+#include <gtkmm/drawingarea.h>
+#include <gtkmm/button.h>
+#include <gtkmm/label.h>
+#include <gtkmm/spinner.h>
+#include <cairomm/cairomm.h>
+#include <pangomm.h>
+#include <poppler.h>
+#include <zip.h>
+#include <string>
+#include <memory>
+#include <vector>
+
+#include "Book.hpp"
+#include "DatabaseManager.hpp"
+
+// Forward declaration
+struct EpubChapter {
+  std::string href;     // Path within EPUB
+  std::string title;    // Chapter title (from TOC or filename)
+  std::string content;  // Extracted text content
+};
+
+/**
+ * @brief A widget for reading PDF and EPUB files.
+ *
+ * Uses Poppler for PDF rendering with Cairo.
+ * Supports page navigation and tracks reading progress.
+ */
+class ReaderView : public Gtk::Box {
+public:
+  ReaderView(DatabaseManager& db);
+  ~ReaderView() override;
+
+  /// Open a book for reading. Returns true on success.
+  bool open(const Book& book);
+
+  /// Close the current book.
+  void close();
+
+  /// Check if a book is currently open.
+  bool is_open() const { return m_is_open; }
+
+  /// Get current page (0-indexed).
+  int current_page() const { return m_current_page; }
+
+  /// Get total page count.
+  int total_pages() const { return m_total_pages; }
+
+  /// Navigate to a specific page (0-indexed).
+  void go_to_page(int page);
+
+  /// Navigate to next page.
+  void next_page();
+
+  /// Navigate to previous page.
+  void prev_page();
+
+  /// Signal emitted when the user wants to close the reader.
+  sigc::signal<void()>& signal_close_requested() { return m_signal_close_requested; }
+
+private:
+  void build_ui();
+  void update_nav_label();
+  void render_current_page();
+  void render_pdf_page();
+  void render_epub_page();
+  void on_draw(const Cairo::RefPtr<Cairo::Context>& cr, int width, int height);
+  void save_progress();
+
+  // EPUB helpers
+  bool open_epub(const std::string& path);
+  void close_epub();
+  std::string epub_read_file(const std::string& path);
+  std::string extract_text_from_html(const std::string& html);
+  void parse_epub_spine();
+
+  DatabaseManager& m_db;
+  std::string m_book_id;
+  std::string m_file_path;
+  bool m_is_open = false;
+  bool m_is_epub = false;
+  int m_current_page = 0;  // For EPUB: current chapter index
+  int m_total_pages = 0;   // For EPUB: total chapter count
+
+  // Poppler document (for PDFs)
+  PopplerDocument* m_pdf_doc = nullptr;
+  Cairo::RefPtr<Cairo::ImageSurface> m_page_surface;
+
+  // EPUB data
+  zip* m_epub_zip = nullptr;
+  std::string m_epub_root_dir;  // Root directory for relative paths
+  std::vector<EpubChapter> m_epub_chapters;
+  std::string m_current_chapter_text;
+
+  // UI widgets
+  Gtk::Box m_toolbar{Gtk::Orientation::HORIZONTAL, 6};
+  Gtk::Button m_close_button;
+  Gtk::Button m_prev_button;
+  Gtk::Button m_next_button;
+  Gtk::Label m_nav_label;
+  Gtk::ScrolledWindow m_scroll;
+  Gtk::DrawingArea m_canvas;
+  Gtk::Spinner m_spinner;
+  Gtk::Label m_loading_label;
+
+  // Cached page dimensions
+  double m_page_width = 0;
+  double m_page_height = 0;
+
+  sigc::signal<void()> m_signal_close_requested;
+};

+ 73 - 6
src/SettingsDialog.cpp

@@ -5,6 +5,7 @@
 // Setting keys
 static constexpr const char* SETTING_TILE_SIZE = "tile_size";
 static constexpr const char* SETTING_IMPORT_FOLDER = "import_folder";
+static constexpr const char* SETTING_WATCHED_FOLDER = "watched_folder";
 
 // Defaults
 static constexpr int DEFAULT_TILE_SIZE = 180;
@@ -28,11 +29,20 @@ SettingsDialog::SettingsDialog(Gtk::Window& parent, DatabaseManager& db)
   m_tileSizeHint.set_xalign(0.0f);
   m_importFolderHint.add_css_class("dim-label");
   m_importFolderHint.set_xalign(0.0f);
+  m_watchedFolderHint.add_css_class("dim-label");
+  m_watchedFolderHint.set_xalign(0.0f);
 
   // Configure import folder button
   m_importFolderButton.set_hexpand(true);
   m_importFolderButton.set_halign(Gtk::Align::FILL);
 
+  // Configure watched folder buttons
+  m_watchedFolderButton.set_hexpand(true);
+  m_watchedFolderButton.set_halign(Gtk::Align::FILL);
+  m_watchedFolderButton.set_tooltip_text("Select folder to watch for new books");
+  m_watchedFolderClearButton.set_tooltip_text("Stop watching folder");
+  m_watchedFolderClearButton.add_css_class("flat");
+
   // Build grid layout
   m_grid.set_row_spacing(6);
   m_grid.set_column_spacing(12);
@@ -61,20 +71,35 @@ SettingsDialog::SettingsDialog(Gtk::Window& parent, DatabaseManager& db)
 
   // Row 5: spacing
   auto* spacer2 = Gtk::make_managed<Gtk::Label>("");
-  spacer2->set_margin_top(12);
+  spacer2->set_margin_top(6);
   m_grid.attach(*spacer2, 0, 5);
 
-  // Row 6: Library maintenance
+  // Row 6: Watched folder
+  m_watchedFolderLabel.set_halign(Gtk::Align::END);
+  m_grid.attach(m_watchedFolderLabel, 0, 6);
+  m_watchedFolderBox.append(m_watchedFolderButton);
+  m_watchedFolderBox.append(m_watchedFolderClearButton);
+  m_grid.attach(m_watchedFolderBox, 1, 6);
+
+  // Row 7: Watched folder hint
+  m_grid.attach(m_watchedFolderHint, 1, 7);
+
+  // Row 8: spacing
+  auto* spacer3 = Gtk::make_managed<Gtk::Label>("");
+  spacer3->set_margin_top(12);
+  m_grid.attach(*spacer3, 0, 8);
+
+  // Row 9: Library maintenance
   m_maintenanceLabel.set_halign(Gtk::Align::END);
-  m_grid.attach(m_maintenanceLabel, 0, 6);
+  m_grid.attach(m_maintenanceLabel, 0, 9);
   m_scanButton.set_hexpand(false);
   m_scanButton.set_halign(Gtk::Align::START);
-  m_grid.attach(m_scanButton, 1, 6);
+  m_grid.attach(m_scanButton, 1, 9);
 
-  // Row 7: Scan hint
+  // Row 10: Scan hint
   m_scanHint.add_css_class("dim-label");
   m_scanHint.set_xalign(0.0f);
-  m_grid.attach(m_scanHint, 1, 7);
+  m_grid.attach(m_scanHint, 1, 10);
 
   m_contentBox.append(m_grid);
 
@@ -130,6 +155,40 @@ SettingsDialog::SettingsDialog(Gtk::Window& parent, DatabaseManager& db)
     dialog.present();
   });
 
+  // Watched folder button opens file chooser
+  m_watchedFolderButton.signal_clicked().connect([this]() {
+    auto dialog = Gtk::FileChooserDialog(
+      "Select Folder to Watch",
+      Gtk::FileChooser::Action::SELECT_FOLDER);
+    dialog.set_transient_for(*this);
+    dialog.set_modal(true);
+    dialog.add_button("Cancel", Gtk::ResponseType::CANCEL);
+    dialog.add_button("Select", Gtk::ResponseType::ACCEPT);
+
+    if (!m_watchedFolderPath.empty()) {
+      dialog.set_current_folder(Gio::File::create_for_path(m_watchedFolderPath));
+    }
+
+    dialog.signal_response().connect([this, &dialog](int response) {
+      if (response == Gtk::ResponseType::ACCEPT) {
+        auto file = dialog.get_file();
+        if (file) {
+          m_watchedFolderPath = file->get_path();
+          m_watchedFolderButton.set_label(m_watchedFolderPath);
+        }
+      }
+      dialog.close();
+    });
+
+    dialog.present();
+  });
+
+  // Watched folder clear button
+  m_watchedFolderClearButton.signal_clicked().connect([this]() {
+    m_watchedFolderPath.clear();
+    m_watchedFolderButton.set_label("(none)");
+  });
+
   // Load current settings
   loadSettings();
 }
@@ -144,12 +203,20 @@ void SettingsDialog::loadSettings() {
     m_importFolderPath = Glib::get_home_dir();
   }
   m_importFolderButton.set_label(m_importFolderPath);
+
+  m_watchedFolderPath = m_db.get_setting(SETTING_WATCHED_FOLDER, "");
+  if (m_watchedFolderPath.empty()) {
+    m_watchedFolderButton.set_label("(none)");
+  } else {
+    m_watchedFolderButton.set_label(m_watchedFolderPath);
+  }
 }
 
 void SettingsDialog::saveSettings() {
   int tileSize = static_cast<int>(m_tileSizeSpinButton.get_value());
   m_db.set_setting_int(SETTING_TILE_SIZE, tileSize);
   m_db.set_setting(SETTING_IMPORT_FOLDER, m_importFolderPath);
+  m_db.set_setting(SETTING_WATCHED_FOLDER, m_watchedFolderPath);
 }
 
 void SettingsDialog::onSaveClicked() {

+ 9 - 0
src/SettingsDialog.hpp

@@ -16,6 +16,7 @@
  * Allows users to adjust:
  * - Grid tile size (book cover display size)
  * - Default import folder
+ * - Watched folder for auto-import
  */
 class SettingsDialog : public Gtk::Dialog {
 public:
@@ -50,6 +51,14 @@ private:
   Gtk::Label  m_importFolderHint {"Folder shown when importing books"};
   std::string m_importFolderPath;
 
+  // Watched folder for auto-import
+  Gtk::Label  m_watchedFolderLabel {"Watched Folder:"};
+  Gtk::Box    m_watchedFolderBox {Gtk::Orientation::HORIZONTAL, 6};
+  Gtk::Button m_watchedFolderButton;
+  Gtk::Button m_watchedFolderClearButton {"Clear"};
+  Gtk::Label  m_watchedFolderHint {"Books in this folder are automatically imported on startup"};
+  std::string m_watchedFolderPath;
+
   // Library maintenance
   Gtk::Label  m_maintenanceLabel {"Library Maintenance:"};
   Gtk::Button m_scanButton {"Remove Missing Files"};