From 155fffbe74b5c7d4c29628b03d60d4d38ef4df32 Mon Sep 17 00:00:00 2001 From: Brandon Peterman Date: Sun, 15 Mar 2026 17:08:52 -0500 Subject: [PATCH 1/3] Annots browser: Add grouping options Addresses one of the suggestions in #1897359 --- src/calibre/gui2/library/annotations.py | 191 ++++++++++++++++++++---- 1 file changed, 160 insertions(+), 31 deletions(-) diff --git a/src/calibre/gui2/library/annotations.py b/src/calibre/gui2/library/annotations.py index 1e0bf5e0ef..608d021f3d 100644 --- a/src/calibre/gui2/library/annotations.py +++ b/src/calibre/gui2/library/annotations.py @@ -7,6 +7,7 @@ import os import re from functools import lru_cache, partial from urllib.parse import quote +from enum import StrEnum from qt.core import ( QAbstractItemView, @@ -47,6 +48,7 @@ from calibre.db.backend import FTSQueryError from calibre.ebooks.metadata import authors_to_string, fmt_sidx from calibre.gui2 import Application, choose_save_file, config, error_dialog, gprefs, is_dark_theme, safe_open_url from calibre.gui2.dialogs.confirm_delete import confirm +from calibre.gui2.viewer.highlights import decoration_for_style from calibre.gui2.viewer.widgets import ResultsDelegate, SearchBox from calibre.gui2.widgets import BusyCursor from calibre.gui2.widgets2 import Dialog, RightClickButton @@ -377,6 +379,12 @@ def current_db(): return (getattr(current_db, 'ans', None) or get_gui().current_db).new_api +class Group(StrEnum): + BOOK_ID = "Title" + AUTHOR = "Author" + USER = "User" + DATE = "Date" + class ResultsList(QTreeWidget): current_result_changed = pyqtSignal(object) @@ -442,40 +450,32 @@ class ResultsList(QTreeWidget): if isinstance(r, dict): self.open_annotation.emit(r['book_id'], r['format'], r['annotation']) - def set_results(self, results, emphasize_text): - from calibre.gui2.viewer.highlights import decoration_for_style - is_dark = is_dark_theme() - dpr = self.devicePixelRatioF() + def set_results(self, results, emphasize_text, group_order=(Group.BOOK_ID,)): self.clear() self.delegate.emphasize_text = emphasize_text self.number_of_results = 0 self.item_map = [] - book_id_map = {} - db = current_db() + + # Ensure deepest level is always Book/Title + if not group_order: + group_order = (Group.BOOK_ID,) + if group_order[-1] != Group.BOOK_ID: + group_order = tuple(group_order) + (Group.BOOK_ID,) + + tree = {} for result in results: - book_id = result['book_id'] - if book_id not in book_id_map: - book_id_map[book_id] = {'title': db.field_for('title', book_id), 'matches': []} - book_id_map[book_id]['matches'].append(result) - for book_id, entry in book_id_map.items(): - section = QTreeWidgetItem([entry['title']], 1) - section.setFlags(Qt.ItemFlag.ItemIsEnabled) - section.setFont(0, self.section_font) - section.setData(0, Qt.ItemDataRole.UserRole, book_id) - self.addTopLevelItem(section) - section.setExpanded(True) - for result in sorted_items(entry['matches']): - item = QTreeWidgetItem(section, [' '], 2) - self.item_map.append(item) - item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemNeverHasChildren) - item.setData(0, Qt.ItemDataRole.UserRole, result) - item.setData(0, Qt.ItemDataRole.UserRole + 1, self.number_of_results) - self.number_of_results += 1 - a = result.get('annotation') - if a and (s := a.get('style')): - dec = decoration_for_style(self.palette(), s, self.icon_size, dpr, is_dark) - if dec: - item.setData(0, Qt.ItemDataRole.DecorationRole, dec) + node = tree + entry = None + for g in group_order: + key, label = self.grouping_key(result, g) + if key not in node: + node[key] = {'label': label, 'children': {}, 'results': []} + entry = node[key] + node = entry['children'] + if entry is not None: + entry['results'].append(result) + + self.add_children(None, tree) if self.item_map: self.setCurrentItem(self.item_map[0]) @@ -495,6 +495,74 @@ class ResultsList(QTreeWidget): i += -1 if backwards else 1 i %= self.number_of_results self.setCurrentItem(self.item_map[i]) + + def grouping_key(self, result, group_type): + ''' + Given an annotation and group type, return an association from + the result to a localized value in that group. + ''' + db = current_db() + bid = result['book_id'] + if group_type == Group.BOOK_ID: + title = db.field_for('title', bid) + return (title.lower(), bid), title + if group_type == Group.AUTHOR: + authors = db.field_for('authors', bid) + text = authors_to_string(authors) + return (text.lower(), text), text or _('Unknown author') + if group_type == Group.USER: + user = friendly_username(result['user_type'], result['user']) + return (user.lower(), user), user + if group_type == Group.DATE: + # Using QDateTime is a little awkward here, as it's never displayed, + # but it contains a bunch of helpful tools for localization. + ts = result['annotation'].get('timestamp') + qdt = QDateTime.fromString(ts, Qt.DateFormat.ISODate) + if qdt.isValid(): + # Bucket the timestamps by discrete day, using the system's local timezone + qdt = qdt.toLocalTime() + qdate = qdt.date() + jd = qdate.toJulianDay() + else: + qdate = QDateTime.currentDateTime().date() + jd = qdate.toJulianDay() + loc = QLocale.system() + label = loc.toString(qdate, loc.dateFormat(QLocale.FormatType.ShortFormat)) + if not label: + label = _('Unknown date') + return (jd, label), label + return (result['id'],), _('Other') + + def add_children(self, parent_item, children): + ''' + Create child items under a parent of the annotations tree. + ''' + is_dark = is_dark_theme() + dpr = self.devicePixelRatioF() + for key, entry in sorted(children.items(), key=lambda kv: kv[0]): + item = QTreeWidgetItem([entry['label']], 1) + item.setFlags(Qt.ItemFlag.ItemIsEnabled) + item.setFont(0, self.section_font) + item.setData(0, Qt.ItemDataRole.UserRole, key) + if parent_item is None: + self.addTopLevelItem(item) + else: + parent_item.addChild(item) + item.setExpanded(True) + if entry['children']: + self.add_children(item, entry['children']) + for result in sorted_items(entry['results']): + res_item = QTreeWidgetItem(item, [' '], 2) + self.item_map.append(res_item) + res_item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemNeverHasChildren) + res_item.setData(0, Qt.ItemDataRole.UserRole, result) + res_item.setData(0, Qt.ItemDataRole.UserRole + 1, self.number_of_results) + self.number_of_results += 1 + a = result.get('annotation') + if a and (s := a.get('style')): + dec = decoration_for_style(self.palette(), s, self.icon_size, dpr, is_dark) + if dec: + res_item.setData(0, Qt.ItemDataRole.DecorationRole, dec) @property def selected_annot_ids(self): @@ -639,7 +707,6 @@ class Restrictions(QWidget): # Append highlight colors after the 'highlight' entry, if it exists highlight_row = tb.findData({'type': 'highlight'}) if highlight_row > -1: - from calibre.gui2.viewer.highlights import decoration_for_style dpr = self.devicePixelRatioF() is_dark = is_dark_theme() model = tb.model() @@ -678,6 +745,59 @@ class Restrictions(QWidget): self.rla.setVisible(tb_is_visible or ub_is_visible) self.setVisible(True) +class GroupOptions(QWidget): + grouping_changed = pyqtSignal() + + def __init__(self, parent): + QWidget.__init__(self, parent) + v = QVBoxLayout(self) + v.setContentsMargins(0, 0, 0, 0) + h = QHBoxLayout() + h.setContentsMargins(0, 0, 0, 0) + v.addLayout(h) + la = QLabel(_('Group by:')) + h.addWidget(la) + self.group_box = gb = QComboBox(self) + gb.currentIndexChanged.connect(self.grouping_changed) + connect_lambda(gb.currentIndexChanged, gb, lambda gb: gprefs.set('browse_annots_group_by', gb.currentData())) + la.setBuddy(gb) + gb.setToolTip(_('Display annotations grouped by this value')) + gb.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToContents) + h.addWidget(gb) + h.addStretch(10) + + @property + def selected_group(self): + data = self.group_box.currentData() + if isinstance(data, dict): + # Backwards compatibility with previous storage format + data = next(iter(data.values())) if data else None + return data or Group.BOOK_ID + + @property + def group_order(self): + # Ensure final level is always Book/Title + order = [self.selected_group] + if order[-1] != Group.BOOK_ID: + order.append(Group.BOOK_ID) + return tuple(order) + + def re_initialize(self): + gb = self.group_box + before = gb.currentData() + if not before: + before = gprefs.get('browse_annots_group_by', Group.BOOK_ID) + if isinstance(before, dict): + before = next(iter(before.values())) if before else None + gb.blockSignals(True) + gb.clear() + for group_type in Group: + gb.addItem(_(group_type), group_type) + if before: + row = gb.findData(before) + if row > -1: + gb.setCurrentIndex(row) + gb.blockSignals(False) class BrowsePanel(QWidget): @@ -722,6 +842,10 @@ class BrowsePanel(QWidget): self.use_stemmer.stateChanged.connect(self.effective_query_changed) l.addWidget(rs) + self.group_options = grp = GroupOptions(self) + grp.grouping_changed.connect(self.grouping_changed) + l.addWidget(grp) + self.results_list = rl = ResultsList(self) rl.current_result_changed.connect(self.current_result_changed) rl.open_annotation.connect(self.open_annotation) @@ -735,12 +859,16 @@ class BrowsePanel(QWidget): db = current_db() self.search_box.setFocus(Qt.FocusReason.OtherFocusReason) self.restrictions.re_initialize(db, restrict_to_book_ids or set()) + self.group_options.re_initialize() self.current_query = None self.results_list.clear() def selection_changed(self, restrict_to_book_ids): self.restrictions.selection_changed(restrict_to_book_ids) + def grouping_changed(self): + self.refresh() + def sizeHint(self): return QSize(450, 600) @@ -792,7 +920,8 @@ class BrowsePanel(QWidget): highlight_start='\x1d', highlight_end='\x1d', snippet_size=64, ignore_removed=True, **q2 ) - self.results_list.set_results(results, bool(q['fts_engine_query'])) + group_order = getattr(self.group_options, 'group_order', (Group.BOOK_ID,)) + self.results_list.set_results(results, bool(q['fts_engine_query']), group_order=group_order) self.current_query = q except FTSQueryError as err: return error_dialog(self, _('Invalid search expression'), '

' + _( From 806b433a6ff518888fde9f8c0c2ae061a882c1d6 Mon Sep 17 00:00:00 2001 From: Brandon Peterman Date: Sun, 15 Mar 2026 17:13:02 -0500 Subject: [PATCH 2/3] Address circular dependency when importing decoration_for_style --- src/calibre/gui2/library/annotations.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/calibre/gui2/library/annotations.py b/src/calibre/gui2/library/annotations.py index 608d021f3d..411c07d300 100644 --- a/src/calibre/gui2/library/annotations.py +++ b/src/calibre/gui2/library/annotations.py @@ -48,7 +48,6 @@ from calibre.db.backend import FTSQueryError from calibre.ebooks.metadata import authors_to_string, fmt_sidx from calibre.gui2 import Application, choose_save_file, config, error_dialog, gprefs, is_dark_theme, safe_open_url from calibre.gui2.dialogs.confirm_delete import confirm -from calibre.gui2.viewer.highlights import decoration_for_style from calibre.gui2.viewer.widgets import ResultsDelegate, SearchBox from calibre.gui2.widgets import BusyCursor from calibre.gui2.widgets2 import Dialog, RightClickButton @@ -537,6 +536,7 @@ class ResultsList(QTreeWidget): ''' Create child items under a parent of the annotations tree. ''' + from calibre.gui2.viewer.highlights import decoration_for_style is_dark = is_dark_theme() dpr = self.devicePixelRatioF() for key, entry in sorted(children.items(), key=lambda kv: kv[0]): @@ -707,6 +707,7 @@ class Restrictions(QWidget): # Append highlight colors after the 'highlight' entry, if it exists highlight_row = tb.findData({'type': 'highlight'}) if highlight_row > -1: + from calibre.gui2.viewer.highlights import decoration_for_style dpr = self.devicePixelRatioF() is_dark = is_dark_theme() model = tb.model() From eb97e8c3e6ccd9e70d886858b8f4b68a59a5c06c Mon Sep 17 00:00:00 2001 From: Brandon Peterman Date: Fri, 27 Mar 2026 11:41:47 -0500 Subject: [PATCH 3/3] Annots browser: Refactor grouping to better match existing patterns --- src/calibre/gui2/__init__.py | 1 + src/calibre/gui2/library/annotations.py | 177 ++++++++++----- src/calibre/gui2/library/test_annotations.py | 213 +++++++++++++++++++ src/calibre/utils/run_tests.py | 2 + 4 files changed, 339 insertions(+), 54 deletions(-) create mode 100644 src/calibre/gui2/library/test_annotations.py diff --git a/src/calibre/gui2/__init__.py b/src/calibre/gui2/__init__.py index 2880a1cfe3..04f47a08f0 100644 --- a/src/calibre/gui2/__init__.py +++ b/src/calibre/gui2/__init__.py @@ -491,6 +491,7 @@ def create_defs(): defs['browse_annots_restrict_to_type'] = None defs['browse_annots_use_stemmer'] = True defs['browse_notes_use_stemmer'] = True + defs['browse_annots_group_by'] = None defs['fts_library_use_stemmer'] = True defs['fts_library_restrict_books'] = False defs['fts_visualisation'] = 'cards' diff --git a/src/calibre/gui2/library/annotations.py b/src/calibre/gui2/library/annotations.py index 411c07d300..775fdf47b7 100644 --- a/src/calibre/gui2/library/annotations.py +++ b/src/calibre/gui2/library/annotations.py @@ -7,7 +7,7 @@ import os import re from functools import lru_cache, partial from urllib.parse import quote -from enum import StrEnum +from enum import Enum, auto from qt.core import ( QAbstractItemView, @@ -45,7 +45,7 @@ from qt.core import ( from calibre import prepare_string_for_xml from calibre.constants import builtin_colors_dark, builtin_colors_light, builtin_decorations from calibre.db.backend import FTSQueryError -from calibre.ebooks.metadata import authors_to_string, fmt_sidx +from calibre.ebooks.metadata import authors_to_sort_string, authors_to_string, fmt_sidx from calibre.gui2 import Application, choose_save_file, config, error_dialog, gprefs, is_dark_theme, safe_open_url from calibre.gui2.dialogs.confirm_delete import confirm from calibre.gui2.viewer.widgets import ResultsDelegate, SearchBox @@ -378,11 +378,120 @@ def current_db(): return (getattr(current_db, 'ans', None) or get_gui().current_db).new_api -class Group(StrEnum): - BOOK_ID = "Title" - AUTHOR = "Author" - USER = "User" - DATE = "Date" +class Group(Enum): + BOOK_ID = auto() + AUTHOR = auto() + USER = auto() + DATE = auto() + + @property + def field_name(self) -> str: + '''The underlying annotation or book field this grouping applies to.''' + return GROUP_INFO[self]['field_name'] + + @property + def display_name(self) -> str: + '''A localized human-readable name for this grouping.''' + return GROUP_INFO[self]['display_name'] + +GROUP_INFO: dict[Group, dict[str, str]] = { + Group.BOOK_ID: { + 'field_name': 'title', + 'display_name': _('Title'), + }, + Group.AUTHOR: { + 'field_name': 'authors', + 'display_name': _('Author'), + }, + Group.USER: { + 'field_name': 'user', + 'display_name': _('User'), + }, + Group.DATE: { + 'field_name': 'timestamp', + 'display_name': _('Date'), + }, +} + +def get_annotation_value(annotation, bid, field, db): + ''' + Get the value for a field from an annotation result, checking + annotation-level fields first and falling back to book metadata. + ''' + val = annotation.get(field) + if val is None: + val = annotation.get('annotation', {}).get(field) + if val is None: + val = db.field_for(field, bid) + return val + +def get_group_key(result, field, db): + ''' + Return (sort_key, display_label) for an annotation result grouped by field. + + field may be a Group enum member or any string naming a field in the + annotation row (e.g. 'format', 'annot_type', 'user', 'timestamp') or a + book metadata field accessible via db.field_for (e.g. 'title', 'authors'). + + sort_key is a tuple suitable for use as a dict key and for natural ordering. + display_label is a localized human-readable string for the group header. + ''' + if isinstance(field, Group): + field = field.field_name + + fm = db.field_metadata + dt = fm.get(field, {}).get('datatype') + bid = result['book_id'] + + match field: + case 'title': + title = db.field_for('title', bid) + return (title.lower(), bid), title + case 'authors': + authors = db.field_for('authors', bid) + sort_key = authors_to_sort_string(authors) + text = authors_to_string(authors) + return (sort_key, text), text or _('Unknown author') + case 'user': + user = friendly_username(result['user_type'], result['user']) + return (user.lower(), user), user + case field if dt == 'datetime': + ts = get_annotation_value(result, bid, field, db) + df = fm[field].get('display', {}).get('date_format') or 'dd MMM yyyy' + if 'd' in df: + qdt = QDateTime.fromString(ts, Qt.DateFormat.ISODate) + if qdt.isValid(): + # Bucket the timestamps by discrete day, using the system's local timezone + qdt = qdt.toLocalTime() + qdate = qdt.date() + jd = qdate.toJulianDay() + else: + qdate = QDateTime.currentDateTime().date() + jd = qdate.toJulianDay() + # Store the number of days in the past so we get the natural sort order + today = QDateTime.currentDateTime().toLocalTime().date() + days_past = today.toJulianDay() - jd + loc = QLocale.system() + label = loc.toString(qdate, loc.dateFormat(QLocale.FormatType.ShortFormat)) + if not label: + label = _('Unknown date') + return (days_past, label), label + else: # Assume it's a year + year = QDateTime.fromString(ts, Qt.DateFormat.ISODate).date().year() + current_year = QDateTime.currentDateTime().date().year() + years_past = current_year - year + label = str(year) if year > 0 else _('Unknown year') + return (years_past, label), label + + # Generic fallback + val = get_annotation_value(result, bid, field, db) + if not val: + return ('',), _('Unknown') + sort_key = val + if dt == 'text': + sort_key = val.lower() + label = str(val) + return (sort_key, label), label class ResultsList(QTreeWidget): @@ -461,12 +570,14 @@ class ResultsList(QTreeWidget): if group_order[-1] != Group.BOOK_ID: group_order = tuple(group_order) + (Group.BOOK_ID,) + db = current_db() + tree = {} for result in results: node = tree entry = None for g in group_order: - key, label = self.grouping_key(result, g) + key, label = get_group_key(result, g, db) if key not in node: node[key] = {'label': label, 'children': {}, 'results': []} entry = node[key] @@ -494,43 +605,6 @@ class ResultsList(QTreeWidget): i += -1 if backwards else 1 i %= self.number_of_results self.setCurrentItem(self.item_map[i]) - - def grouping_key(self, result, group_type): - ''' - Given an annotation and group type, return an association from - the result to a localized value in that group. - ''' - db = current_db() - bid = result['book_id'] - if group_type == Group.BOOK_ID: - title = db.field_for('title', bid) - return (title.lower(), bid), title - if group_type == Group.AUTHOR: - authors = db.field_for('authors', bid) - text = authors_to_string(authors) - return (text.lower(), text), text or _('Unknown author') - if group_type == Group.USER: - user = friendly_username(result['user_type'], result['user']) - return (user.lower(), user), user - if group_type == Group.DATE: - # Using QDateTime is a little awkward here, as it's never displayed, - # but it contains a bunch of helpful tools for localization. - ts = result['annotation'].get('timestamp') - qdt = QDateTime.fromString(ts, Qt.DateFormat.ISODate) - if qdt.isValid(): - # Bucket the timestamps by discrete day, using the system's local timezone - qdt = qdt.toLocalTime() - qdate = qdt.date() - jd = qdate.toJulianDay() - else: - qdate = QDateTime.currentDateTime().date() - jd = qdate.toJulianDay() - loc = QLocale.system() - label = loc.toString(qdate, loc.dateFormat(QLocale.FormatType.ShortFormat)) - if not label: - label = _('Unknown date') - return (jd, label), label - return (result['id'],), _('Other') def add_children(self, parent_item, children): ''' @@ -756,11 +830,11 @@ class GroupOptions(QWidget): h = QHBoxLayout() h.setContentsMargins(0, 0, 0, 0) v.addLayout(h) - la = QLabel(_('Group by:')) + la = QLabel(_('&Group by:')) h.addWidget(la) self.group_box = gb = QComboBox(self) gb.currentIndexChanged.connect(self.grouping_changed) - connect_lambda(gb.currentIndexChanged, gb, lambda gb: gprefs.set('browse_annots_group_by', gb.currentData())) + connect_lambda(gb.currentIndexChanged, gb, lambda gb: gprefs.set('browse_annots_group_by', gb.currentData().name)) la.setBuddy(gb) gb.setToolTip(_('Display annotations grouped by this value')) gb.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToContents) @@ -770,9 +844,6 @@ class GroupOptions(QWidget): @property def selected_group(self): data = self.group_box.currentData() - if isinstance(data, dict): - # Backwards compatibility with previous storage format - data = next(iter(data.values())) if data else None return data or Group.BOOK_ID @property @@ -787,13 +858,11 @@ class GroupOptions(QWidget): gb = self.group_box before = gb.currentData() if not before: - before = gprefs.get('browse_annots_group_by', Group.BOOK_ID) - if isinstance(before, dict): - before = next(iter(before.values())) if before else None + before = Group[gprefs.get('browse_annots_group_by', Group.BOOK_ID.name)] gb.blockSignals(True) gb.clear() for group_type in Group: - gb.addItem(_(group_type), group_type) + gb.addItem(group_type.display_name, group_type) if before: row = gb.findData(before) if row > -1: diff --git a/src/calibre/gui2/library/test_annotations.py b/src/calibre/gui2/library/test_annotations.py new file mode 100644 index 0000000000..b4b527b5ad --- /dev/null +++ b/src/calibre/gui2/library/test_annotations.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python +# License: GPL v3 Copyright: 2026, calibre contributors + +import unittest +from unittest.mock import MagicMock + +from calibre.gui2.library.annotations import get_group_key + + +def _make_result(book_id=1, annot_id=1, **extra): + return { + 'book_id': book_id, + 'id': annot_id, + 'format': 'EPUB', + 'user_type': 'local', + 'user': 'viewer', + 'annotation': {}, + **extra, + } + + +def _make_mock_db(field_for_map=None, field_metadata=None): + ''' + Build a lightweight mock that satisfies grouping_key's db access: + - db.field_for(field, bid): returns values from field_for_map + - db.field_metadata: plain dict + ''' + db = MagicMock() + d = field_for_map or {} + + def _field_for(field, bid): + if (field, bid) in d: + return d[(field, bid)] + return d.get(field) + + db.field_for.side_effect = _field_for + db.field_metadata = field_metadata or {} + return db + +class GroupKeyTest(unittest.TestCase): + + def test_pubdate_year_buckets_without_day_in_format(self): + from qt.core import QDateTime, Qt + + pub_iso = '2005-03-01T00:00:00.000Z' + db = _make_mock_db( + field_for_map={'pubdate': pub_iso}, + field_metadata={ + 'pubdate': { + 'datatype': 'datetime', + 'display': {'date_format': 'MMM yyyy'}, + } + }, + ) + (sort_key, label) = get_group_key(_make_result(), 'pubdate', db) + + expected_year = QDateTime.fromString(pub_iso, Qt.DateFormat.ISODate).date().year() + current_year = QDateTime.currentDateTime().date().year() + + self.assertEqual(label, str(expected_year), "Expected label to be the year string") + self.assertEqual(sort_key, (current_year - expected_year, label), "Expected sort key to be (years_past, label)") + + def test_pubdate_unknown_year_when_invalid_timestamp(self): + db = _make_mock_db( + field_for_map={'pubdate': 'not-a-date'}, + field_metadata={ + 'pubdate': { + 'datatype': 'datetime', + 'display': {'date_format': 'MMM yyyy'}, + } + }, + ) + (_, label) = get_group_key(_make_result(), 'pubdate', db) + + # year() returns 0 for an invalid QDate; the code treats 0 as "unknown" + self.assertIn('Unknown', label, "Expected label to indicate unknown year") + + def test_arbitrary_text_field_sorts_case_insensitively(self): + db = _make_mock_db( + field_for_map={'publisher': 'Tor Books'}, + field_metadata={'publisher': {'datatype': 'text'}}, + ) + (sort_key, label) = get_group_key(_make_result(), 'publisher', db) + + self.assertEqual(label, 'Tor Books') + self.assertEqual(sort_key, ('tor books', 'Tor Books')) + + def test_arbitrary_float_field_uses_raw_value_as_sort_key(self): + db = _make_mock_db( + field_for_map={'series_index': 4.5}, + field_metadata={'series_index': {'datatype': 'float'}}, + ) + (sort_key, label) = get_group_key(_make_result(), 'series_index', db) + + self.assertEqual(label, '4.5') + self.assertEqual(sort_key, (4.5, '4.5')) + + def test_missing_value_returns_unknown_sentinel(self): + db = _make_mock_db( + field_for_map={'publisher': None}, + field_metadata={'publisher': {'datatype': 'text'}}, + ) + (sort_key, _) = get_group_key(_make_result(), 'publisher', db) + + self.assertEqual(sort_key, ('',), "Expected sort key to be the unknown sentinel") + + def test_annotation_level_field_read_from_result_dict(self): + db = _make_mock_db(field_metadata={'format': {'datatype': 'text'}}) + result = _make_result(format='PDF') + (_, label) = get_group_key(result, 'format', db) + + db.field_for.assert_not_called() + self.assertEqual(label, 'PDF') + + def test_group_enum_resolves_via_field_name_property(self): + from calibre.gui2.library.annotations import Group + + db = _make_mock_db( + field_for_map={'title': 'Dune'}, + field_metadata={}, + ) + (sort_key_enum, label_enum) = get_group_key(_make_result(), Group.BOOK_ID, db) + (sort_key_str, label_str) = get_group_key(_make_result(), 'title', db) + + self.assertEqual(label_enum, label_str) + self.assertEqual(sort_key_enum, sort_key_str) + + def test_group_by_book_id_title(self): + db = _make_mock_db( + field_for_map={'title': 'The Great Gatsby'}, + field_metadata={}, + ) + result = _make_result(book_id=42) + (sort_key, label) = get_group_key(result, 'title', db) + + self.assertEqual(label, 'The Great Gatsby') + self.assertEqual(sort_key, ('the great gatsby', 42)) + + def test_group_by_authors(self): + db = _make_mock_db( + field_for_map={'authors': ('F. Scott Fitzgerald',)}, + field_metadata={}, + ) + (sort_key, label) = get_group_key(_make_result(), 'authors', db) + + self.assertEqual(label, 'F. Scott Fitzgerald') + self.assertIsInstance(sort_key[0], str) # Don't test the implementation of authors_to_sort_string + + def test_group_by_authors_unknown_when_empty(self): + db = _make_mock_db( + field_for_map={'authors': ()}, + field_metadata={}, + ) + (_, label) = get_group_key(_make_result(), 'authors', db) + + self.assertEqual(label, 'Unknown author') + + def test_group_by_user(self): + db = _make_mock_db(field_metadata={}) + result = _make_result(user_type='local', user='reader') + (sort_key, label) = get_group_key(result, 'user', db) + + self.assertIsInstance(label, str) + self.assertEqual(sort_key[0], label.lower()) + + def test_group_by_timestamp_day_bucketing(self): + from qt.core import QDateTime, Qt + + # Use an ISO timestamp from the annotation + ts_iso = '2024-12-25T10:30:00.000Z' + db = _make_mock_db( + field_metadata={ + 'timestamp': { + 'datatype': 'datetime', + 'display': {'date_format': 'dd MMM yyyy'}, + } + } + ) + result = _make_result(annotation={'timestamp': ts_iso}) + (sort_key, label) = get_group_key(result, 'timestamp', db) + + # Parse the timestamp to verify sort key + qdt = QDateTime.fromString(ts_iso, Qt.DateFormat.ISODate) + self.assertTrue(qdt.isValid(), "Test setup: timestamp should be valid") + qdt = qdt.toLocalTime() + qdate = qdt.date() + today = QDateTime.currentDateTime().toLocalTime().date() + expected_days_past = today.toJulianDay() - qdate.toJulianDay() + + # sort_key[0] is days_past, sort_key[1] is the formatted date label + self.assertEqual(sort_key[0], expected_days_past) + # Not asserting a particular label due to locale variations + self.assertIsInstance(label, str) + + def test_group_by_timestamp_with_invalid_date(self): + db = _make_mock_db( + field_metadata={ + 'timestamp': { + 'datatype': 'datetime', + 'display': {'date_format': 'dd MMM yyyy'}, + } + } + ) + result = _make_result(annotation={'timestamp': 'invalid-timestamp'}) + (sort_key, _) = get_group_key(result, 'timestamp', db) + + # Should use current date when parse fails + self.assertIsInstance(sort_key[0], int) + self.assertIsInstance(sort_key[1], str) + + +def find_tests(): + return unittest.defaultTestLoader.loadTestsFromTestCase(GroupKeyTest) diff --git a/src/calibre/utils/run_tests.py b/src/calibre/utils/run_tests.py index 6d354a6ff2..920d6a2fd0 100644 --- a/src/calibre/utils/run_tests.py +++ b/src/calibre/utils/run_tests.py @@ -276,6 +276,8 @@ def find_tests(which_tests=None, exclude_tests=None): a(find_tests()) from calibre.gui2.viewer.annotations import find_tests a(find_tests()) + from calibre.gui2.library.test_annotations import find_tests + a(find_tests()) from calibre.ebooks.html_entities import find_tests a(find_tests()) from calibre.spell.dictionary import find_tests