Przeglądaj źródła

Add tag management UI with rename, delete, and cleanup

- Add TagManagerDialog for comprehensive tag management
- Display all tags with usage counts (books using each tag)
- Implement rename tag with automatic merge if target exists
- Implement delete individual tags
- Add "Clean Up Unused" button to remove orphan tags
- Add DatabaseManager methods: get_all_tags(), rename_tag(),
  delete_tag(), delete_unused_tags()
- Add Tags button to header bar for quick access

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Bernardo Magri 1 miesiąc temu
rodzic
commit
eeab8f50b9

+ 9 - 7
PLAN.md

@@ -159,14 +159,16 @@ Not implemented (deferred):
 - [ ] Batch metadata edit (complex UI, lower priority)
 
 ### 3.3 Tag Management UI
-**Files**: New files required
-**Status**: Not Started
+**Files**: `src/TagManagerDialog.cpp`, `src/TagManagerDialog.hpp`, `src/DatabaseManager.cpp`, `src/DatabaseManager.hpp`
+**Status**: COMPLETED
 
-- [ ] Dedicated tag browser/manager panel
-- [ ] Rename tags
-- [ ] Merge duplicate tags
-- [ ] Delete unused tags
-- [ ] Tag usage statistics
+Implemented:
+- [x] Tag manager dialog accessible via Tags button in header bar
+- [x] Display all tags with usage counts
+- [x] Rename tags (with automatic merge if target exists)
+- [x] Delete individual tags
+- [x] Delete unused tags (cleanup button)
+- [x] DatabaseManager methods: get_all_tags(), rename_tag(), delete_tag(), delete_unused_tags()
 
 ### 3.4 Settings Panel
 **Files**: New files required

+ 2 - 1
meson.build

@@ -11,7 +11,8 @@ 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/BookShelf.cpp', 'src/BookTile.cpp', 'src/BookImport.cpp', 'src/BookDetails.cpp',
+'src/TagManagerDialog.cpp']
 
 executable('bibliotheca',
   src,

+ 16 - 0
src/BibliothecaWindow.cpp

@@ -188,6 +188,14 @@ void BibliothecaWindow::buildHeaderBar() {
   m_searchButton.signal_toggled().connect(sigc::mem_fun(*this, &BibliothecaWindow::onSearchToggle));
   m_headerBar.pack_start(m_searchButton);
 
+  // Tags button
+  m_tagsButton.set_icon_name("tag-symbolic");
+  m_tagsButton.set_has_frame(false);
+  m_tagsButton.add_css_class("flat");
+  m_tagsButton.set_tooltip_text("Manage tags");
+  m_tagsButton.signal_clicked().connect(sigc::mem_fun(*this, &BibliothecaWindow::onManageTagsClicked));
+  m_headerBar.pack_start(m_tagsButton);
+
   // Add (+) button on the right
   m_addBookButton.set_icon_name("list-add-symbolic");
   m_addBookButton.set_has_frame(false);
@@ -400,6 +408,14 @@ void BibliothecaWindow::onBatchClearSelectionClicked() {
   }
 }
 
+void BibliothecaWindow::onManageTagsClicked() {
+  auto dialog = Gtk::make_managed<TagManagerDialog>(*this, m_db);
+  dialog->signalTagsModified().connect([this]() {
+    m_bookList.loadAll();
+  });
+  dialog->present();
+}
+
 void BibliothecaWindow::showPlaceholderIfEmpty() {
   updateVisibleView();
 }

+ 5 - 0
src/BibliothecaWindow.hpp

@@ -19,6 +19,7 @@
 #include "BookList.hpp"
 #include "BookShelf.hpp"
 #include "BookDetails.hpp"
+#include "TagManagerDialog.hpp"
 
 // Forward-declare the hash helper you already have.
 std::string sha256_file(const std::string& path);
@@ -64,6 +65,9 @@ private:
   void onBatchSelectAllClicked();
   void onBatchClearSelectionClicked();
 
+  // Tag management
+  void onManageTagsClicked();
+
   // Model (not owned)
   DatabaseManager& m_db;
   BookList& m_bookList;
@@ -72,6 +76,7 @@ private:
   Gtk::HeaderBar m_headerBar;
   Gtk::Button    m_addBookButton;
   Gtk::Button    m_backButton;
+  Gtk::Button    m_tagsButton;
   Gtk::ToggleButton m_searchButton;
 
   Gtk::Box       m_mainBox {Gtk::Orientation::VERTICAL};

+ 127 - 0
src/DatabaseManager.cpp

@@ -210,3 +210,130 @@ std::vector<std::string> DatabaseManager::get_tags_for_book(const std::string& b
   return rows;
 }
 
+std::vector<DatabaseManager::TagInfo> DatabaseManager::get_all_tags() {
+  std::lock_guard lk(mtx_);
+  static const char* sql =
+    "SELECT t.name, COUNT(bt.book_id) as usage_count "
+    "FROM tags t "
+    "LEFT JOIN book_tags bt ON t.id = bt.tag_id "
+    "GROUP BY t.id "
+    "ORDER BY t.name COLLATE NOCASE;";
+  sqlite3_stmt* st = nullptr;
+  std::vector<TagInfo> rows;
+
+  if (sqlite3_prepare_v2(db_, sql, -1, &st, nullptr) != SQLITE_OK) return rows;
+
+  while (sqlite3_step(st) == SQLITE_ROW) {
+    TagInfo info;
+    info.name = reinterpret_cast<const char*>(sqlite3_column_text(st, 0));
+    info.usage_count = sqlite3_column_int(st, 1);
+    rows.push_back(std::move(info));
+  }
+  sqlite3_finalize(st);
+  return rows;
+}
+
+bool DatabaseManager::rename_tag(const std::string& old_name, const std::string& new_name) {
+  std::lock_guard lk(mtx_);
+
+  // Check if new_name already exists
+  static const char* check_sql = "SELECT id FROM tags WHERE name = ?;";
+  sqlite3_stmt* check_st = nullptr;
+  if (sqlite3_prepare_v2(db_, check_sql, -1, &check_st, nullptr) == SQLITE_OK) {
+    sqlite3_bind_text(check_st, 1, new_name.c_str(), -1, SQLITE_TRANSIENT);
+    if (sqlite3_step(check_st) == SQLITE_ROW) {
+      // new_name already exists - need to merge
+      int new_id = sqlite3_column_int(check_st, 0);
+      sqlite3_finalize(check_st);
+
+      // Get old tag id
+      int old_id = -1;
+      sqlite3_stmt* old_st = nullptr;
+      if (sqlite3_prepare_v2(db_, check_sql, -1, &old_st, nullptr) == SQLITE_OK) {
+        sqlite3_bind_text(old_st, 1, old_name.c_str(), -1, SQLITE_TRANSIENT);
+        if (sqlite3_step(old_st) == SQLITE_ROW) {
+          old_id = sqlite3_column_int(old_st, 0);
+        }
+        sqlite3_finalize(old_st);
+      }
+      if (old_id < 0) return false;
+
+      // Update book_tags to point to new_id (ignore conflicts for books that already have the new tag)
+      static const char* update_sql =
+        "UPDATE OR IGNORE book_tags SET tag_id = ? WHERE tag_id = ?;";
+      sqlite3_stmt* upd_st = nullptr;
+      if (sqlite3_prepare_v2(db_, update_sql, -1, &upd_st, nullptr) == SQLITE_OK) {
+        sqlite3_bind_int(upd_st, 1, new_id);
+        sqlite3_bind_int(upd_st, 2, old_id);
+        sqlite3_step(upd_st);
+        sqlite3_finalize(upd_st);
+      }
+
+      // Delete old tag
+      static const char* del_sql = "DELETE FROM tags WHERE id = ?;";
+      sqlite3_stmt* del_st = nullptr;
+      if (sqlite3_prepare_v2(db_, del_sql, -1, &del_st, nullptr) == SQLITE_OK) {
+        sqlite3_bind_int(del_st, 1, old_id);
+        sqlite3_step(del_st);
+        sqlite3_finalize(del_st);
+      }
+
+      // Clean up orphan book_tags entries
+      sqlite3_exec(db_, "DELETE FROM book_tags WHERE tag_id NOT IN (SELECT id FROM tags);", nullptr, nullptr, nullptr);
+
+      return true;
+    }
+    sqlite3_finalize(check_st);
+  }
+
+  // Simple rename
+  static const char* sql = "UPDATE tags SET name = ? WHERE name = ?;";
+  sqlite3_stmt* st = nullptr;
+  if (sqlite3_prepare_v2(db_, sql, -1, &st, nullptr) != SQLITE_OK) return false;
+
+  sqlite3_bind_text(st, 1, new_name.c_str(), -1, SQLITE_TRANSIENT);
+  sqlite3_bind_text(st, 2, old_name.c_str(), -1, SQLITE_TRANSIENT);
+
+  bool ok = (sqlite3_step(st) == SQLITE_DONE && sqlite3_changes(db_) > 0);
+  sqlite3_finalize(st);
+  return ok;
+}
+
+bool DatabaseManager::delete_tag(const std::string& tag_name) {
+  std::lock_guard lk(mtx_);
+
+  // First delete from book_tags
+  static const char* del_bt_sql =
+    "DELETE FROM book_tags WHERE tag_id = (SELECT id FROM tags WHERE name = ?);";
+  sqlite3_stmt* bt_st = nullptr;
+  if (sqlite3_prepare_v2(db_, del_bt_sql, -1, &bt_st, nullptr) == SQLITE_OK) {
+    sqlite3_bind_text(bt_st, 1, tag_name.c_str(), -1, SQLITE_TRANSIENT);
+    sqlite3_step(bt_st);
+    sqlite3_finalize(bt_st);
+  }
+
+  // Then delete the tag
+  static const char* sql = "DELETE 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);
+
+  bool ok = (sqlite3_step(st) == SQLITE_DONE && sqlite3_changes(db_) > 0);
+  sqlite3_finalize(st);
+  return ok;
+}
+
+int DatabaseManager::delete_unused_tags() {
+  std::lock_guard lk(mtx_);
+  static const char* sql =
+    "DELETE FROM tags WHERE id NOT IN (SELECT DISTINCT tag_id FROM book_tags);";
+
+  char* err = nullptr;
+  if (sqlite3_exec(db_, sql, nullptr, nullptr, &err) != SQLITE_OK) {
+    if (err) sqlite3_free(err);
+    return 0;
+  }
+  return sqlite3_changes(db_);
+}
+

+ 14 - 0
src/DatabaseManager.hpp

@@ -41,6 +41,20 @@ public:
   /// Get all tags for a book.
   std::vector<std::string> get_tags_for_book(const std::string& book_id);
 
+  // Tag management
+  /// Get all tags with their usage counts, sorted by name.
+  struct TagInfo {
+    std::string name;
+    int usage_count;
+  };
+  std::vector<TagInfo> get_all_tags();
+  /// Rename a tag across all books.
+  bool rename_tag(const std::string& old_name, const std::string& new_name);
+  /// Delete a tag (removes from all books).
+  bool delete_tag(const std::string& tag_name);
+  /// Delete all unused tags (tags with no associated books).
+  int delete_unused_tags();
+
 private:
   static int busy_handler(void* /*opaque*/, int prev_sleep_ms);
   void prepare_statements();

+ 169 - 0
src/TagManagerDialog.cpp

@@ -0,0 +1,169 @@
+#include "TagManagerDialog.hpp"
+#include <glibmm/markup.h>
+
+TagManagerDialog::TagManagerDialog(Gtk::Window& parent, DatabaseManager& db)
+  : Gtk::Dialog("Manage Tags", parent, true),
+    m_db(db) {
+
+  set_default_size(400, 450);
+  set_modal(true);
+
+  // Content area
+  m_contentBox.set_spacing(8);
+  m_contentBox.set_margin_top(12);
+  m_contentBox.set_margin_bottom(12);
+  m_contentBox.set_margin_start(12);
+  m_contentBox.set_margin_end(12);
+
+  // Empty state label
+  m_emptyLabel.set_markup("<span size='large'>No tags</span>\n<small>Tags will appear here when you add them to books.</small>");
+  m_emptyLabel.set_halign(Gtk::Align::CENTER);
+  m_emptyLabel.set_valign(Gtk::Align::CENTER);
+  m_emptyLabel.set_vexpand(true);
+
+  // Scrolled list
+  m_scrolled.set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
+  m_scrolled.set_vexpand(true);
+  m_tagList.set_selection_mode(Gtk::SelectionMode::NONE);
+  m_tagList.add_css_class("boxed-list");
+  m_scrolled.set_child(m_tagList);
+
+  // Cleanup button
+  m_cleanupButton.set_label("Clean Up Unused");
+  m_cleanupButton.set_tooltip_text("Remove tags that aren't assigned to any books");
+  m_cleanupButton.signal_clicked().connect(sigc::mem_fun(*this, &TagManagerDialog::on_cleanup_clicked));
+  m_buttonBox.set_halign(Gtk::Align::END);
+  m_buttonBox.append(m_cleanupButton);
+
+  m_contentBox.append(m_scrolled);
+  m_contentBox.append(m_buttonBox);
+
+  get_content_area()->append(m_contentBox);
+
+  // Setup rename popover
+  m_renameBox.set_spacing(6);
+  m_renameBox.set_margin_top(6);
+  m_renameBox.set_margin_bottom(6);
+  m_renameBox.set_margin_start(6);
+  m_renameBox.set_margin_end(6);
+
+  m_renameEntry.set_placeholder_text("New tag name...");
+  m_renameEntry.signal_activate().connect([this]() {
+    m_renameApplyButton.activate();
+  });
+
+  m_renameApplyButton.set_label("Rename");
+  m_renameApplyButton.add_css_class("suggested-action");
+  m_renameApplyButton.signal_clicked().connect([this]() {
+    const auto new_name = m_renameEntry.get_text();
+    if (new_name.empty() || m_renameOldName.empty()) return;
+
+    if (m_db.rename_tag(m_renameOldName, new_name.raw())) {
+      m_renamePopover.popdown();
+      refresh_tag_list();
+      m_signalTagsModified.emit();
+    }
+  });
+
+  m_renameBox.append(m_renameEntry);
+  m_renameBox.append(m_renameApplyButton);
+  m_renamePopover.set_child(m_renameBox);
+
+  // Close button
+  add_button("Close", Gtk::ResponseType::CLOSE);
+
+  signal_response().connect([this](int) {
+    hide();
+  });
+
+  refresh_tag_list();
+}
+
+void TagManagerDialog::refresh_tag_list() {
+  // Remove all children
+  while (auto* child = m_tagList.get_first_child()) {
+    m_tagList.remove(*child);
+  }
+
+  auto tags = m_db.get_all_tags();
+
+  if (tags.empty()) {
+    m_scrolled.set_visible(false);
+    m_buttonBox.set_visible(false);
+    m_contentBox.append(m_emptyLabel);
+    m_emptyLabel.set_visible(true);
+  } else {
+    m_emptyLabel.set_visible(false);
+    if (m_emptyLabel.get_parent())
+      m_contentBox.remove(m_emptyLabel);
+    m_scrolled.set_visible(true);
+    m_buttonBox.set_visible(true);
+
+    for (const auto& tag : tags) {
+      auto* row = create_tag_row(tag);
+      m_tagList.append(*row);
+    }
+  }
+}
+
+Gtk::Widget* TagManagerDialog::create_tag_row(const DatabaseManager::TagInfo& tag) {
+  auto* row = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 8);
+  row->set_margin_top(8);
+  row->set_margin_bottom(8);
+  row->set_margin_start(12);
+  row->set_margin_end(12);
+
+  auto* name_label = Gtk::make_managed<Gtk::Label>(tag.name);
+  name_label->set_hexpand(true);
+  name_label->set_xalign(0);
+
+  auto* count_label = Gtk::make_managed<Gtk::Label>(
+    Glib::ustring::compose("%1 book%2", tag.usage_count, tag.usage_count == 1 ? "" : "s"));
+  count_label->add_css_class("dim-label");
+
+  auto* rename_btn = Gtk::make_managed<Gtk::Button>();
+  rename_btn->set_icon_name("document-edit-symbolic");
+  rename_btn->add_css_class("flat");
+  rename_btn->set_tooltip_text("Rename tag");
+  rename_btn->signal_clicked().connect([this, tag, rename_btn]() {
+    on_rename_tag(tag.name);
+    m_renamePopover.set_parent(*rename_btn);
+    m_renamePopover.popup();
+  });
+
+  auto* delete_btn = Gtk::make_managed<Gtk::Button>();
+  delete_btn->set_icon_name("user-trash-symbolic");
+  delete_btn->add_css_class("flat");
+  delete_btn->set_tooltip_text("Delete tag");
+  delete_btn->signal_clicked().connect([this, tag]() {
+    on_delete_tag(tag.name);
+  });
+
+  row->append(*name_label);
+  row->append(*count_label);
+  row->append(*rename_btn);
+  row->append(*delete_btn);
+
+  return row;
+}
+
+void TagManagerDialog::on_rename_tag(const std::string& old_name) {
+  m_renameOldName = old_name;
+  m_renameEntry.set_text(old_name);
+  m_renameEntry.select_region(0, -1);
+}
+
+void TagManagerDialog::on_delete_tag(const std::string& tag_name) {
+  if (m_db.delete_tag(tag_name)) {
+    refresh_tag_list();
+    m_signalTagsModified.emit();
+  }
+}
+
+void TagManagerDialog::on_cleanup_clicked() {
+  int removed = m_db.delete_unused_tags();
+  if (removed > 0) {
+    refresh_tag_list();
+    m_signalTagsModified.emit();
+  }
+}

+ 45 - 0
src/TagManagerDialog.hpp

@@ -0,0 +1,45 @@
+#pragma once
+
+#include <gtkmm/dialog.h>
+#include <gtkmm/box.h>
+#include <gtkmm/listbox.h>
+#include <gtkmm/scrolledwindow.h>
+#include <gtkmm/button.h>
+#include <gtkmm/label.h>
+#include <gtkmm/entry.h>
+#include <gtkmm/popover.h>
+
+#include "DatabaseManager.hpp"
+
+class TagManagerDialog : public Gtk::Dialog {
+public:
+  TagManagerDialog(Gtk::Window& parent, DatabaseManager& db);
+
+  // Signal emitted when tags are modified (so caller can refresh book list)
+  sigc::signal<void()>& signalTagsModified() { return m_signalTagsModified; }
+
+private:
+  void refresh_tag_list();
+  Gtk::Widget* create_tag_row(const DatabaseManager::TagInfo& tag);
+  void on_rename_tag(const std::string& old_name);
+  void on_delete_tag(const std::string& tag_name);
+  void on_cleanup_clicked();
+
+  DatabaseManager& m_db;
+
+  Gtk::Box m_contentBox {Gtk::Orientation::VERTICAL};
+  Gtk::ScrolledWindow m_scrolled;
+  Gtk::ListBox m_tagList;
+  Gtk::Box m_buttonBox {Gtk::Orientation::HORIZONTAL};
+  Gtk::Button m_cleanupButton;
+  Gtk::Label m_emptyLabel;
+
+  // Rename popover
+  Gtk::Popover m_renamePopover;
+  Gtk::Box m_renameBox {Gtk::Orientation::VERTICAL};
+  Gtk::Entry m_renameEntry;
+  Gtk::Button m_renameApplyButton;
+  std::string m_renameOldName;
+
+  sigc::signal<void()> m_signalTagsModified;
+};