From 78f25aac5b3c079e3840e2ba467d7f5d272fdaa9 Mon Sep 17 00:00:00 2001 From: Sarah Wolff Date: Sun, 5 Apr 2026 20:16:36 -0400 Subject: [PATCH 01/14] feat(detected): persist started external books Track ABS and KoSync activity even when no pairing candidates\nexist so the dashboard can move to a true detected-book queue.\nKeep legacy suggestions intact while adding schema, repository,\nand backend detection support. --- .../s1t2u3v4w5x6_add_detected_books_table.py | 46 ++++++++ src/db/database_service.py | 3 + src/db/detected_repository.py | 106 ++++++++++++++++++ src/db/models.py | 62 ++++++++++ src/services/kosync_service.py | 17 +++ src/services/suggestion_service.py | 99 +++++++++++----- tests/test_database_service_integration.py | 46 ++++++++ tests/test_queue_suggestion.py | 5 +- tests/test_suggestion_logic.py | 2 + 9 files changed, 360 insertions(+), 26 deletions(-) create mode 100644 alembic/versions/s1t2u3v4w5x6_add_detected_books_table.py create mode 100644 src/db/detected_repository.py diff --git a/alembic/versions/s1t2u3v4w5x6_add_detected_books_table.py b/alembic/versions/s1t2u3v4w5x6_add_detected_books_table.py new file mode 100644 index 0000000..694ea46 --- /dev/null +++ b/alembic/versions/s1t2u3v4w5x6_add_detected_books_table.py @@ -0,0 +1,46 @@ +"""Add detected_books table. + +Revision ID: s1t2u3v4w5x6 +Revises: r5s6t7u8v9w0 +Create Date: 2026-04-05 +""" + +from typing import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "s1t2u3v4w5x6" +down_revision: str = "r5s6t7u8v9w0" +branch_labels: Sequence[str] | None = None +depends_on: str | None = None + + +def upgrade() -> None: + op.create_table( + "detected_books", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("source", sa.String(length=50), nullable=False, server_default="abs"), + sa.Column("source_id", sa.String(length=255), nullable=False), + sa.Column("title", sa.String(length=500), nullable=True), + sa.Column("author", sa.String(length=500), nullable=True), + sa.Column("cover_url", sa.String(length=500), nullable=True), + sa.Column("progress_percentage", sa.Float(), nullable=False, server_default="0"), + sa.Column("first_detected_at", sa.DateTime(), nullable=True), + sa.Column("last_seen_at", sa.DateTime(), nullable=True), + sa.Column("status", sa.String(length=20), nullable=True, server_default="detected"), + sa.Column("matches_json", sa.Text(), nullable=True), + sa.Column("device", sa.String(length=128), nullable=True), + sa.Column("ebook_filename", sa.String(length=500), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("source_id", "source", name="uq_detected_books_source_id_source"), + ) + op.create_index("ix_detected_books_source", "detected_books", ["source"], unique=False) + op.create_index("ix_detected_books_status", "detected_books", ["status"], unique=False) + + +def downgrade() -> None: + op.drop_index("ix_detected_books_status", table_name="detected_books") + op.drop_index("ix_detected_books_source", table_name="detected_books") + op.drop_table("detected_books") diff --git a/src/db/database_service.py b/src/db/database_service.py index 8c4ccb4..e08f9b1 100644 --- a/src/db/database_service.py +++ b/src/db/database_service.py @@ -9,6 +9,7 @@ from pathlib import Path from .book_repository import BookRepository +from .detected_repository import DetectedRepository from .bookfusion_repository import BookFusionRepository from .grimmory_repository import GrimmoryRepository from .hardcover_repository import HardcoverRepository @@ -66,6 +67,7 @@ def __init__(self, db_path: str): self._kosync = KoSyncRepository(self.db_manager) self._reading = ReadingRepository(self.db_manager) self._suggestions = SuggestionRepository(self.db_manager) + self._detected = DetectedRepository(self.db_manager) self._hardcover = HardcoverRepository(self.db_manager) self._storyteller = StorytellerRepository(self.db_manager) self._bookfusion = BookFusionRepository(self.db_manager) @@ -290,6 +292,7 @@ def get_session(self): "_kosync", "_reading", "_suggestions", + "_detected", "_hardcover", "_storyteller", "_bookfusion", diff --git a/src/db/detected_repository.py b/src/db/detected_repository.py new file mode 100644 index 0000000..9037013 --- /dev/null +++ b/src/db/detected_repository.py @@ -0,0 +1,106 @@ +"""Repository for detected external books.""" + +from datetime import UTC, datetime + +from .base_repository import BaseRepository +from .models import DetectedBook + + +class DetectedRepository(BaseRepository): + ACTIVE_STATUSES = ("detected",) + + def get_detected_book(self, source_id, source="abs"): + return self._get_one( + DetectedBook, + DetectedBook.source_id == source_id, + DetectedBook.source == source, + ) + + def get_active_detected_books(self, limit=None): + with self.get_session() as session: + query = ( + session.query(DetectedBook) + .filter(DetectedBook.status.in_(self.ACTIVE_STATUSES)) + .order_by(DetectedBook.last_seen_at.desc()) + ) + if limit is not None: + query = query.limit(limit) + items = query.all() + for item in items: + session.expunge(item) + return items + + def save_detected_book(self, detected_book): + """Upsert a detected book while preserving dismissed status.""" + filters = [ + DetectedBook.source_id == detected_book.source_id, + DetectedBook.source == detected_book.source, + ] + with self.get_session() as session: + existing = session.query(DetectedBook).filter(*filters).first() + now = datetime.now(UTC) + if existing: + if existing.status == "dismissed" and detected_book.status == "detected": + detected_book.status = "dismissed" + + if detected_book.title: + existing.title = detected_book.title + if detected_book.author: + existing.author = detected_book.author + if detected_book.cover_url: + existing.cover_url = detected_book.cover_url + if detected_book.matches_json is not None: + existing.matches_json = detected_book.matches_json + if detected_book.device: + existing.device = detected_book.device + if detected_book.ebook_filename: + existing.ebook_filename = detected_book.ebook_filename + + existing.progress_percentage = detected_book.progress_percentage + existing.status = detected_book.status + existing.last_seen_at = detected_book.last_seen_at or now + if existing.first_detected_at is None: + existing.first_detected_at = detected_book.first_detected_at or now + + session.flush() + session.refresh(existing) + session.expunge(existing) + return existing + + session.add(detected_book) + session.flush() + session.refresh(detected_book) + session.expunge(detected_book) + return detected_book + + def dismiss_detected_book(self, source_id, source="abs"): + with self.get_session() as session: + detected = ( + session.query(DetectedBook) + .filter( + DetectedBook.source_id == source_id, + DetectedBook.source == source, + ) + .first() + ) + if not detected: + return False + detected.status = "dismissed" + detected.last_seen_at = datetime.now(UTC) + return True + + def resolve_detected_book(self, source_id, source="abs"): + with self.get_session() as session: + detected = ( + session.query(DetectedBook) + .filter( + DetectedBook.source_id == source_id, + DetectedBook.source == source, + ) + .first() + ) + if not detected: + return False + detected.status = "resolved" + detected.last_seen_at = datetime.now(UTC) + return True diff --git a/src/db/models.py b/src/db/models.py index cc43d02..ca10bcf 100644 --- a/src/db/models.py +++ b/src/db/models.py @@ -521,6 +521,68 @@ def __repr__(self): return f"" +class DetectedBook(Base): + """Model for external books with real progress that are not yet in PageKeeper.""" + + __tablename__ = "detected_books" + __table_args__ = (UniqueConstraint("source_id", "source", name="uq_detected_books_source_id_source"),) + + id = Column(Integer, primary_key=True, autoincrement=True) + source = Column(String(50), default="abs", nullable=False) + source_id = Column(String(255), nullable=False) + title = Column(String(500), nullable=True) + author = Column(String(500), nullable=True) + cover_url = Column(String(500), nullable=True) + progress_percentage = Column(Float, default=0.0, nullable=False) + first_detected_at = Column(DateTime, default=utc_now) + last_seen_at = Column(DateTime, default=utc_now, onupdate=utc_now) + status = Column(String(20), default="detected") + matches_json = Column(Text, nullable=True) + device = Column(String(128), nullable=True) + ebook_filename = Column(String(500), nullable=True) + + def __init__( + self, + source_id: str, + title: str, + progress_percentage: float, + author: str = None, + cover_url: str = None, + status: str = "detected", + source: str = "abs", + matches_json: str = None, + device: str = None, + ebook_filename: str = None, + ): + self.source = source + self.source_id = source_id + self.title = title + self.author = author + self.cover_url = cover_url + self.progress_percentage = progress_percentage + self.status = status + self.matches_json = matches_json + self.device = device + self.ebook_filename = ebook_filename + self.first_detected_at = utc_now() + self.last_seen_at = utc_now() + + @property + def matches(self): + import json + + try: + return json.loads(self.matches_json) if self.matches_json else [] + except json.JSONDecodeError: + return [] + + def __repr__(self): + return ( + f"" + ) + + class Setting(Base): """ Setting model storing application configuration. diff --git a/src/services/kosync_service.py b/src/services/kosync_service.py index 10dafab..7514625 100644 --- a/src/services/kosync_service.py +++ b/src/services/kosync_service.py @@ -423,6 +423,7 @@ def run_put_auto_discovery(self, doc_hash): ) self._db.save_book(book, is_new=True) self._db.link_kosync_document(doc_hash, book.id, book.abs_id) + self._db.resolve_detected_book(doc_hash, source="kosync") self._db.resolve_suggestion(doc_hash) logger.info( f"Auto-created book '{match['title']}' from exact title match (abs_id={match['abs_id']})" @@ -519,6 +520,7 @@ def create_ebook_only_book(self, doc_hash, title, epub_filename=None): ) self._db.save_book(book, is_new=True) self._db.link_kosync_document(doc_hash, book.id, book.abs_id) + self._db.resolve_detected_book(doc_hash, source="kosync") self._db.resolve_suggestion(doc_hash) logger.info(f"Created ebook-only book: {book.id} '{title}'" + (f" -> {epub_filename}" if epub_filename else "")) @@ -621,6 +623,21 @@ def handle_put_progress(self, data, remote_addr, debounce_manager=None): self._db.save_kosync_document(kosync_doc) + if 0.01 <= percentage <= 0.70: + try: + suggestion_svc = self._container.suggestion_service() + suggestion_svc.queue_kosync_suggestion( + doc_hash, + filename=kosync_doc.filename, + device=device, + ) + detected = self._db.get_detected_book(doc_hash, source="kosync") + if detected: + detected.progress_percentage = float(percentage) + self._db.save_detected_book(detected) + except Exception as e: + logger.debug(f"KOSync detected-book update failed for {doc_hash[:8]}...: {e}") + # Update linked book if exists linked_book = None if kosync_doc.linked_book_id: diff --git a/src/services/suggestion_service.py b/src/services/suggestion_service.py index 0edec2e..66d2a85 100644 --- a/src/services/suggestion_service.py +++ b/src/services/suggestion_service.py @@ -9,7 +9,7 @@ from difflib import SequenceMatcher from pathlib import Path -from src.db.models import PendingSuggestion +from src.db.models import DetectedBook, PendingSuggestion from src.utils.string_utils import clean_book_title logger = logging.getLogger(__name__) @@ -143,6 +143,32 @@ def _score_to_confidence(self, score: float) -> str: return "medium" return "low" + def _upsert_detected_book( + self, + *, + source: str, + source_id: str, + title: str, + progress_percentage: float, + author: str = "", + cover_url: str | None = None, + matches: list[dict] | None = None, + device: str | None = None, + ebook_filename: str | None = None, + ): + detected = DetectedBook( + source=source, + source_id=source_id, + title=title or source_id, + author=author or "", + cover_url=cover_url, + progress_percentage=max(0.0, min(progress_percentage, 1.0)), + matches_json=json.dumps(matches or []), + device=device, + ebook_filename=ebook_filename, + ) + return self.database_service.save_detected_book(detected) + def _get_bookfusion_context(self) -> dict: try: bf_books = list(self.database_service.get_bookfusion_books() or []) @@ -414,11 +440,9 @@ def queue_suggestion(self, abs_id: str) -> None: self._create_suggestion(abs_id, None) def queue_kosync_suggestion(self, doc_hash: str, filename: str | None = None, device: str | None = None) -> None: - """Create a reverse suggestion for a KoSync document (ebook -> ABS audiobook).""" + """Create or refresh a detected entry for a KoSync document.""" if os.environ.get("SUGGESTIONS_ENABLED", "true").lower() != "true": return - if self.database_service.suggestion_exists(doc_hash, source="kosync"): - return title = "" if filename: @@ -431,7 +455,6 @@ def queue_kosync_suggestion(self, doc_hash: str, filename: str | None = None, de logger.debug(f"KoSync suggestion: no title derivable for {doc_hash[:8]}..., skipping") return - # Try ABS audiobook matching first (if ABS is configured) matches = [] if self.abs_client: try: @@ -461,27 +484,33 @@ def queue_kosync_suggestion(self, doc_hash: str, filename: str | None = None, de all_ebook = ebook_candidates.get("storyteller", []) + ebook_candidates.get("grimmory", []) matches = self._rank_candidates_for_book(title, "", all_ebook) - if not matches: - logger.debug(f"KoSync suggestion: no match for '{title}' (hash {doc_hash[:8]}...)") - return + cover = None + author = "" + if matches: + best = matches[0] + author = best.get("author") or best.get("authorName") or "" + if best.get("abs_id"): + cover = f"/api/cover-proxy/{best['abs_id']}" + else: + cover = best.get("cover_url") or self._cover_url_for( + best.get("source_family", ""), best.get("abs_id", ""), best + ) - best = matches[0] - if best.get("abs_id"): - cover = f"/api/cover-proxy/{best['abs_id']}" - else: - cover = best.get("cover_url") or self._cover_url_for( - best.get("source_family", ""), best.get("abs_id", ""), best - ) - suggestion = PendingSuggestion( + self._upsert_detected_book( source="kosync", source_id=doc_hash, title=title, - author=best.get("author") or best.get("authorName") or "", + author=author, cover_url=cover, - matches_json=json.dumps(matches), + progress_percentage=0.0, + matches=matches, + device=device, + ebook_filename=filename, + ) + logger.info( + f"KoSync detected: '{title}' (hash {doc_hash[:8]}...)" + + (f" with {len(matches)} match(es)" if matches else "") ) - self.database_service.save_pending_suggestion(suggestion) - logger.info(f"KoSync suggestion: '{title}' -> '{best.get('title')}' (hash {doc_hash[:8]}...)") def check_for_suggestions(self, abs_progress_map, active_books): """Check for unmapped books with progress and create suggestions.""" @@ -544,7 +573,10 @@ def check_for_suggestions(self, abs_progress_map, active_books): def _suggestion_already_recorded(self, abs_id: str) -> bool: """Return True when a suggestion should not be recreated for this ABS item.""" - return bool(self.database_service.suggestion_exists(abs_id)) + if self.database_service.suggestion_exists(abs_id): + return True + detected = self.database_service.get_detected_book(abs_id, source="abs") + return bool(detected and detected.status == "dismissed") def _get_storyteller_books_with_progress(self, mapped_uuids: set | None = None) -> list[dict]: """Fetch Storyteller books with 1-70% progress, excluding already-mapped UUIDs.""" @@ -1065,6 +1097,13 @@ def _create_suggestion(self, abs_id, progress_data): cover = self._cover_url_for("abs", abs_id) logger.debug(f"Checking suggestions for '{title}' (Author: {author})") + progress_percentage = 0.0 + if progress_data: + duration = progress_data.get("duration", 0) or 0 + current_time = progress_data.get("currentTime", 0) or 0 + if duration > 0: + progress_percentage = max(0.0, min(current_time / duration, 1.0)) + bookfusion_context = self._get_bookfusion_context() matches = self._rank_candidates_for_book( title, @@ -1075,15 +1114,25 @@ def _create_suggestion(self, abs_id, progress_data): matches.extend(self._search_live_candidates(title, author, bookfusion_context)) matches = self._dedupe_matches(matches) - if not matches: - logger.debug(f"No matches found for '{title}', skipping suggestion creation") - return + self._upsert_detected_book( + source="abs", + source_id=abs_id, + title=title, + author=author, + cover_url=cover, + progress_percentage=progress_percentage, + matches=matches, + ) suggestion = PendingSuggestion( source_id=abs_id, title=title, author=author, cover_url=cover, matches_json=json.dumps(matches) ) self.database_service.save_pending_suggestion(suggestion) - logger.info(f"Created suggestion for '{title}' with {len(matches)} matches") + logger.info( + f"Created suggestion for '{title}' with {len(matches)} matches" + if matches + else f"Created detected entry for '{title}' with no matches yet" + ) except Exception as e: logger.error(f"Failed to create suggestion for '{abs_id}': {e}") diff --git a/tests/test_database_service_integration.py b/tests/test_database_service_integration.py index c109137..b7f95f0 100644 --- a/tests/test_database_service_integration.py +++ b/tests/test_database_service_integration.py @@ -79,6 +79,52 @@ def test_create_book(self): self.assertEqual(saved_book.title, "Test Book Creation") self.assertEqual(saved_book.status, "active") + def test_save_detected_book_creates_and_updates(self): + """Detected books upsert by source and source_id.""" + from src.db.models import DetectedBook + + first = DetectedBook( + source="abs", + source_id="detected-1", + title="Detected Title", + author="Author One", + progress_percentage=0.25, + cover_url="/cover/1", + ) + saved = self.db_service.save_detected_book(first) + self.assertEqual(saved.title, "Detected Title") + self.assertAlmostEqual(saved.progress_percentage, 0.25) + + second = DetectedBook( + source="abs", + source_id="detected-1", + title="Detected Title Updated", + author="Author Two", + progress_percentage=0.55, + ) + updated = self.db_service.save_detected_book(second) + + self.assertEqual(updated.id, saved.id) + self.assertEqual(updated.title, "Detected Title Updated") + self.assertEqual(updated.author, "Author Two") + self.assertAlmostEqual(updated.progress_percentage, 0.55) + + def test_resolve_detected_book_scoped_by_source(self): + """Resolving a detected book only affects the matching source row.""" + from src.db.models import DetectedBook + + abs_detected = DetectedBook(source="abs", source_id="shared-id", title="ABS", progress_percentage=0.2) + kosync_detected = DetectedBook(source="kosync", source_id="shared-id", title="KOSync", progress_percentage=0.3) + self.db_service.save_detected_book(abs_detected) + self.db_service.save_detected_book(kosync_detected) + + self.assertTrue(self.db_service.resolve_detected_book("shared-id", source="abs")) + + resolved = self.db_service.get_detected_book("shared-id", source="abs") + still_active = self.db_service.get_detected_book("shared-id", source="kosync") + self.assertEqual(resolved.status, "resolved") + self.assertEqual(still_active.status, "detected") + # Verify book can be retrieved retrieved_book = self.db_service.get_book_by_abs_id(test_abs_id) self.assertIsNotNone(retrieved_book) diff --git a/tests/test_queue_suggestion.py b/tests/test_queue_suggestion.py index a87e397..c4ec5af 100644 --- a/tests/test_queue_suggestion.py +++ b/tests/test_queue_suggestion.py @@ -53,16 +53,18 @@ def test_skips_existing_suggestion(self): def test_creates_suggestion_for_new_book(self): self.mock_db.suggestion_exists.return_value = False + self.mock_db.get_detected_book.return_value = None self.mock_abs.get_item_details.return_value = { "media": {"metadata": {"title": "Test Book", "authorName": "Author"}} } - # No matches found, so suggestion creation won't save self.manager.queue_suggestion("book-789") self.mock_abs.get_item_details.assert_called_once_with("book-789") + self.mock_db.save_detected_book.assert_called_once() def test_thread_safety_prevents_duplicate(self): """Second concurrent call for same ID should be skipped.""" self.mock_db.suggestion_exists.return_value = False + self.mock_db.get_detected_book.return_value = None # Simulate first call in-flight self.manager.suggestion_service._suggestion_in_flight.add("book-dup") @@ -73,6 +75,7 @@ def test_thread_safety_prevents_duplicate(self): def test_cleans_up_in_flight_on_error(self): self.mock_db.suggestion_exists.return_value = False + self.mock_db.get_detected_book.return_value = None self.mock_abs.get_item_details.side_effect = Exception("boom") self.manager.queue_suggestion("book-err") diff --git a/tests/test_suggestion_logic.py b/tests/test_suggestion_logic.py index d025b3d..5a5ddbe 100644 --- a/tests/test_suggestion_logic.py +++ b/tests/test_suggestion_logic.py @@ -73,6 +73,7 @@ def test_suggestion_created_when_progress_low(self): self.mock_db.get_all_books.return_value = [] self.mock_db.get_pending_suggestion.return_value = None self.mock_db.suggestion_exists.return_value = False + self.mock_db.get_detected_book.return_value = None # Prepare successful suggestion creation mocks self.mock_abs.get_item_details.return_value = { @@ -86,6 +87,7 @@ def test_suggestion_created_when_progress_low(self): # Assert self.mock_db.save_pending_suggestion.assert_called_once() + self.mock_db.save_detected_book.assert_called_once() def test_suggestion_ignored_when_hidden(self): """Test that suggestions are NOT created if they were previously hidden.""" From 2cc336693e9e3d0aa5d8b2eb513cd874af8af430 Mon Sep 17 00:00:00 2001 From: Sarah Wolff Date: Sun, 5 Apr 2026 20:53:46 -0400 Subject: [PATCH 02/14] feat(dashboard): add detected section with actions --- src/blueprints/api.py | 57 +++++++++++++++++++ src/blueprints/dashboard.py | 27 ++++++++- src/blueprints/matching_bp.py | 13 +++++ static/css/kosync.css | 101 ++++++++++++++++++++++++++++++++++ static/js/dashboard.js | 24 +++++++- templates/index.html | 40 ++++++++++++++ 6 files changed, 260 insertions(+), 2 deletions(-) diff --git a/src/blueprints/api.py b/src/blueprints/api.py index 5fedb9e..d4f3785 100644 --- a/src/blueprints/api.py +++ b/src/blueprints/api.py @@ -25,6 +25,63 @@ _VALID_SUGGESTION_SOURCES = ("abs", "kosync", "storyteller", "grimmory") +# ---------------- Detected Books ---------------- + + +@api_bp.route("/api/detected", methods=["GET"]) +def get_detected_books(): + """Return active detected books.""" + database_service = get_database_service() + try: + detected = database_service.get_active_detected_books(limit=50) + results = [] + for d in detected: + results.append( + { + "id": d.id, + "source": d.source, + "source_id": d.source_id, + "title": d.title, + "author": d.author, + "cover_url": d.cover_url, + "progress_percentage": d.progress_percentage, + "first_detected_at": d.first_detected_at.isoformat() if d.first_detected_at else None, + "last_seen_at": d.last_seen_at.isoformat() if d.last_seen_at else None, + "device": d.device, + "ebook_filename": d.ebook_filename, + "status": d.status, + } + ) + return jsonify({"success": True, "detected": results}) + except Exception as e: + logger.error(f"Failed to get detected books: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + + +@api_bp.route("/api/detected//dismiss", methods=["POST"]) +def dismiss_detected_book(source_id): + """Dismiss a detected book.""" + database_service = get_database_service() + source = request.args.get("source", "abs") + if source not in _VALID_SUGGESTION_SOURCES: + return jsonify({"success": False, "error": "Invalid source"}), 400 + if database_service.dismiss_detected_book(source_id, source=source): + return jsonify({"success": True}) + return jsonify({"success": False, "error": "Not found"}), 404 + + +@api_bp.route("/api/detected//resolve", methods=["POST"]) +def resolve_detected_book(source_id): + """Mark a detected book as resolved (added to library).""" + database_service = get_database_service() + source = request.args.get("source", "abs") + if source not in _VALID_SUGGESTION_SOURCES: + return jsonify({"success": False, "error": "Invalid source"}), 400 + if database_service.resolve_detected_book(source_id, source=source): + return jsonify({"success": True}) + return jsonify({"success": False, "error": "Not found"}), 404 + + # ---------------- Status ---------------- diff --git a/src/blueprints/dashboard.py b/src/blueprints/dashboard.py index 1cf82ae..9c8ae24 100644 --- a/src/blueprints/dashboard.py +++ b/src/blueprints/dashboard.py @@ -391,7 +391,31 @@ def _run_date_sync(): except Exception: pass - # Pending suggestions — for dashboard banner + # Active detected books — for dashboard detected section + detected_books = [] + try: + active_detected = database_service.get_active_detected_books(limit=10) + for d in active_detected: + detected_books.append( + { + "id": d.id, + "source": d.source, + "source_id": d.source_id, + "title": d.title, + "author": d.author, + "cover_url": d.cover_url, + "progress_percentage": d.progress_percentage, + "first_detected_at": d.first_detected_at.isoformat() if d.first_detected_at else None, + "last_seen_at": d.last_seen_at.isoformat() if d.last_seen_at else None, + "matches": d.matches, + "device": d.device, + "ebook_filename": d.ebook_filename, + } + ) + except Exception: + pass + + # Pending suggestions — for dashboard banner (legacy, will be replaced) top_suggestions = [] suggestions_enabled = os.environ.get("SUGGESTIONS_ENABLED", "false").lower() in ("true", "1", "yes", "on") if suggestions_enabled: @@ -416,4 +440,5 @@ def _run_date_sync(): kosync_unlinked_count=kosync_unlinked_count, unlinked_reading=unlinked_reading, top_suggestions=top_suggestions, + detected_books=detected_books, ) diff --git a/src/blueprints/matching_bp.py b/src/blueprints/matching_bp.py index fdce3f5..a34fe50 100644 --- a/src/blueprints/matching_bp.py +++ b/src/blueprints/matching_bp.py @@ -226,10 +226,15 @@ def _create_book_mapping( # Resolve suggestions database_service.resolve_suggestion(abs_id) database_service.resolve_suggestion(kosync_doc_id) + # Also resolve detected entries + database_service.resolve_detected_book(abs_id, source="abs") + if kosync_doc_id: + database_service.resolve_detected_book(kosync_doc_id, source="kosync") try: device_doc = database_service.get_kosync_doc_by_filename(ebook_filename) if device_doc and device_doc.document_hash != kosync_doc_id: database_service.resolve_suggestion(device_doc.document_hash) + database_service.resolve_detected_book(device_doc.document_hash, source="kosync") except Exception as e: logger.warning(f"Failed to check/resolve device hash: {e}") @@ -339,6 +344,7 @@ def match(): abs_service.add_to_collection(abs_id, current_app.config["ABS_COLLECTION_NAME"]) attempt_hardcover_automatch(container, book) database_service.resolve_suggestion(abs_id) + database_service.resolve_detected_book(abs_id, source="abs") return redirect(url_for("dashboard.index")) # --- Ebook-only import (no audiobook required) --- @@ -382,8 +388,10 @@ def match(): # Resolve any suggestions involving these source IDs if kosync_doc_id: database_service.resolve_suggestion(kosync_doc_id, source="kosync") + database_service.resolve_detected_book(kosync_doc_id, source="kosync") if storyteller_uuid: database_service.resolve_suggestion(storyteller_uuid, source="storyteller") + database_service.resolve_detected_book(storyteller_uuid, source="storyteller") if ebook_filename: database_service.resolve_suggestion(ebook_filename, source="grimmory") return redirect(url_for("dashboard.index")) @@ -415,6 +423,7 @@ def match(): except Exception as e: logger.warning(f"Grimmory add_to_shelf failed for '{sanitize_log_data(ebook_filename)}': {e}") database_service.resolve_suggestion(kosync_doc_id) + database_service.resolve_detected_book(kosync_doc_id, source="kosync") return redirect(url_for("dashboard.index")) # --- Attach audiobook to ebook-only book --- @@ -463,8 +472,10 @@ def match(): raise attempt_hardcover_automatch(container, new_book) database_service.resolve_suggestion(abs_id) + database_service.resolve_detected_book(abs_id, source="abs") if new_book.kosync_doc_id: database_service.resolve_suggestion(new_book.kosync_doc_id) + database_service.resolve_detected_book(new_book.kosync_doc_id, source="kosync") return redirect(url_for("dashboard.index")) # --- Standard flow (requires audiobook) --- @@ -686,6 +697,7 @@ def batch_match(): abs_service.add_to_collection(item["abs_id"], current_app.config["ABS_COLLECTION_NAME"]) attempt_hardcover_automatch(container, book) database_service.resolve_suggestion(item["abs_id"]) + database_service.resolve_detected_book(item["abs_id"], source="abs") continue # Handle ebook-only queue items @@ -723,6 +735,7 @@ def batch_match(): ensure_kosync_document(book, database_service) if kosync_doc_id: database_service.resolve_suggestion(kosync_doc_id) + database_service.resolve_detected_book(kosync_doc_id, source="kosync") continue book, error = _create_book_mapping( diff --git a/static/css/kosync.css b/static/css/kosync.css index 082e41e..fe63bfc 100644 --- a/static/css/kosync.css +++ b/static/css/kosync.css @@ -317,6 +317,107 @@ flex-shrink: 0; } +/* ── Dashboard: Detected ── */ +.detected-help { + font-size: 13px; + color: var(--color-text-muted); + margin: 0 0 12px; +} + +.detected-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 12px; +} + +.detected-card { + display: flex; + gap: 12px; + padding: 14px; + background: var(--color-surface-2); + border-radius: 8px; + border-left: 3px solid var(--color-primary); +} + +.detected-cover { + width: 60px; + height: 90px; + object-fit: cover; + border-radius: 4px; + flex-shrink: 0; +} + +.detected-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.detected-title { + font-weight: 600; + font-size: 14px; + color: var(--color-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.detected-author { + font-size: 12px; + color: var(--color-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.detected-progress { + font-size: 12px; + font-weight: 600; + color: var(--color-primary); +} + +.detected-meta { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + margin-top: 2px; +} + +.detected-device { + font-size: 11px; + color: var(--color-text-muted); +} + +.detected-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; +} + +.chip--source-abs { + background: #3b82f6; + color: #fff; +} + +.chip--source-kosync { + background: #8b5cf6; + color: #fff; +} + +.chip--source-storyteller { + background: #10b981; + color: #fff; +} + +.chip--source-grimmory { + background: #f59e0b; + color: #fff; +} + /* ── Mobile responsive ── */ @media (max-width: 600px) { .kosync-card { diff --git a/static/js/dashboard.js b/static/js/dashboard.js index 1d8f00f..077ce5f 100644 --- a/static/js/dashboard.js +++ b/static/js/dashboard.js @@ -853,7 +853,29 @@ document.addEventListener('keydown', function(e) { } }); -/* Suggestion banner dismiss */ +/* Detected card dismiss */ +function dismissDetected(sourceId, source, btn) { + if (btn) btn.disabled = true; + fetch('/api/detected/' + encodeURIComponent(sourceId) + '/dismiss?source=' + encodeURIComponent(source || 'abs'), { method: 'POST' }) + .then(function(r) { + if (!r.ok) throw new Error('Failed to dismiss'); + var card = document.querySelector('.detected-card[data-source-id="' + sourceId + '"]'); + if (card) { + card.classList.add('removing'); + setTimeout(function() { + card.remove(); + var section = document.getElementById('detected-section'); + var remaining = section ? section.querySelectorAll('.detected-card') : []; + if (remaining.length === 0 && section) { + section.style.display = 'none'; + } + }, 200); + } + }) + .catch(function() { + if (btn) btn.disabled = false; + }); +} function dismissSuggestion(sourceId, source, btn) { if (btn) btn.disabled = true; fetch('/api/suggestions/' + encodeURIComponent(sourceId) + '/hide?source=' + encodeURIComponent(source || 'abs'), { method: 'POST' }) diff --git a/templates/index.html b/templates/index.html index d266ff5..8b13319 100644 --- a/templates/index.html +++ b/templates/index.html @@ -101,6 +101,46 @@

Pending Identification

{% endif %} + {% if detected_books %} +
+
+

Detected

+
+

Books PageKeeper has detected you started. Add them to your library.

+
+ {% for d in detected_books %} +
+ {% if d.cover_url %} + + {% endif %} +
+
{{ d.title }}
+ {% if d.author %}
{{ d.author }}
{% endif %} + {% if d.progress_percentage > 0 %} +
{{ '%.0f'|format(d.progress_percentage * 100) }}%
+ {% endif %} +
+ {{ d.source }} + {% if d.device %}{{ d.device }}{% endif %} +
+
+ {% if d.source == 'abs' %} + Add to Library + {% elif d.source == 'kosync' %} + Match + Add as Ebook + {% else %} + Add to Library + {% endif %} + +
+
+
+ {% endfor %} +
+
+ {% endif %} + {% if top_suggestions %}
From f1d2a6cc264a1cf06e97752ed89343bce723bdb1 Mon Sep 17 00:00:00 2001 From: Sarah Wolff Date: Sun, 5 Apr 2026 20:55:47 -0400 Subject: [PATCH 03/14] refactor: remove suggestions tab and nav --- src/blueprints/matching_bp.py | 28 ------------------------- templates/index.html | 37 ---------------------------------- templates/partials/navbar.html | 3 --- 3 files changed, 68 deletions(-) diff --git a/src/blueprints/matching_bp.py b/src/blueprints/matching_bp.py index a34fe50..2f5478d 100644 --- a/src/blueprints/matching_bp.py +++ b/src/blueprints/matching_bp.py @@ -282,34 +282,6 @@ def _build_batch_queue_view(queue): } -@matching_bp.route("/suggestions") -def suggestions(): - """Dedicated page for browsing and acting on pairing suggestions.""" - container = get_container() - database_service = get_database_service() - raw_suggestions = database_service.get_all_actionable_suggestions() - suggestions_list = [serialize_suggestion(s) for s in raw_suggestions if s.matches] - visible_count = sum(1 for s in suggestions_list if not s.get("hidden")) - hidden_count = sum(1 for s in suggestions_list if s.get("hidden")) - suggestions_enabled = current_app.config.get("SUGGESTIONS_ENABLED", False) - bookfusion_enabled = container.bookfusion_client().is_configured() - bookfusion_catalog_count = len(database_service.get_bookfusion_books()) if bookfusion_enabled else 0 - initial_search = request.args.get("search", "").strip() - selected_source_id = request.args.get("source_id", "").strip() - return render_template( - "suggestions.html", - suggestions=suggestions_list, - visible_count=visible_count, - hidden_count=hidden_count, - suggestions_enabled=suggestions_enabled, - bookfusion_enabled=bookfusion_enabled, - bookfusion_catalog_count=bookfusion_catalog_count, - suggestions_data=suggestions_list, - initial_search=initial_search, - selected_source_id=selected_source_id, - ) - - @matching_bp.route("/match", methods=["GET", "POST"]) def match(): container = get_container() diff --git a/templates/index.html b/templates/index.html index 8b13319..c81530a 100644 --- a/templates/index.html +++ b/templates/index.html @@ -141,43 +141,6 @@

Detected

{% endif %} - {% if top_suggestions %} -
- -

High-confidence matches found for books you're listening to.

-
- {% for sg in top_suggestions %} -
- -
-
{{ sg.title }}
- {% if sg.author %}
{{ sg.author }}
{% endif %} - {% if sg.top_match %} -
- {{ sg.top_match.title }} - {{ sg.top_match.confidence }} -
- {% endif %} -
- {% if sg.source == 'abs' %} - Map Now - {% elif sg.top_match and sg.top_match.abs_id %} - Map Now - {% else %} - Review - {% endif %} - -
-
-
- {% endfor %} -
-
- {% endif %} -

Currently Reading

diff --git a/templates/partials/navbar.html b/templates/partials/navbar.html index b6dc7dd..a2bed79 100644 --- a/templates/partials/navbar.html +++ b/templates/partials/navbar.html @@ -82,9 +82,6 @@

PageKeeper

{% endif %} Dashboard Reading Log - {% if get_bool('SUGGESTIONS_ENABLED') %} - Suggestions{% if suggestion_count %} {{ suggestion_count }}{% endif %} - {% endif %} Batch Logs Settings From 94a90a6a9c75b53ba37d530f883259c8154357d7 Mon Sep 17 00:00:00 2001 From: Sarah Wolff Date: Sun, 5 Apr 2026 20:59:50 -0400 Subject: [PATCH 04/14] feat(detected): disable rescan by default and migrate cache cleanup to DetectedBook - Set SUGGESTIONS_ENABLED default to 'false' to disable legacy rescan - Add get_all_ebook_filenames() to DetectedRepository - Update sync_manager cache cleanup to use DetectedBook instead of PendingSuggestion - PendingSuggestion table remains for upgrade compatibility --- src/db/detected_repository.py | 21 +++++++++++++++++++++ src/services/suggestion_service.py | 2 +- src/sync_manager.py | 9 ++------- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/src/db/detected_repository.py b/src/db/detected_repository.py index 9037013..4efc9ca 100644 --- a/src/db/detected_repository.py +++ b/src/db/detected_repository.py @@ -104,3 +104,24 @@ def resolve_detected_book(self, source_id, source="abs"): detected.status = "resolved" detected.last_seen_at = datetime.now(UTC) return True + + def get_all_ebook_filenames(self): + """Get all ebook filenames from detected books with matches.""" + with self.get_session() as session: + results = ( + session.query(DetectedBook) + .filter( + DetectedBook.status.in_(self.ACTIVE_STATUSES), + DetectedBook.matches_json.isnot(None), + ) + .all() + ) + filenames = set() + for detected in results: + matches = detected.matches or [] + for match in matches: + if match.get("filename"): + filenames.add(match["filename"]) + for item in results: + session.expunge(item) + return filenames diff --git a/src/services/suggestion_service.py b/src/services/suggestion_service.py index 66d2a85..8542958 100644 --- a/src/services/suggestion_service.py +++ b/src/services/suggestion_service.py @@ -924,7 +924,7 @@ def _run_rescan_job(self) -> None: def rescan_library_suggestions(self) -> dict: """Rebuild suggestions from cached library metadata without live BookFusion calls.""" - if os.environ.get("SUGGESTIONS_ENABLED", "true").lower() != "true": + if os.environ.get("SUGGESTIONS_ENABLED", "false").lower() != "true": return {"created": 0, "updated": 0, "deleted": 0, "total": 0, "bookfusion_catalog": False} mapped_ids = {b.abs_id for b in self.database_service.get_all_books()} diff --git a/src/sync_manager.py b/src/sync_manager.py index 7cb72cf..2c27c66 100644 --- a/src/sync_manager.py +++ b/src/sync_manager.py @@ -238,13 +238,8 @@ def cleanup_cache(self): if book.ebook_filename: valid_filenames.add(book.ebook_filename) - # From Pending Suggestions (covers auto-discovery matches) - suggestions = self.database_service.get_all_actionable_suggestions() - for suggestion in suggestions: - # matches property automatically parses the JSON - for match in suggestion.matches: - if match.get("filename"): - valid_filenames.add(match["filename"]) + # From Detected Books (covers auto-discovery matches) + valid_filenames.update(self.database_service.get_all_ebook_filenames()) # 2. Iterate cache and delete orphans deleted_count = 0 From e8e589e24c36ad60bda012ba7315529ed349d813 Mon Sep 17 00:00:00 2001 From: Sarah Wolff Date: Sun, 5 Apr 2026 21:03:29 -0400 Subject: [PATCH 05/14] refactor: remove legacy suggestion API endpoints and frontend code - Remove /api/suggestions/* endpoints from api.py (7 routes removed) - Remove dismissSuggestion from dashboard.js - Remove clearStaleSuggestions from settings.js - Remove Clear Stale Suggestions button from settings.html - Keep serialize_suggestion import for matching_bp.py and dashboard.py (still used) - Keep suggestions.html, suggestions.js, suggestions.css as orphaned files (can delete later) Upgrade safe: PendingSuggestion table remains in database. --- src/blueprints/api.py | 152 +--------------------------------------- static/js/dashboard.js | 36 ---------- static/js/settings.js | 27 ------- templates/settings.html | 20 ------ 4 files changed, 2 insertions(+), 233 deletions(-) diff --git a/src/blueprints/api.py b/src/blueprints/api.py index d4f3785..e64fab5 100644 --- a/src/blueprints/api.py +++ b/src/blueprints/api.py @@ -1,4 +1,4 @@ -"""API blueprint — /api/status, /api/suggestions/*, /api/storyteller/*, /api/grimmory/*. +"""API blueprint — /api/status, /api/detected/*, /api/storyteller/*, /api/grimmory/*. ABS-specific routes (/api/abs/*, /api/cover-proxy/*) are in abs_bp.py. """ @@ -168,155 +168,7 @@ def api_processing_status(): return jsonify(result) -# ---------------- Suggestions ---------------- - - -@api_bp.route("/api/suggestions", methods=["GET"]) -def get_suggestions(): - database_service = get_database_service() - suggestions = database_service.get_all_actionable_suggestions() - return jsonify([serialize_suggestion(s) for s in suggestions if s.matches]) - - -@api_bp.route("/api/suggestions/rescan", methods=["POST"]) -def rescan_suggestions(): - container = get_container() - data = request.get_json(silent=True) or {} - force = bool(data.get("force")) - stats = container.suggestion_service().request_rescan_library_suggestions(force=force) - return jsonify({"success": True, **stats}) - - -@api_bp.route("/api/suggestions/rescan-status", methods=["GET"]) -def rescan_suggestions_status(): - container = get_container() - status = container.suggestion_service().get_rescan_status() - return jsonify({"success": True, **status}) - - -@api_bp.route("/api/suggestions//hide", methods=["POST"]) -def hide_suggestion(source_id): - database_service = get_database_service() - source = request.args.get("source", "abs") - if source not in _VALID_SUGGESTION_SOURCES: - return jsonify({"success": False, "error": "Invalid source"}), 400 - if database_service.hide_suggestion(source_id, source=source): - return jsonify({"success": True}) - return jsonify({"success": False, "error": "Not found"}), 404 - - -@api_bp.route("/api/suggestions//unhide", methods=["POST"]) -def unhide_suggestion(source_id): - database_service = get_database_service() - source = request.args.get("source", "abs") - if source not in _VALID_SUGGESTION_SOURCES: - return jsonify({"success": False, "error": "Invalid source"}), 400 - if database_service.unhide_suggestion(source_id, source=source): - return jsonify({"success": True}) - return jsonify({"success": False, "error": "Not found"}), 404 - - -@api_bp.route("/api/suggestions//ignore", methods=["POST"]) -def ignore_suggestion(source_id): - database_service = get_database_service() - source = request.args.get("source", "abs") - if source not in _VALID_SUGGESTION_SOURCES: - return jsonify({"success": False, "error": "Invalid source"}), 400 - if database_service.ignore_suggestion(source_id, source=source): - return jsonify({"success": True}) - return jsonify({"success": False, "error": "Not found"}), 404 - - -@api_bp.route("/api/suggestions/clear_stale", methods=["POST"]) -def clear_stale_suggestions(): - database_service = get_database_service() - count = database_service.clear_stale_suggestions() - logger.info(f"Cleared {count} stale suggestions from database") - return jsonify({"success": True, "count": count}) - - -@api_bp.route("/api/suggestions//link-bookfusion", methods=["POST"]) -def link_suggestion_bookfusion(source_id): - database_service = get_database_service() - container = get_container() - data = request.get_json(silent=True) or {} - source = data.get("source", "abs") - if source not in _VALID_SUGGESTION_SOURCES: - return jsonify({"success": False, "error": "Invalid source"}), 400 - - suggestion = database_service.get_pending_suggestion(source_id, source=source) - if not suggestion: - return jsonify({"success": False, "error": "Suggestion not found"}), 404 - - match_index = data.get("match_index") - matches = suggestion.matches or [] - if match_index is None or not isinstance(match_index, int) or match_index < 0 or match_index >= len(matches): - return jsonify({"success": False, "error": "Valid match_index required"}), 400 - - match = matches[match_index] - bookfusion_ids = match.get("bookfusion_ids") or [] - if match.get("source_family") != "bookfusion" or not bookfusion_ids: - return jsonify({"success": False, "error": "Selected match is not a BookFusion candidate"}), 400 - - # Find or create the book to link BookFusion to - if source == "abs": - book = database_service.get_book_by_ref(source_id) - if not book: - abs_client = container.abs_client() - item = abs_client.get_item_details(source_id) if abs_client else None - metadata = (item or {}).get("media", {}).get("metadata", {}) - book = Book( - abs_id=source_id, - title=metadata.get("title") or suggestion.title or source_id, - status="not_started", - duration=(item or {}).get("media", {}).get("duration"), - sync_mode="audiobook", - ) - database_service.save_book(book) - abs_service = container.abs_service() - if abs_service and abs_service.is_available(): - try: - abs_service.add_to_collection(source_id, current_app.config["ABS_COLLECTION_NAME"]) - except Exception as e: - logger.warning(f"Failed to add '{source_id}' to ABS collection during BookFusion link: {e}") - book = database_service.get_book_by_ref(source_id) - else: - # Non-ABS source: look up by the field matching the source type - if source == "storyteller": - book = database_service.get_book_by_storyteller_uuid(source_id) - elif source == "kosync": - book = database_service.get_book_by_kosync_id(source_id) - else: - book = database_service.get_book_by_ebook_filename(source_id) - if not book: - book_kwargs = { - "abs_id": None, - "title": suggestion.title or source_id, - "status": "not_started", - "sync_mode": "ebook_only", - } - if source == "storyteller": - book_kwargs["storyteller_uuid"] = source_id - elif source == "grimmory": - book_kwargs["ebook_filename"] = source_id - elif source == "kosync": - book_kwargs["kosync_doc_id"] = source_id - book = Book(**book_kwargs) - database_service.save_book(book, is_new=True) - book = database_service.get_book_by_id(book.id) - - if not book: - return jsonify({"success": False, "error": "Could not find or create book"}), 500 - - for bid in bookfusion_ids: - database_service.set_bookfusion_book_match_by_book_id(bid, book.id) - database_service.link_bookfusion_highlights_by_book_id(bid, book.id) - - database_service.resolve_suggestion(source_id, source=source) - return jsonify({"success": True, "book_id": book.id}) - - -@api_bp.route("/api/sync-reading-dates", methods=["POST"]) +# ---------------- Storyteller ---------------- def sync_reading_dates_api(): """Auto-complete books at 100% progress and fill missing dates.""" container = get_container() diff --git a/static/js/dashboard.js b/static/js/dashboard.js index 077ce5f..81deb7b 100644 --- a/static/js/dashboard.js +++ b/static/js/dashboard.js @@ -876,39 +876,3 @@ function dismissDetected(sourceId, source, btn) { if (btn) btn.disabled = false; }); } -function dismissSuggestion(sourceId, source, btn) { - if (btn) btn.disabled = true; - fetch('/api/suggestions/' + encodeURIComponent(sourceId) + '/hide?source=' + encodeURIComponent(source || 'abs'), { method: 'POST' }) - .then(function(r) { - if (!r.ok) throw new Error('Failed to dismiss'); - var card = document.getElementById('suggestion-card-' + sourceId); - if (card) { - card.classList.add('removing'); - setTimeout(function() { - card.remove(); - var banner = document.getElementById('suggestion-banner'); - var remaining = banner ? banner.querySelectorAll('.suggestion-banner-card') : []; - if (remaining.length === 0 && banner) { - banner.style.display = 'none'; - } - var countEl = document.getElementById('suggestion-banner-count'); - if (countEl && remaining.length > 0) { - countEl.textContent = remaining.length; - } - // Update navbar badge - var badge = document.querySelector('.nav-badge'); - if (badge) { - var current = parseInt(badge.textContent, 10) || 0; - if (current > 1) { - badge.textContent = current - 1; - } else { - badge.style.display = 'none'; - } - } - }, 200); - } - }) - .catch(function() { - if (btn) btn.disabled = false; - }); -} diff --git a/static/js/settings.js b/static/js/settings.js index e153a09..994bf6c 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -265,33 +265,6 @@ function copyInputValue(inputId) { /* ─── Tool Actions ─── */ -function clearStaleSuggestions() { - PKModal.confirm({ - title: 'Clear Stale Suggestions', - message: 'This will permanently delete all suggestions for books that are not currently matched in your bridge. Books you are already syncing will be preserved.', - confirmLabel: 'Clear', - confirmClass: 'btn btn-danger', - onConfirm: function() { - fetch('/api/suggestions/clear_stale', { - method: 'POST', - headers: { 'Content-Type': 'application/json' } - }) - .then(function(r) { return r.json(); }) - .then(function(data) { - if (data.success) { - PKModal.alert({ title: 'Success', message: 'Cleared ' + data.count + ' stale suggestions.' }); - } else { - PKModal.alert({ title: 'Error', message: 'Failed to clear suggestions: ' + (data.error || 'Unknown error') }); - } - }) - .catch(function(err) { - console.error('Error clearing suggestions:', err); - PKModal.alert({ title: 'Error', message: 'An error occurred while clearing suggestions.' }); - }); - } - }); -} - function syncReadingDates(btn) { btn.disabled = true; btn.textContent = 'Syncing\u2026'; diff --git a/templates/settings.html b/templates/settings.html index 952b4a5..cf4b0f4 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -746,26 +746,6 @@

Suggestions

- -
-
-

Remove Stale Suggestions

-
-
-
-
- -

- Deletes suggestions for Audiobookshelf audiobooks that are no longer in your library. - Useful after removing books from ABS. -

-
-
-
-
-
From c64b06376ff365f3277286a8a278eae4dfef117a Mon Sep 17 00:00:00 2001 From: Sarah Wolff Date: Sun, 5 Apr 2026 21:05:10 -0400 Subject: [PATCH 06/14] refactor: remove orphaned suggestion frontend files Delete suggestions.html, suggestions.js, suggestions.css - no longer referenced Keeps settings.html toggle for SUGGESTIONS_ENABLED env var (still used for rescan) --- static/css/suggestions.css | 497 ------------------------------------- static/js/suggestions.js | 447 --------------------------------- templates/suggestions.html | 111 --------- 3 files changed, 1055 deletions(-) delete mode 100644 static/css/suggestions.css delete mode 100644 static/js/suggestions.js delete mode 100644 templates/suggestions.html diff --git a/static/css/suggestions.css b/static/css/suggestions.css deleted file mode 100644 index dba8881..0000000 --- a/static/css/suggestions.css +++ /dev/null @@ -1,497 +0,0 @@ -/* PageKeeper - Suggestions page styles */ - -.suggestions-shell { - margin-bottom: 60px; -} - -.sg-page-header { - display: flex; - justify-content: space-between; - align-items: flex-end; - gap: 24px; - margin-bottom: 24px; -} - -.sg-header-copy { - max-width: 760px; -} - -.sg-page-kicker { - margin: 0 0 6px; - color: var(--color-text-faint); - font-size: 11px; - font-weight: 700; - letter-spacing: 0.12em; - text-transform: uppercase; -} - -.sg-page-title { - margin: 0 0 10px; - font-family: var(--font-heading); - font-size: 30px; - font-weight: 700; - color: var(--color-text); -} - -.sg-page-description { - margin: 0; - color: var(--color-text-muted); - max-width: 720px; -} - -.sg-toolbar { - position: sticky; - top: 0; - z-index: 40; - display: flex; - flex-direction: column; - gap: 10px; - margin-bottom: 20px; - padding: 14px 16px; - border-radius: var(--radius-lg); - border: 1px solid var(--color-border-light); - background: - linear-gradient(135deg, rgba(59, 130, 246, 0.12) 0%, rgba(33, 30, 49, 0.96) 35%, rgba(33, 30, 49, 0.96) 100%); - -webkit-backdrop-filter: blur(12px); - backdrop-filter: blur(12px); -} - -.sg-toolbar-main { - display: flex; - align-items: center; - gap: 10px; - flex-wrap: wrap; -} - -.sg-toolbar .search-box { - width: 320px; - max-width: 100%; -} - -.sg-toolbar select { - min-width: 180px; - padding: 9px 12px; - border: 1px solid var(--color-border-light); - border-radius: var(--radius); - background: var(--color-surface); - color: var(--color-text); -} - -.sg-toolbar-status { - min-height: 18px; -} - -.sg-view-toggle { - display: flex; - background: var(--color-bg-input); - border: 1px solid var(--color-border); - border-radius: var(--radius); - overflow: hidden; - margin-left: auto; -} - -.sg-view-btn { - display: flex; - align-items: center; - justify-content: center; - padding: 7px 10px; - background: transparent; - border: none; - color: var(--color-text-faint); - cursor: pointer; - transition: all var(--transition); -} - -.sg-view-btn:hover:not(:disabled) { - color: var(--color-text); -} - -.sg-view-btn.active { - background: var(--color-surface); - color: var(--color-text); -} - -.sg-view-btn:disabled { - opacity: 0.45; - cursor: default; -} - -.stats-strip { - display: flex; - gap: 12px; - flex-wrap: wrap; - margin-bottom: 22px; -} - -.stat-pill { - border: 1px solid var(--color-border-light); - background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(8, 11, 20, 0.18)); - padding: 10px 14px; - border-radius: 999px; - font-size: 0.9rem; - color: var(--color-text-muted); -} - -.stat-pill strong { - color: var(--color-text); -} - -.suggestions-results { - margin-bottom: 24px; -} - -.suggestion-grid { - display: grid; - gap: 18px; -} - -.suggestions-results.sg-grid-view .suggestion-grid, -.suggestion-grid:not(.sg-list-grid) { - grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); -} - -.suggestion-grid.sg-list-grid, -.suggestions-results.sg-list-view .suggestion-grid { - grid-template-columns: 1fr; -} - -.suggestion-card { - display: flex; - flex-direction: column; - gap: 16px; - min-width: 0; - background: - radial-gradient(circle at top right, rgba(59, 130, 246, 0.08), transparent 30%), - linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(8, 11, 20, 0.2)); - border: 1px solid var(--color-border-light); - border-radius: 22px; - padding: 20px; - transition: transform var(--transition-bounce), border-color var(--transition), box-shadow var(--transition); -} - -.suggestion-card:hover { - transform: translateY(-1px); - border-color: rgba(255, 255, 255, 0.08); - box-shadow: 0 18px 36px rgba(8, 11, 20, 0.18); -} - -.suggestion-card[data-has-bookfusion="true"] { - border-color: rgba(59, 130, 246, 0.35); - box-shadow: 0 12px 32px rgba(59, 130, 246, 0.08); -} - -.suggestion-card--hidden { - opacity: 0.68; -} - -.suggestion-card--hidden:hover { - opacity: 1; -} - -.suggestions-results.sg-list-view .suggestion-card, -.suggestion-grid.sg-list-grid .suggestion-card { - display: grid; - grid-template-columns: minmax(220px, 280px) minmax(0, 1fr) auto; - align-items: start; - gap: 18px; -} - -.suggestion-source { - display: flex; - gap: 14px; - min-width: 0; -} - -.suggestion-cover { - width: 72px; - height: 108px; - border-radius: 12px; - object-fit: cover; - flex-shrink: 0; - background: var(--color-bg); -} - -.suggestion-meta { - min-width: 0; -} - -.suggestion-meta h3 { - margin: 0 0 4px; - font-size: 1.05rem; - color: var(--color-text); - overflow-wrap: anywhere; -} - -.suggestion-meta p { - margin: 0; - color: var(--color-text-muted); - font-size: 0.9rem; -} - -.badge-row { - display: flex; - gap: 8px; - flex-wrap: wrap; - margin-top: 10px; -} - -.chip { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 4px 8px; - border-radius: 999px; - font-size: 0.72rem; - font-weight: 600; - background: var(--color-bg); - color: var(--color-text-muted); -} - -.chip--bookfusion { - background: rgba(59, 130, 246, 0.12); - color: var(--color-bookfusion); -} - -.chip--confidence-high { - background: var(--color-confidence-high-bg); - color: var(--color-confidence-high); -} - -.chip--confidence-medium { - background: var(--color-confidence-medium-bg); - color: var(--color-confidence-medium); -} - -.chip--source-kosync { - background: rgba(167, 139, 250, 0.14); - color: var(--color-kosync); -} - -.candidate-list { - display: flex; - flex-direction: column; - gap: 10px; - min-width: 0; -} - -.candidate { - border: 1px solid var(--color-border-light); - border-radius: 16px; - padding: 12px; - background: rgba(255, 255, 255, 0.03); -} - -.candidate-top { - display: flex; - justify-content: space-between; - gap: 12px; - align-items: flex-start; - margin-bottom: 8px; -} - -.candidate-title { - font-weight: 600; - margin-bottom: 2px; - color: var(--color-text); -} - -.candidate-author { - color: var(--color-text-muted); - font-size: 0.84rem; -} - -.candidate-score { - display: inline-flex; - align-items: center; - gap: 8px; - color: var(--color-text-muted); - font-size: 0.8rem; - white-space: nowrap; -} - -.candidate-actions { - display: flex; - gap: 8px; - flex-wrap: wrap; - margin-top: 10px; -} - -.suggestion-actions { - display: flex; - gap: 8px; - flex-wrap: wrap; - align-content: flex-start; -} - -.suggestions-results.sg-grid-view .suggestion-actions, -.suggestion-grid:not(.sg-list-grid) .suggestion-actions { - margin-top: 2px; - padding-top: 14px; - border-top: 1px solid var(--color-border-light); -} - -.suggestions-results.sg-list-view .suggestion-actions, -.suggestion-grid.sg-list-grid .suggestion-actions { - flex-direction: column; - min-width: 132px; -} - -.suggestions-results.sg-list-view .suggestion-actions .btn, -.suggestion-grid.sg-list-grid .suggestion-actions .btn { - width: 100%; -} - -.empty-state { - text-align: center; - padding: 56px 18px; - color: var(--color-text-muted); - border: 1px dashed var(--color-border-light); - border-radius: 18px; - background: var(--color-surface); -} - -.help-note { - color: var(--color-text-muted); - font-size: 0.85rem; -} - -.suggestions-hidden-section { - margin-top: 24px; - border-top: 1px solid var(--color-border-light); - padding-top: 16px; -} - -.suggestions-hidden-header { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 16px; - background: var(--color-surface); - border: 1px solid var(--color-border-light); - border-radius: var(--radius-lg); - cursor: pointer; - font-weight: 600; - font-family: var(--font-heading); - font-size: 0.9rem; - color: var(--color-text-muted); - user-select: none; - transition: all var(--transition); -} - -.suggestions-hidden-header:hover { - border-color: var(--color-border); - background: var(--color-surface-hover); -} - -.suggestions-hidden-header:focus-visible { - outline: 2px solid var(--color-primary-hover); - outline-offset: 2px; -} - -.suggestions-hidden-header .chevron { - transition: transform 0.2s; - font-size: 0.8rem; -} - -.suggestions-hidden-count { - color: var(--color-text-muted); -} - -.suggestions-hidden-header.collapsed .chevron { - transform: rotate(-90deg); -} - -.suggestions-hidden-body { - margin-top: 10px; -} - -.suggestions-hidden-empty { - margin-top: 8px; -} - -@media (max-width: 1120px) { - .suggestions-results.sg-list-view .suggestion-card, - .suggestion-grid.sg-list-grid .suggestion-card { - grid-template-columns: minmax(220px, 280px) minmax(0, 1fr); - } - - .suggestions-results.sg-list-view .suggestion-actions, - .suggestion-grid.sg-list-grid .suggestion-actions { - grid-column: 1 / -1; - flex-direction: row; - min-width: 0; - padding-top: 4px; - border-top: 1px solid var(--color-border-light); - } - - .suggestions-results.sg-list-view .suggestion-actions .btn, - .suggestion-grid.sg-list-grid .suggestion-actions .btn { - width: auto; - } -} - -@media (max-width: 960px) { - .sg-page-header { - flex-direction: column; - align-items: stretch; - gap: 14px; - } - - .sg-toolbar-main { - align-items: stretch; - } - - .sg-toolbar .search-box, - .sg-toolbar select { - width: 100%; - } - - .sg-view-toggle { - margin-left: 0; - align-self: flex-start; - } -} - -@media (max-width: 768px) { - .sg-page-header { - gap: 12px; - margin-bottom: 18px; - } - - .sg-page-kicker { - margin-bottom: 4px; - font-size: 10px; - letter-spacing: 0.1em; - } - - .sg-page-title { - font-size: 26px; - margin-bottom: 8px; - } - - .sg-toolbar { - padding: 12px; - } - - .suggestion-grid, - .suggestions-results.sg-grid-view .suggestion-grid { - grid-template-columns: 1fr; - } - - .suggestions-results.sg-list-view .suggestion-card, - .suggestion-grid.sg-list-grid .suggestion-card { - grid-template-columns: 1fr; - } - - .candidate-top { - flex-direction: column; - } - - .suggestion-actions { - flex-direction: row; - } - - .suggestion-actions .btn { - width: auto; - } -} diff --git a/static/js/suggestions.js b/static/js/suggestions.js deleted file mode 100644 index 9940dc3..0000000 --- a/static/js/suggestions.js +++ /dev/null @@ -1,447 +0,0 @@ -/* ═══════════════════════════════════════════ - PageKeeper — suggestions page - ═══════════════════════════════════════════ - Depends on: utils.js, confirm-modal.js - Reads: window.PK_PAGE_DATA.suggestionsData - window.PK_PAGE_DATA.selectedSourceId - ═══════════════════════════════════════════ */ - -(function () { - 'use strict'; - - var suggestionData = window.PK_PAGE_DATA.suggestionsData; - var selectedSourceId = window.PK_PAGE_DATA.selectedSourceId; - var rescanPollTimer = null; - var desktopMedia = window.matchMedia('(min-width: 961px)'); - var currentView = 'list'; - - /* ── helpers ── */ - - function formatEvidence(evidence) { - return (evidence || []).map(function (item) { - return '' + escapeHtml(item.split('_').join(' ')) + ''; - }).join(''); - } - - function confidenceRank(confidence) { - if (confidence === 'high') return 3; - if (confidence === 'medium') return 2; - return 1; - } - - function filterSuggestions() { - var query = (document.getElementById('suggestion-search').value || '').toLowerCase().trim(); - var confidenceFilter = document.getElementById('confidence-filter').value; - var bfFilterEl = document.getElementById('bookfusion-filter'); - var bookfusionFilter = bfFilterEl ? bfFilterEl.value : 'all'; - - return suggestionData.filter(function (suggestion) { - if (selectedSourceId && suggestion.source_id !== selectedSourceId) return false; - if (bookfusionFilter === 'bookfusion' && !suggestion.has_bookfusion_evidence) return false; - - var topConfidence = suggestion.top_match ? suggestion.top_match.confidence : 'low'; - if (confidenceFilter === 'high' && topConfidence !== 'high') return false; - if (confidenceFilter === 'medium' && confidenceRank(topConfidence) < 2) return false; - - if (!query) return true; - var haystack = [ - suggestion.title, - suggestion.author - ].concat((suggestion.matches || []).map(function (match) { - return [match.title, match.author, match.filename, match.source_family, (match.evidence || []).join(' ')].join(' '); - })).join(' ').toLowerCase(); - return haystack.indexOf(query) !== -1; - }); - } - - /* ── rendering ── - Note: all user-facing strings are passed through escapeHtml() (from utils.js) - before insertion into HTML markup strings. */ - - function renderCandidate(match, suggestion, index) { - var confidenceClass = 'chip--confidence-' + (match.confidence || 'low'); - var actions = []; - var sgSource = suggestion.source || 'unknown'; - - if (!suggestion.hidden) { - if (match.source_family === 'bookfusion') { - actions.push(''); - if (match.bookfusion_ids && match.bookfusion_ids.length) { - actions.push(''); - } - } else if (match.action_kind === 'create_ebook_mapping') { - var ebookMappingUrl = '/match?search=' + encodeURIComponent(match.title || suggestion.title || ''); - actions.push('Create Ebook Mapping'); - } else { - var mappingUrl = '/match?search=' + encodeURIComponent(suggestion.title || ''); - if (sgSource === 'abs') { - mappingUrl = '/match?abs_id=' + encodeURIComponent(suggestion.source_id) + '&search=' + encodeURIComponent(suggestion.title || ''); - } else if (match.abs_id) { - mappingUrl = '/match?abs_id=' + encodeURIComponent(match.abs_id) + '&search=' + encodeURIComponent(match.title || suggestion.title || ''); - } - actions.push('Create Mapping'); - } - } - - return '' + - '
' + - '
' + - '
' + - '
' + escapeHtml(match.title || match.filename || 'Untitled') + '
' + - '
' + escapeHtml(match.author || match.source_family || '') + '
' + - '
' + - '
' + - '' + escapeHtml(match.confidence || 'low') + '' + - '' + Math.round((match.score || 0) * 100) + '%' + - '
' + - '
' + - '
' + - '' + escapeHtml(match.source_family || match.source || 'unknown') + '' + - formatEvidence(match.evidence) + - '
' + - (match.highlight_count ? '
BookFusion highlights: ' + escapeHtml(match.highlight_count) + '
' : '') + - (actions.length ? '
' + actions.join('') + '
' : '') + - '
'; - } - - function renderSuggestionCard(suggestion) { - var matches = (suggestion.matches || []).map(function (match, index) { - return renderCandidate(match, suggestion, index); - }).join(''); - - var suggestionSource = suggestion.source || 'unknown'; - var actionButtons = suggestion.hidden - ? '' - : ''; - - return '' + - '
' + - '
' + - (suggestion.cover_url - ? '' - : '
') + - '
' + - '

' + escapeHtml(suggestion.title) + '

' + - '

' + escapeHtml(suggestion.author || 'Unknown author') + '

' + - '
' + - (suggestion.source && suggestion.source !== 'abs' ? '' + escapeHtml(suggestion.source) + '' : '') + - '' + escapeHtml((suggestion.matches || []).length) + ' candidates' + - (suggestion.hidden ? 'Hidden' : '') + - (suggestion.has_bookfusion_evidence ? 'BookFusion evidence' : '') + - '
' + - '
' + - '
' + - '
' + matches + '
' + - '
' + - actionButtons + - '' + - '
' + - '
'; - } - - /* ── view toggle ── */ - - function setView(view, persist) { - var results = document.getElementById('suggestions-results'); - var hiddenGrid = document.getElementById('hidden-grid'); - var viewButtons = document.querySelectorAll('.sg-view-btn'); - var forcedView = desktopMedia.matches ? view : 'list'; - - currentView = forcedView; - - if (results) { - results.classList.toggle('sg-grid-view', forcedView === 'grid'); - results.classList.toggle('sg-list-view', forcedView !== 'grid'); - } - - if (hiddenGrid) { - hiddenGrid.classList.toggle('sg-list-grid', forcedView !== 'grid'); - } - - viewButtons.forEach(function (btn) { - var isActive = btn.dataset.view === forcedView; - btn.classList.toggle('active', isActive); - btn.disabled = !desktopMedia.matches; - btn.setAttribute('aria-pressed', isActive ? 'true' : 'false'); - }); - - if (persist && desktopMedia.matches) { - try { localStorage.setItem('pk-suggestions-view', forcedView); } catch (e) {} - } - } - - /* ── main render ── */ - - function renderSuggestions() { - var filtered = filterSuggestions(); - var visible = filtered.filter(function (item) { return !item.hidden; }); - var hidden = filtered.filter(function (item) { return item.hidden; }); - var grid = document.getElementById('suggestion-grid'); - var hiddenSection = document.getElementById('hidden-section'); - var hiddenGrid = document.getElementById('hidden-grid'); - var hiddenCount = document.getElementById('hidden-section-count'); - var empty = document.getElementById('empty-state'); - - document.getElementById('visible-count').textContent = visible.length; - document.getElementById('hidden-count').textContent = hidden.length; - document.getElementById('total-count').textContent = filtered.length; - - /* All values passed to renderSuggestionCard are escapeHtml-sanitized */ - grid.innerHTML = visible.map(renderSuggestionCard).join(''); // eslint-disable-line no-unsanitized/property - - if (hidden.length) { - hiddenSection.classList.remove('hidden'); - hiddenGrid.innerHTML = hidden.map(renderSuggestionCard).join(''); // eslint-disable-line no-unsanitized/property - hiddenCount.textContent = '(' + hidden.length + ')'; - } else { - hiddenSection.classList.add('hidden'); - hiddenGrid.textContent = ''; - hiddenCount.textContent = '(0)'; - } - - if (!visible.length) { - empty.classList.remove('hidden'); - } else { - empty.classList.add('hidden'); - } - } - - /* ── state management ── */ - - function updateSuggestionState(sourceId, updater) { - suggestionData = suggestionData.map(function (item) { - if (item.source_id !== sourceId) return item; - return updater(Object.assign({}, item)); - }).filter(Boolean); - renderSuggestions(); - } - - function showErrorToast(message) { - PKModal.alert({ title: 'Error', message: message }); - } - - function actOnSuggestion(url, btn, onSuccess) { - if (btn) btn.disabled = true; - fetch(url, { method: 'POST' }) - .then(function (r) { return r.json(); }) - .then(function (data) { - if (!data.success) throw new Error(data.error || 'Request failed'); - onSuccess(); - }) - .catch(function (err) { - if (btn) btn.disabled = false; - showErrorToast(err.message || String(err)); - }); - } - - /* ── actions ── */ - - function hideSuggestion(sourceId, source, btn) { - PKModal.confirm({ - title: 'Hide Suggestion?', - message: 'This suggestion will move to the Hidden section. You can restore it later.', - confirmLabel: 'Hide', - confirmClass: 'btn', - onConfirm: function () { - actOnSuggestion('/api/suggestions/' + encodeURIComponent(sourceId) + '/hide?source=' + encodeURIComponent(source || 'abs'), btn, function () { - updateSuggestionState(sourceId, function (item) { - item.status = 'hidden'; - item.hidden = true; - return item; - }); - }); - } - }); - } - - function ignoreSuggestion(sourceId, source, btn) { - PKModal.confirm({ - title: 'Never Ask Again?', - message: 'This suggestion will be permanently ignored and will not return on future rescans.', - confirmLabel: 'Never Ask', - confirmClass: 'btn btn-danger', - onConfirm: function () { - actOnSuggestion('/api/suggestions/' + encodeURIComponent(sourceId) + '/ignore?source=' + encodeURIComponent(source || 'abs'), btn, function () { - updateSuggestionState(sourceId, function () { - return null; - }); - }); - } - }); - } - - function unhideSuggestion(sourceId, source, btn) { - actOnSuggestion('/api/suggestions/' + encodeURIComponent(sourceId) + '/unhide?source=' + encodeURIComponent(source || 'abs'), btn, function () { - updateSuggestionState(sourceId, function (item) { - item.status = 'pending'; - item.hidden = false; - return item; - }); - }); - } - - function linkBookFusion(sourceId, matchIndex, source) { - fetch('/api/suggestions/' + encodeURIComponent(sourceId) + '/link-bookfusion', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ match_index: matchIndex, source: source || 'abs' }) - }) - .then(function (r) { return r.json(); }) - .then(function (data) { - if (!data.success) throw new Error(data.error || 'Link failed'); - refreshSuggestionsData('BookFusion link created.'); - }) - .catch(function (err) { - showErrorToast(err.message || String(err)); - }); - } - - function addBookFusionToDashboard(bookfusionIds) { - fetch('/api/bookfusion/add-to-dashboard', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ bookfusion_ids: bookfusionIds }) - }) - .then(function (r) { return r.json(); }) - .then(function (data) { - if (!data.success) throw new Error(data.error || 'Add failed'); - window.location.href = '/'; - }) - .catch(function (err) { - showErrorToast(err.message || String(err)); - }); - } - - /* ── rescan ── */ - - function rescanSuggestions() { - var btn = document.getElementById('rescan-btn'); - var status = document.getElementById('rescan-status'); - btn.disabled = true; - status.textContent = 'Queued library rescan...'; - fetch('/api/suggestions/rescan', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}) - }) - .then(function (r) { return r.json(); }) - .then(function (data) { - if (!data.success) throw new Error(data.error || 'Rescan failed'); - if (data.rate_limited) { - status.textContent = data.message || ('Please wait ' + (data.next_allowed_in || 0) + 's before rescanning again.'); - btn.disabled = false; - return; - } - status.textContent = data.message || 'Suggestions rescan started...'; - pollRescanStatus(); - }) - .catch(function (err) { - status.textContent = err.message || String(err); - btn.disabled = false; - }); - } - - function pollRescanStatus() { - if (rescanPollTimer) { - clearTimeout(rescanPollTimer); - rescanPollTimer = null; - } - fetch('/api/suggestions/rescan-status') - .then(function (r) { return r.json(); }) - .then(function (data) { - if (!data.success) throw new Error(data.error || 'Status failed'); - var status = document.getElementById('rescan-status'); - var btn = document.getElementById('rescan-btn'); - - if (data.running) { - status.textContent = data.message || 'Rescan in progress...'; - btn.disabled = true; - rescanPollTimer = setTimeout(pollRescanStatus, 1500); - return; - } - - if (data.phase === 'complete') { - refreshSuggestionsData(data.message || 'Rescan complete.'); - return; - } - - if (data.rate_limited) { - status.textContent = data.message || ('Please wait ' + (data.next_allowed_in || 0) + 's before rescanning again.'); - } else if (data.message) { - status.textContent = data.message; - } - btn.disabled = false; - }) - .catch(function (err) { - document.getElementById('rescan-status').textContent = err.message || String(err); - document.getElementById('rescan-btn').disabled = false; - }); - } - - function refreshSuggestionsData(statusMessage) { - fetch('/api/suggestions') - .then(function (r) { return r.json(); }) - .then(function (data) { - suggestionData = data; - renderSuggestions(); - var status = document.getElementById('rescan-status'); - var btn = document.getElementById('rescan-btn'); - if (statusMessage) status.textContent = statusMessage; - btn.disabled = false; - }) - .catch(function (err) { - var status = document.getElementById('rescan-status'); - var btn = document.getElementById('rescan-btn'); - status.textContent = 'Refresh failed: ' + (err.message || String(err)); - btn.disabled = false; - }); - } - - /* ── init ── */ - - document.querySelectorAll('.sg-view-btn').forEach(function (btn) { - btn.addEventListener('click', function () { - setView(btn.dataset.view, true); - }); - }); - - try { - var savedView = localStorage.getItem('pk-suggestions-view'); - setView(savedView === 'grid' ? 'grid' : 'list', false); - } catch (e) { - setView('list', false); - } - - function handleViewportChange() { - var savedView = 'list'; - try { savedView = localStorage.getItem('pk-suggestions-view') || 'list'; } catch (e) {} - setView(savedView, false); - } - - if (desktopMedia.addEventListener) { - desktopMedia.addEventListener('change', handleViewportChange); - } else if (desktopMedia.addListener) { - desktopMedia.addListener(handleViewportChange); - } - - /* Wire up filter inputs */ - document.getElementById('suggestion-search').addEventListener('input', renderSuggestions); - document.getElementById('confidence-filter').addEventListener('change', renderSuggestions); - var bfFilter = document.getElementById('bookfusion-filter'); - if (bfFilter) bfFilter.addEventListener('change', renderSuggestions); - - /* Wire up rescan button */ - document.getElementById('rescan-btn').addEventListener('click', rescanSuggestions); - - renderSuggestions(); - pollRescanStatus(); - - /* ── expose functions called from inline onclick in rendered HTML ── */ - window.PK_Suggestions = { - hideSuggestion: hideSuggestion, - unhideSuggestion: unhideSuggestion, - ignoreSuggestion: ignoreSuggestion, - linkBookFusion: linkBookFusion, - addBookFusionToDashboard: addBookFusionToDashboard - }; -})(); diff --git a/templates/suggestions.html b/templates/suggestions.html deleted file mode 100644 index 5d33000..0000000 --- a/templates/suggestions.html +++ /dev/null @@ -1,111 +0,0 @@ - - - - - - {{ title_prefix }}Suggestions - PageKeeper - - - - - - - - - - - - - {% include 'partials/navbar.html' %} - -
-
-
-

Discovery Queue

-

Pairing Suggestions

-

Review potential audiobook & ebook pairings

-
-
- -
-
- -
-
- - - {% if bookfusion_enabled %} - - {% endif %} -
- - -
-
- -
- -
-
{{ visible_count }} visible suggestions
-
{{ hidden_count }} hidden suggestions
-
{{ suggestions|length }} total actionable
- {% if bookfusion_enabled %} -
{{ bookfusion_catalog_count }} BookFusion catalog items cached
- {% endif %} -
{{ 'enabled' if suggestions_enabled else 'disabled' }} suggestions status
-
- -
-
-
- - - - - - {% include 'partials/confirm_modal.html' %} -
- - - - - - - - From ce91078dd0fac0b5d5f3c7e31ade504dc75f34c9 Mon Sep 17 00:00:00 2001 From: Sarah Wolff Date: Sun, 5 Apr 2026 21:08:22 -0400 Subject: [PATCH 07/14] refactor: remove remaining suggestion dead code - Remove legacy suggestion banner from dashboard.py - Remove suggestions toggle from settings.html - Remove serialize_suggestion and _has_bookfusion_evidence from helpers.py - Remove unused imports from api.py, dashboard.py, matching_bp.py --- src/blueprints/api.py | 1 - src/blueprints/dashboard.py | 26 +++-------------------- src/blueprints/helpers.py | 39 ----------------------------------- src/blueprints/matching_bp.py | 1 - templates/settings.html | 23 --------------------- 5 files changed, 3 insertions(+), 87 deletions(-) diff --git a/src/blueprints/api.py b/src/blueprints/api.py index e64fab5..0703f7c 100644 --- a/src/blueprints/api.py +++ b/src/blueprints/api.py @@ -14,7 +14,6 @@ get_database_service, get_grimmory_client, get_kosync_id_for_ebook, - serialize_suggestion, ) from src.db.models import Book diff --git a/src/blueprints/dashboard.py b/src/blueprints/dashboard.py index 9c8ae24..6a9228f 100644 --- a/src/blueprints/dashboard.py +++ b/src/blueprints/dashboard.py @@ -9,14 +9,10 @@ from flask import Blueprint, render_template from src.blueprints.helpers import ( - find_grimmory_metadata, - get_abs_service, - get_container, + get_book_or_404, get_database_service, - get_enabled_grimmory_server_ids, - get_hardcover_book_url, - get_service_web_url, - serialize_suggestion, + get_grimmory_client, + serialize_detected_book, ) from src.utils.cover_resolver import resolve_book_covers from src.version import APP_VERSION @@ -415,21 +411,6 @@ def _run_date_sync(): except Exception: pass - # Pending suggestions — for dashboard banner (legacy, will be replaced) - top_suggestions = [] - suggestions_enabled = os.environ.get("SUGGESTIONS_ENABLED", "false").lower() in ("true", "1", "yes", "on") - if suggestions_enabled: - try: - pending = database_service.get_all_pending_suggestions() - for s in pending[:10]: - serialized = serialize_suggestion(s) - if serialized["top_match"] and serialized["top_match"].get("confidence") == "high": - top_suggestions.append(serialized) - if len(top_suggestions) >= 3: - break - except Exception: - pass - return render_template( "index.html", mappings=mappings, @@ -439,6 +420,5 @@ def _run_date_sync(): grimmory_label=grimmory_label, kosync_unlinked_count=kosync_unlinked_count, unlinked_reading=unlinked_reading, - top_suggestions=top_suggestions, detected_books=detected_books, ) diff --git a/src/blueprints/helpers.py b/src/blueprints/helpers.py index db8a46a..7a6dfb6 100644 --- a/src/blueprints/helpers.py +++ b/src/blueprints/helpers.py @@ -533,45 +533,6 @@ def restart_server(): os.kill(os.getpid(), signal.SIGTERM) -def _has_bookfusion_evidence(match_dict): - """Check if a match dict has BookFusion-related evidence.""" - if match_dict.get("source_family") == "bookfusion": - return True - return any(ev.startswith("bookfusion") for ev in (match_dict.get("evidence") or [])) - - -def serialize_suggestion(s): - """Shared serializer for PendingSuggestion → JSON-ready dict.""" - matches = [] - for m in s.matches: - # Skip provenance-only entries (e.g. abs_audiobook markers from reverse suggestions) - if m.get("source") == "abs_audiobook" and not m.get("action_kind"): - continue - matches.append( - { - **m, - "evidence": m.get("evidence") or [], - "has_bookfusion": _has_bookfusion_evidence(m), - } - ) - - has_bookfusion_evidence = any(m.get("has_bookfusion") for m in matches) - return { - "id": s.id, - "source_id": s.source_id, - "source": s.source or "unknown", - "title": s.title, - "author": s.author, - "cover_url": s.cover_url, - "matches": matches, - "created_at": s.created_at.isoformat() if s.created_at else None, - "has_bookfusion_evidence": has_bookfusion_evidence, - "top_match": matches[0] if matches else None, - "status": "hidden" if s.status == "dismissed" else s.status, - "hidden": s.status in ("hidden", "dismissed"), - } - - def find_grimmory_metadata(book, grimmory_by_filename): """Find best Grimmory metadata entry for a book by filename.""" for fn in (book.ebook_filename, book.original_ebook_filename): diff --git a/src/blueprints/matching_bp.py b/src/blueprints/matching_bp.py index 2f5478d..eb019da 100644 --- a/src/blueprints/matching_bp.py +++ b/src/blueprints/matching_bp.py @@ -21,7 +21,6 @@ get_kosync_id_for_ebook, get_manager, get_searchable_ebooks, - serialize_suggestion, ) from src.db.models import Book, StorytellerSubmission from src.services.kosync_service import ensure_kosync_document diff --git a/templates/settings.html b/templates/settings.html index cf4b0f4..79383e6 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -723,29 +723,6 @@

Tools

Optional features and maintenance utilities.

- -
-
-

Suggestions

-
- Enable - -
-
-
-
-
- When enabled, the system will look for unmapped audiobooks with progress and suggest - matching ebooks from your library. -
-
-
-
-
From ed8df7cade6d0e4ef9bdc21e090a457e452bb414 Mon Sep 17 00:00:00 2001 From: Sarah Wolff Date: Sun, 5 Apr 2026 21:13:55 -0400 Subject: [PATCH 08/14] fix: restore missing imports and add serialize_detected_book helper - Add missing imports to dashboard.py (get_container, get_abs_service, etc.) - Add serialize_detected_book function to helpers.py - Import get_service_web_url and get_hardcover_book_url from service_url_helper --- src/blueprints/dashboard.py | 5 +++++ src/blueprints/helpers.py | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/blueprints/dashboard.py b/src/blueprints/dashboard.py index 6a9228f..d9f81fb 100644 --- a/src/blueprints/dashboard.py +++ b/src/blueprints/dashboard.py @@ -9,12 +9,17 @@ from flask import Blueprint, render_template from src.blueprints.helpers import ( + find_grimmory_metadata, + get_abs_service, get_book_or_404, + get_container, get_database_service, + get_enabled_grimmory_server_ids, get_grimmory_client, serialize_detected_book, ) from src.utils.cover_resolver import resolve_book_covers +from src.utils.service_url_helper import get_hardcover_book_url, get_service_web_url from src.version import APP_VERSION logger = logging.getLogger(__name__) diff --git a/src/blueprints/helpers.py b/src/blueprints/helpers.py index 7a6dfb6..3f66306 100644 --- a/src/blueprints/helpers.py +++ b/src/blueprints/helpers.py @@ -561,3 +561,21 @@ def safe_folder_name(name: str) -> str: for c in invalid: name = name.replace(c, "_") return name.strip() or "Unknown" + + +def serialize_detected_book(d): + """Serialize DetectedBook for template context.""" + return { + "id": d.id, + "source": d.source, + "source_id": d.source_id, + "title": d.title, + "author": d.author, + "cover_url": d.cover_url, + "progress_percentage": d.progress_percentage, + "first_detected_at": d.first_detected_at.isoformat() if d.first_detected_at else None, + "last_seen_at": d.last_seen_at.isoformat() if d.last_seen_at else None, + "matches": d.matches, + "device": d.device, + "ebook_filename": d.ebook_filename, + } From 9bee31a4c6e33bf7516d29ccf943ca9bab436abd Mon Sep 17 00:00:00 2001 From: Sarah Wolff Date: Sun, 5 Apr 2026 21:21:13 -0400 Subject: [PATCH 09/14] fix(migrations): merge detected books alembic heads --- .../t9u0v1w2x3y4_merge_detected_books_head.py | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 alembic/versions/t9u0v1w2x3y4_merge_detected_books_head.py diff --git a/alembic/versions/t9u0v1w2x3y4_merge_detected_books_head.py b/alembic/versions/t9u0v1w2x3y4_merge_detected_books_head.py new file mode 100644 index 0000000..fe20f5a --- /dev/null +++ b/alembic/versions/t9u0v1w2x3y4_merge_detected_books_head.py @@ -0,0 +1,22 @@ +"""merge detected books migration head + +Revision ID: t9u0v1w2x3y4 +Revises: 5308a8e2c930, s1t2u3v4w5x6 +Create Date: 2026-04-05 +""" + +from typing import Sequence + + +revision: str = "t9u0v1w2x3y4" +down_revision: Sequence[str] = ("5308a8e2c930", "s1t2u3v4w5x6") +branch_labels = None +depends_on = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass From d7682a3f2bd45e454ac8945ca4eb26816fd58181 Mon Sep 17 00:00:00 2001 From: Sarah Wolff Date: Sun, 5 Apr 2026 21:33:39 -0400 Subject: [PATCH 10/14] fix(detected): remove final ABS suggestion path Stop creating PendingSuggestion rows for ABS detection and keep ABS progress discovery on the DetectedBook path only. Also pin mistune to 3.2.0 so the dev image can build again. --- requirements.txt | 2 +- src/services/suggestion_service.py | 55 +++++++++++------------------- 2 files changed, 20 insertions(+), 37 deletions(-) diff --git a/requirements.txt b/requirements.txt index a13ffd8..7d0dec5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,5 +19,5 @@ alembic==1.18.4 python-socketio[client]==5.16.1 h11==0.16.0 mkdocs-material==9.7.4 -mistune==3.2.2 +mistune==3.2.0 pytest-asyncio==1.3.0 diff --git a/src/services/suggestion_service.py b/src/services/suggestion_service.py index 8542958..31bd150 100644 --- a/src/services/suggestion_service.py +++ b/src/services/suggestion_service.py @@ -29,7 +29,7 @@ class SuggestionService: - """Handles suggestion discovery and creation for unmapped books.""" + """Handles detected-book discovery plus legacy suggestion workflows.""" def __init__( self, @@ -423,27 +423,21 @@ def _rank_candidates_for_book( return ranked[:6] def queue_suggestion(self, abs_id: str) -> None: - """Queue suggestion discovery for an unmapped book (called from socket listener).""" - if os.environ.get("SUGGESTIONS_ENABLED", "true").lower() != "true": - return - + """Queue detected-book discovery for an unmapped ABS item.""" # Already mapped? all_books = self.database_service.get_all_books() mapped_ids = {b.abs_id for b in all_books} if abs_id in mapped_ids: return - if self._suggestion_already_recorded(abs_id): + if self._detected_book_is_dismissed(abs_id, source="abs"): return - logger.info(f"Socket.IO: Queuing suggestion discovery for '{abs_id[:12]}...'") + logger.info(f"Socket.IO: Queuing detected-book discovery for '{abs_id[:12]}...'") self._create_suggestion(abs_id, None) def queue_kosync_suggestion(self, doc_hash: str, filename: str | None = None, device: str | None = None) -> None: """Create or refresh a detected entry for a KoSync document.""" - if os.environ.get("SUGGESTIONS_ENABLED", "true").lower() != "true": - return - title = "" if filename: title = Path(filename).stem @@ -513,12 +507,7 @@ def queue_kosync_suggestion(self, doc_hash: str, filename: str | None = None, de ) def check_for_suggestions(self, abs_progress_map, active_books): - """Check for unmapped books with progress and create suggestions.""" - suggestions_enabled_val = os.environ.get("SUGGESTIONS_ENABLED", "true") - logger.debug(f"SUGGESTIONS_ENABLED env var is: '{suggestions_enabled_val}'") - - if suggestions_enabled_val.lower() != "true": - return + """Check for unmapped books with progress and create detected entries.""" try: # optimization: get all mapped IDs to avoid suggesting existing books (even if inactive) @@ -526,7 +515,7 @@ def check_for_suggestions(self, abs_progress_map, active_books): mapped_ids = {b.abs_id for b in all_books} logger.debug( - f"Checking for suggestions: {len(abs_progress_map)} books with progress, {len(mapped_ids)} already mapped" + f"Checking for detected ABS books: {len(abs_progress_map)} books with progress, {len(mapped_ids)} already mapped" ) for abs_id, item_data in abs_progress_map.items(): @@ -540,8 +529,8 @@ def check_for_suggestions(self, abs_progress_map, active_books): if duration > 0: pct = current_time / duration if pct > 0.01: - if self._suggestion_already_recorded(abs_id): - logger.debug(f"Skipping {abs_id}: suggestion already exists/hidden") + if self._detected_book_is_dismissed(abs_id, source="abs"): + logger.debug(f"Skipping {abs_id}: detected entry dismissed") continue # Check if book is already mostly finished (>70%) @@ -550,14 +539,14 @@ def check_for_suggestions(self, abs_progress_map, active_books): logger.debug(f"Skipping {abs_id}: progress {pct:.1%} > 70% threshold") continue - logger.debug(f"Creating suggestion for {abs_id} (progress: {pct:.1%})") + logger.debug(f"Creating detected entry for {abs_id} (progress: {pct:.1%})") self._create_suggestion(abs_id, item_data) else: logger.debug(f"Skipping {abs_id}: progress {pct:.1%} below 1% threshold") else: logger.debug(f"Skipping {abs_id}: no duration") except Exception as e: - logger.error(f"Error checking suggestions: {e}") + logger.error(f"Error checking detected ABS books: {e}") # Reverse suggestions: ebook sources → ABS audiobooks try: @@ -571,11 +560,9 @@ def check_for_suggestions(self, abs_progress_map, active_books): except Exception as e: logger.warning(f"Cross-ebook suggestions check failed: {e}") - def _suggestion_already_recorded(self, abs_id: str) -> bool: - """Return True when a suggestion should not be recreated for this ABS item.""" - if self.database_service.suggestion_exists(abs_id): - return True - detected = self.database_service.get_detected_book(abs_id, source="abs") + def _detected_book_is_dismissed(self, source_id: str, source: str = "abs") -> bool: + """Return True when a dismissed detected entry should stay hidden.""" + detected = self.database_service.get_detected_book(source_id, source=source) return bool(detected and detected.status == "dismissed") def _get_storyteller_books_with_progress(self, mapped_uuids: set | None = None) -> list[dict]: @@ -1077,17 +1064,17 @@ def _dedupe_matches(self, matches: list[dict], limit: int = 6) -> list[dict]: return sorted(deduped.values(), key=lambda m: m.get("score", 0.0), reverse=True)[:limit] def _create_suggestion(self, abs_id, progress_data): - """Create a new suggestion for an unmapped book.""" + """Create or update a detected ABS book for an unmapped item.""" with self._suggestion_lock: if abs_id in self._suggestion_in_flight: return self._suggestion_in_flight.add(abs_id) try: - logger.info(f"Found potential new book for suggestion: '{abs_id}'") + logger.info(f"Found potential new detected ABS book: '{abs_id}'") item = self.abs_client.get_item_details(abs_id) if not item: - logger.debug(f"Suggestion failed: Could not get details for {abs_id}") + logger.debug(f"Detected book lookup failed: Could not get details for {abs_id}") return media = item.get("media", {}) @@ -1095,7 +1082,7 @@ def _create_suggestion(self, abs_id, progress_data): title = metadata.get("title") or "" author = metadata.get("authorName") or "" cover = self._cover_url_for("abs", abs_id) - logger.debug(f"Checking suggestions for '{title}' (Author: {author})") + logger.debug(f"Checking detected matches for '{title}' (Author: {author})") progress_percentage = 0.0 if progress_data: @@ -1124,18 +1111,14 @@ def _create_suggestion(self, abs_id, progress_data): matches=matches, ) - suggestion = PendingSuggestion( - source_id=abs_id, title=title, author=author, cover_url=cover, matches_json=json.dumps(matches) - ) - self.database_service.save_pending_suggestion(suggestion) logger.info( - f"Created suggestion for '{title}' with {len(matches)} matches" + f"Created detected entry for '{title}' with {len(matches)} matches" if matches else f"Created detected entry for '{title}' with no matches yet" ) except Exception as e: - logger.error(f"Failed to create suggestion for '{abs_id}': {e}") + logger.error(f"Failed to create detected entry for '{abs_id}': {e}") logger.debug(traceback.format_exc()) finally: with self._suggestion_lock: From 9291c26fc09fc8bff1a98163a64483061e71c385 Mon Sep 17 00:00:00 2001 From: Sarah Wolff Date: Sun, 5 Apr 2026 21:38:53 -0400 Subject: [PATCH 11/14] fix(git): allow origin pushes for feature branches --- .githooks/pre-push | 6 ------ scripts/git/README.md | 41 ++++++++++++++++++++--------------------- 2 files changed, 20 insertions(+), 27 deletions(-) diff --git a/.githooks/pre-push b/.githooks/pre-push index c83328a..a37bb09 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -20,12 +20,6 @@ while read -r local_ref local_sha remote_ref remote_sha; do branch="$(echo "$remote_ref" | sed 's|refs/heads/||')" expected_local_ref="refs/heads/$branch" - if [ "$branch" != "dev" ] && [ "$branch" != "main" ]; then - echo "Blocked: refusing to push '$branch' to origin." >&2 - echo "Push private branches to the 'private' remote instead." >&2 - exit 1 - fi - if [ "$local_ref" != "$expected_local_ref" ]; then echo "Blocked: push local '$local_ref' to matching public branch '$branch' instead of rewriting refs." >&2 exit 1 diff --git a/scripts/git/README.md b/scripts/git/README.md index 2a33eb9..a663075 100644 --- a/scripts/git/README.md +++ b/scripts/git/README.md @@ -5,10 +5,9 @@ flow with repo-owned tooling. ## Branch Roles -- `dev`: public development branch on `origin` -- `main`: public release branch on `origin` -- `draft`: private unpublished branch on `private` -- `private/dev` and `private/main`: mirrors of the public branches +- `dev`: integration branch on `origin` +- `main`: release branch on `origin` +- feature branches: short-lived topic branches on `origin`, typically merged into `dev` ## First-Time Setup @@ -16,41 +15,41 @@ flow with repo-owned tooling. scripts/git/install-hooks.sh scripts/git/sanitize-public-branch.sh dev "chore: strip private-only files from dev" scripts/git/sanitize-public-branch.sh main "chore: strip private-only files from main" -scripts/git/create-draft-branch.sh -git branch --track draft private/draft scripts/git/setup-worktrees.sh ``` -## Public-First Workflow +## Development Workflow 1. Work on `dev` 2. Push to `origin/dev` -3. Sync the private mirror: +3. Open a PR from `dev` to `main` when ready to release -```bash -scripts/git/sync-private-mirrors.sh dev -``` +## Feature Branch Workflow -## Private-First Workflow - -1. Work on `draft` -2. Push to `private/draft` -3. Promote a sanitized snapshot to `dev` +1. Create a branch from `dev` +2. Push the branch to `origin` +3. Open a PR into `dev` ```bash -scripts/git/promote.sh --push draft dev "feat: publish draft snapshot" -scripts/git/sync-private-mirrors.sh dev +git switch dev +git pull --ff-only origin dev +git switch -c my-feature +git push -u origin my-feature ``` ## Release Workflow +1. Merge feature branches into `dev` +2. Push `dev` +3. Open a PR from `dev` to `main` + ```bash -scripts/git/promote.sh --push dev main "release: vX.Y.Z" -scripts/git/sync-private-mirrors.sh main +git push origin dev +gh pr create --base main --head dev ``` ## Safety Checks - `config/private-paths.txt` defines what must never land on public branches -- `.githooks/pre-push` blocks non-public branches from pushing to `origin` +- `.githooks/pre-push` verifies any branch pushed to `origin` and prompts before direct pushes to `main` - `scripts/git/verify-public-tree.sh` validates public branch content From d7d1c42d3e660df27440baa723bd31c538b4c026 Mon Sep 17 00:00:00 2001 From: Sarah Wolff Date: Sun, 5 Apr 2026 21:40:25 -0400 Subject: [PATCH 12/14] refactor(git): remove obsolete private workflow scripts --- scripts/git/create-draft-branch.sh | 76 ---------------------- scripts/git/setup-worktrees.sh | 7 +-- scripts/git/sync-private-mirrors.sh | 98 ----------------------------- 3 files changed, 1 insertion(+), 180 deletions(-) delete mode 100755 scripts/git/create-draft-branch.sh delete mode 100755 scripts/git/sync-private-mirrors.sh diff --git a/scripts/git/create-draft-branch.sh b/scripts/git/create-draft-branch.sh deleted file mode 100755 index d3c965a..0000000 --- a/scripts/git/create-draft-branch.sh +++ /dev/null @@ -1,76 +0,0 @@ -#!/bin/sh - -set -eu - -repo_root="$(git rev-parse --show-toplevel)" -archive_legacy=1 -archive_name="" - -usage() { - cat <<'EOF' -Usage: - scripts/git/create-draft-branch.sh [--no-archive] [--archive-name ] - -Creates private/draft from private/dev and optionally archives private/dev-private. -EOF -} - -while [ $# -gt 0 ]; do - case "$1" in - --no-archive) - archive_legacy=0 - shift - ;; - --archive-name) - archive_name="${2-}" - [ -n "$archive_name" ] || { - usage >&2 - exit 1 - } - shift 2 - ;; - -h|--help) - usage - exit 0 - ;; - *) - usage >&2 - exit 1 - ;; - esac -done - -cd "$repo_root" - -git remote get-url private >/dev/null 2>&1 || { - echo "Missing private remote." >&2 - exit 1 -} - -git fetch private --prune >/dev/null 2>&1 - -git rev-parse --verify refs/remotes/private/dev^{commit} >/dev/null 2>&1 || { - echo "Missing private/dev. Sync the private mirror first." >&2 - exit 1 -} - -if git show-ref --verify --quiet refs/remotes/private/draft; then - echo "private/draft already exists." - exit 0 -fi - -draft_sha="$(git rev-parse refs/remotes/private/dev^{commit})" -git push private "$draft_sha:refs/heads/draft" -echo "Created private/draft from private/dev at $draft_sha" - -if [ "$archive_legacy" -eq 1 ] && \ - git show-ref --verify --quiet refs/remotes/private/dev-private; then - if [ -z "$archive_name" ]; then - archive_name="archive/dev-private-$(date +%Y-%m-%d)" - fi - legacy_sha="$(git rev-parse refs/remotes/private/dev-private^{commit})" - git push private "$legacy_sha:refs/heads/$archive_name" - echo "Archived private/dev-private to private/$archive_name" -fi - -echo "Next step: git branch --track draft private/draft" diff --git a/scripts/git/setup-worktrees.sh b/scripts/git/setup-worktrees.sh index 79cedb4..d21af3e 100755 --- a/scripts/git/setup-worktrees.sh +++ b/scripts/git/setup-worktrees.sh @@ -8,11 +8,6 @@ repo_name="$(basename "$repo_root")" cd "$repo_root" -if ! git show-ref --verify --quiet refs/heads/draft && \ - git show-ref --verify --quiet refs/remotes/private/draft; then - git branch --track draft private/draft >/dev/null 2>&1 || true -fi - active_branches="$(git worktree list --porcelain | awk '/^branch / {sub("^refs/heads/","",$2); print $2}')" add_worktree() { @@ -37,5 +32,5 @@ add_worktree() { git worktree add "$target_dir" "$branch" } -add_worktree draft "$parent_dir/$repo_name-draft" +add_worktree dev "$parent_dir/$repo_name-dev" add_worktree main "$parent_dir/$repo_name-main" diff --git a/scripts/git/sync-private-mirrors.sh b/scripts/git/sync-private-mirrors.sh deleted file mode 100755 index 0105d49..0000000 --- a/scripts/git/sync-private-mirrors.sh +++ /dev/null @@ -1,98 +0,0 @@ -#!/bin/sh - -set -eu - -repo_root="$(git rev-parse --show-toplevel)" -force=0 -target="${1-}" - -usage() { - cat <<'EOF' -Usage: - scripts/git/sync-private-mirrors.sh dev - scripts/git/sync-private-mirrors.sh main - scripts/git/sync-private-mirrors.sh --all - scripts/git/sync-private-mirrors.sh --force --all -EOF -} - -while [ $# -gt 0 ]; do - case "$1" in - --all) - target="all" - shift - ;; - --force) - force=1 - shift - ;; - -h|--help) - usage - exit 0 - ;; - dev|main) - target="$1" - shift - ;; - *) - usage >&2 - exit 1 - ;; - esac -done - -[ -n "$target" ] || { - usage >&2 - exit 1 -} - -cd "$repo_root" - -git remote get-url origin >/dev/null 2>&1 || { - echo "Missing origin remote." >&2 - exit 1 -} - -git remote get-url private >/dev/null 2>&1 || { - echo "Missing private remote." >&2 - exit 1 -} - -git fetch origin --prune >/dev/null 2>&1 -git fetch private --prune >/dev/null 2>&1 - -sync_branch() { - branch="$1" - origin_ref="refs/remotes/origin/$branch" - private_ref="refs/remotes/private/$branch" - - git rev-parse --verify "$origin_ref^{commit}" >/dev/null 2>&1 || { - echo "Missing origin branch: $branch" >&2 - exit 1 - } - - origin_sha="$(git rev-parse "$origin_ref^{commit}")" - - if git show-ref --verify --quiet "$private_ref"; then - private_sha="$(git rev-parse "$private_ref^{commit}")" - if [ "$origin_sha" = "$private_sha" ]; then - echo "private/$branch already matches origin/$branch" - return - fi - fi - - if [ "$force" -eq 1 ]; then - git push private --force-with-lease="refs/heads/$branch" \ - "$origin_sha:refs/heads/$branch" - else - git push private "$origin_sha:refs/heads/$branch" - fi -} - -if [ "$target" = "all" ]; then - sync_branch dev - sync_branch main - exit 0 -fi - -sync_branch "$target" From df353f2f120747b578b3ed5d21a325ad4b588d09 Mon Sep 17 00:00:00 2001 From: Sarah Wolff Date: Sun, 5 Apr 2026 21:41:19 -0400 Subject: [PATCH 13/14] docs(git): update promote script examples --- scripts/git/promote.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/git/promote.sh b/scripts/git/promote.sh index 0668cdf..320cdfd 100755 --- a/scripts/git/promote.sh +++ b/scripts/git/promote.sh @@ -13,7 +13,7 @@ Usage: scripts/git/promote.sh [--push] [commit message] Examples: - scripts/git/promote.sh draft dev "feat: publish draft snapshot" + scripts/git/promote.sh feature/my-change dev "feat: publish feature snapshot" scripts/git/promote.sh --push dev main "release: v0.2.0" EOF } From db1816e99f8357759f8a5d5b3230ae97d505416f Mon Sep 17 00:00:00 2001 From: Sarah Wolff Date: Sun, 5 Apr 2026 21:43:25 -0400 Subject: [PATCH 14/14] fix(lint): resolve Ruff failures --- .../r5s6t7u8v9w0_remove_bookfusion_matched_abs_id.py | 1 + alembic/versions/t9u0v1w2x3y4_merge_detected_books_head.py | 5 +---- src/blueprints/api.py | 1 - src/blueprints/dashboard.py | 3 --- src/db/database_service.py | 7 ++----- src/sync_manager.py | 5 +++-- tests/test_database_service_integration.py | 5 ----- 7 files changed, 7 insertions(+), 20 deletions(-) diff --git a/alembic/versions/r5s6t7u8v9w0_remove_bookfusion_matched_abs_id.py b/alembic/versions/r5s6t7u8v9w0_remove_bookfusion_matched_abs_id.py index ad123ab..715df72 100644 --- a/alembic/versions/r5s6t7u8v9w0_remove_bookfusion_matched_abs_id.py +++ b/alembic/versions/r5s6t7u8v9w0_remove_bookfusion_matched_abs_id.py @@ -6,6 +6,7 @@ """ import sqlalchemy as sa + from alembic import op revision = "r5s6t7u8v9w0" diff --git a/alembic/versions/t9u0v1w2x3y4_merge_detected_books_head.py b/alembic/versions/t9u0v1w2x3y4_merge_detected_books_head.py index fe20f5a..5de936b 100644 --- a/alembic/versions/t9u0v1w2x3y4_merge_detected_books_head.py +++ b/alembic/versions/t9u0v1w2x3y4_merge_detected_books_head.py @@ -5,11 +5,8 @@ Create Date: 2026-04-05 """ -from typing import Sequence - - revision: str = "t9u0v1w2x3y4" -down_revision: Sequence[str] = ("5308a8e2c930", "s1t2u3v4w5x6") +down_revision = ("5308a8e2c930", "s1t2u3v4w5x6") branch_labels = None depends_on = None diff --git a/src/blueprints/api.py b/src/blueprints/api.py index 0703f7c..b6df694 100644 --- a/src/blueprints/api.py +++ b/src/blueprints/api.py @@ -15,7 +15,6 @@ get_grimmory_client, get_kosync_id_for_ebook, ) -from src.db.models import Book logger = logging.getLogger(__name__) diff --git a/src/blueprints/dashboard.py b/src/blueprints/dashboard.py index d9f81fb..bc41517 100644 --- a/src/blueprints/dashboard.py +++ b/src/blueprints/dashboard.py @@ -11,12 +11,9 @@ from src.blueprints.helpers import ( find_grimmory_metadata, get_abs_service, - get_book_or_404, get_container, get_database_service, get_enabled_grimmory_server_ids, - get_grimmory_client, - serialize_detected_book, ) from src.utils.cover_resolver import resolve_book_covers from src.utils.service_url_helper import get_hardcover_book_url, get_service_web_url diff --git a/src/db/database_service.py b/src/db/database_service.py index e08f9b1..f2d2feb 100644 --- a/src/db/database_service.py +++ b/src/db/database_service.py @@ -9,15 +9,12 @@ from pathlib import Path from .book_repository import BookRepository -from .detected_repository import DetectedRepository from .bookfusion_repository import BookFusionRepository +from .detected_repository import DetectedRepository from .grimmory_repository import GrimmoryRepository from .hardcover_repository import HardcoverRepository from .kosync_repository import KoSyncRepository -from .models import ( - Base, - DatabaseManager, -) +from .models import Base, DatabaseManager from .reading_repository import VALID_JOURNAL_EVENTS, ReadingRepository from .settings_repository import SettingsRepository from .storyteller_repository import StorytellerRepository diff --git a/src/sync_manager.py b/src/sync_manager.py index 2c27c66..4bfcc44 100644 --- a/src/sync_manager.py +++ b/src/sync_manager.py @@ -155,13 +155,14 @@ def _setup_sync_clients(self, clients: dict[str, SyncClient]): def startup_checks(self): # Check configured sync clients for client_name, client in (self.sync_clients or {}).items(): + first_err = RuntimeError("unknown startup check failure") try: if client.check_connection(): logger.info(f"'{client_name}' connection verified") continue first_err = RuntimeError("check_connection() returned False") - except Exception as first_err: - pass + except Exception as e: + first_err = e time.sleep(2) try: diff --git a/tests/test_database_service_integration.py b/tests/test_database_service_integration.py index b7f95f0..aa2b9a3 100644 --- a/tests/test_database_service_integration.py +++ b/tests/test_database_service_integration.py @@ -125,11 +125,6 @@ def test_resolve_detected_book_scoped_by_source(self): self.assertEqual(resolved.status, "resolved") self.assertEqual(still_active.status, "detected") - # Verify book can be retrieved - retrieved_book = self.db_service.get_book_by_abs_id(test_abs_id) - self.assertIsNotNone(retrieved_book) - self.assertEqual(retrieved_book.abs_id, test_abs_id) - def test_delete_book(self): """Test deleting a book record with cascading deletes for states and hardcover details.""" test_abs_id = "test-book-delete"