This commit is contained in:
Kovid Goyal 2026-03-31 10:15:39 +05:30
commit 81ea2dcc55
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
4 changed files with 445 additions and 30 deletions

View File

@ -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'

View File

@ -7,6 +7,7 @@ import os
import re
from functools import lru_cache, partial
from urllib.parse import quote
from enum import Enum, auto
from qt.core import (
QAbstractItemView,
@ -44,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
@ -377,6 +378,121 @@ def current_db():
return (getattr(current_db, 'ans', None) or get_gui().current_db).new_api
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):
current_result_changed = pyqtSignal(object)
@ -442,40 +558,34 @@ 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 = {}
# 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,)
db = current_db()
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 = get_group_key(result, g, db)
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])
@ -496,6 +606,38 @@ class ResultsList(QTreeWidget):
i %= self.number_of_results
self.setCurrentItem(self.item_map[i])
def add_children(self, parent_item, children):
'''
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]):
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):
for item in self.selectedItems():
@ -678,6 +820,54 @@ 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().name))
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()
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 = 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.display_name, group_type)
if before:
row = gb.findData(before)
if row > -1:
gb.setCurrentIndex(row)
gb.blockSignals(False)
class BrowsePanel(QWidget):
@ -722,6 +912,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 +929,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 +990,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'), '<p>' + _(

View File

@ -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)

View File

@ -278,6 +278,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