mirror of
https://github.com/kovidgoyal/calibre.git
synced 2026-04-11 19:51:59 -04:00
Merge branch 'annot-sort' of https://github.com/brpeterman/calibre
This commit is contained in:
commit
81ea2dcc55
@ -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'
|
||||
|
||||
@ -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>' + _(
|
||||
|
||||
213
src/calibre/gui2/library/test_annotations.py
Normal file
213
src/calibre/gui2/library/test_annotations.py
Normal 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)
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user