reader 3.15__tar.gz → 3.16__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. {reader-3.15 → reader-3.16}/CHANGES.rst +54 -0
  2. {reader-3.15/src/reader.egg-info → reader-3.16}/PKG-INFO +1 -1
  3. {reader-3.15 → reader-3.16}/docs/api.rst +10 -0
  4. {reader-3.15 → reader-3.16}/docs/dev.rst +17 -5
  5. {reader-3.15 → reader-3.16}/src/reader/__init__.py +2 -1
  6. {reader-3.15 → reader-3.16}/src/reader/_app/__init__.py +15 -19
  7. {reader-3.15 → reader-3.16}/src/reader/_app/templates/entries.html +4 -1
  8. {reader-3.15 → reader-3.16}/src/reader/_app/templates/entry.html +1 -1
  9. {reader-3.15 → reader-3.16}/src/reader/_app/templates/macros.html +2 -8
  10. {reader-3.15 → reader-3.16}/src/reader/_parser/feedparser.py +20 -0
  11. {reader-3.15 → reader-3.16}/src/reader/_storage/_base.py +6 -10
  12. {reader-3.15 → reader-3.16}/src/reader/_storage/_entries.py +34 -3
  13. {reader-3.15 → reader-3.16}/src/reader/_storage/_schema.py +8 -1
  14. {reader-3.15 → reader-3.16}/src/reader/_storage/_search.py +38 -13
  15. {reader-3.15 → reader-3.16}/src/reader/_storage/_sqlite_utils.py +10 -1
  16. {reader-3.15 → reader-3.16}/src/reader/_types.py +58 -0
  17. {reader-3.15 → reader-3.16}/src/reader/_update.py +1 -0
  18. {reader-3.15 → reader-3.16}/src/reader/_utils.py +7 -0
  19. {reader-3.15 → reader-3.16}/src/reader/core.py +106 -12
  20. {reader-3.15 → reader-3.16}/src/reader/types.py +68 -1
  21. reader-3.16/src/reader/utils.py +78 -0
  22. {reader-3.15 → reader-3.16/src/reader.egg-info}/PKG-INFO +1 -1
  23. {reader-3.15 → reader-3.16}/src/reader.egg-info/SOURCES.txt +3 -1
  24. {reader-3.15 → reader-3.16}/tests/data/full.atom +38 -0
  25. {reader-3.15 → reader-3.16}/tests/data/full.atom.py +31 -0
  26. {reader-3.15 → reader-3.16}/tests/data/full.rss +14 -0
  27. {reader-3.15 → reader-3.16}/tests/data/full.rss.py +16 -0
  28. {reader-3.15 → reader-3.16}/tests/data/relative.rss +1 -1
  29. {reader-3.15 → reader-3.16}/tests/data/relative.rss.py +1 -1
  30. {reader-3.15 → reader-3.16}/tests/test__types.py +5 -2
  31. {reader-3.15 → reader-3.16}/tests/test_cli.py +9 -8
  32. {reader-3.15 → reader-3.16}/tests/test_reader.py +118 -0
  33. {reader-3.15 → reader-3.16}/tests/test_reader_filter.py +5 -1
  34. {reader-3.15 → reader-3.16}/tests/test_reader_integration.py +5 -5
  35. {reader-3.15 → reader-3.16}/tests/test_reader_search.py +90 -0
  36. reader-3.16/tests/test_reader_utils.py +59 -0
  37. {reader-3.15 → reader-3.16}/tests/test_storage.py +1 -6
  38. {reader-3.15 → reader-3.16}/LICENSE +0 -0
  39. {reader-3.15 → reader-3.16}/MANIFEST.in +0 -0
  40. {reader-3.15 → reader-3.16}/README.rst +0 -0
  41. {reader-3.15 → reader-3.16}/docs/Makefile +0 -0
  42. {reader-3.15 → reader-3.16}/docs/app.rst +0 -0
  43. {reader-3.15 → reader-3.16}/docs/changelog.rst +0 -0
  44. {reader-3.15 → reader-3.16}/docs/cli.rst +0 -0
  45. {reader-3.15 → reader-3.16}/docs/conf.py +0 -0
  46. {reader-3.15 → reader-3.16}/docs/config.rst +0 -0
  47. {reader-3.15 → reader-3.16}/docs/contributing.rst +0 -0
  48. {reader-3.15 → reader-3.16}/docs/dev-app.rst +0 -0
  49. {reader-3.15 → reader-3.16}/docs/guide.rst +0 -0
  50. {reader-3.15 → reader-3.16}/docs/images/redesign-01.png +0 -0
  51. {reader-3.15 → reader-3.16}/docs/index.rst +0 -0
  52. {reader-3.15 → reader-3.16}/docs/install.rst +0 -0
  53. {reader-3.15 → reader-3.16}/docs/internal.rst +0 -0
  54. {reader-3.15 → reader-3.16}/docs/make.bat +0 -0
  55. {reader-3.15 → reader-3.16}/docs/plugins.rst +0 -0
  56. {reader-3.15 → reader-3.16}/docs/screenshots/dillo.png +0 -0
  57. {reader-3.15 → reader-3.16}/docs/screenshots/entries-feed.png +0 -0
  58. {reader-3.15 → reader-3.16}/docs/screenshots/entries.png +0 -0
  59. {reader-3.15 → reader-3.16}/docs/screenshots/entry-one.png +0 -0
  60. {reader-3.15 → reader-3.16}/docs/screenshots/entry-two.png +0 -0
  61. {reader-3.15 → reader-3.16}/docs/screenshots/feeds.png +0 -0
  62. {reader-3.15 → reader-3.16}/docs/screenshots/lynx.png +0 -0
  63. {reader-3.15 → reader-3.16}/docs/screenshots/search.png +0 -0
  64. {reader-3.15 → reader-3.16}/docs/tutorial.rst +0 -0
  65. {reader-3.15 → reader-3.16}/docs/why.rst +0 -0
  66. {reader-3.15 → reader-3.16}/examples/config.yaml +0 -0
  67. {reader-3.15 → reader-3.16}/examples/custom_headers.py +0 -0
  68. {reader-3.15 → reader-3.16}/examples/feed_slugs.py +0 -0
  69. {reader-3.15 → reader-3.16}/examples/parser_only.py +0 -0
  70. {reader-3.15 → reader-3.16}/examples/podcast.py +0 -0
  71. {reader-3.15 → reader-3.16}/examples/terminal.py +0 -0
  72. {reader-3.15 → reader-3.16}/pyproject.toml +0 -0
  73. {reader-3.15 → reader-3.16}/run.sh +0 -0
  74. {reader-3.15 → reader-3.16}/scripts/.coverage +0 -0
  75. {reader-3.15 → reader-3.16}/scripts/backup.sh +0 -0
  76. {reader-3.15 → reader-3.16}/scripts/bench.py +0 -0
  77. {reader-3.15 → reader-3.16}/scripts/debug_storage_stats.py +0 -0
  78. {reader-3.15 → reader-3.16}/scripts/generate_import_all.py +0 -0
  79. {reader-3.15 → reader-3.16}/scripts/jscontrols.html +0 -0
  80. {reader-3.15 → reader-3.16}/scripts/jscontrols.py +0 -0
  81. {reader-3.15 → reader-3.16}/scripts/lines.sh +0 -0
  82. {reader-3.15 → reader-3.16}/scripts/release.py +0 -0
  83. {reader-3.15 → reader-3.16}/setup.cfg +0 -0
  84. {reader-3.15 → reader-3.16}/src/reader/__main__.py +0 -0
  85. {reader-3.15 → reader-3.16}/src/reader/_app/api_thing.py +0 -0
  86. {reader-3.15 → reader-3.16}/src/reader/_app/cli.py +0 -0
  87. {reader-3.15 → reader-3.16}/src/reader/_app/static/controls.js +0 -0
  88. {reader-3.15 → reader-3.16}/src/reader/_app/static/style.css +0 -0
  89. {reader-3.15 → reader-3.16}/src/reader/_app/templates/add_entry.html +0 -0
  90. {reader-3.15 → reader-3.16}/src/reader/_app/templates/feeds.html +0 -0
  91. {reader-3.15 → reader-3.16}/src/reader/_app/templates/layout.html +0 -0
  92. {reader-3.15 → reader-3.16}/src/reader/_app/templates/metadata.html +0 -0
  93. {reader-3.15 → reader-3.16}/src/reader/_app/templates/tags.html +0 -0
  94. {reader-3.15 → reader-3.16}/src/reader/_app/wsgi.py +0 -0
  95. {reader-3.15 → reader-3.16}/src/reader/_cli.py +0 -0
  96. {reader-3.15 → reader-3.16}/src/reader/_config.py +0 -0
  97. {reader-3.15 → reader-3.16}/src/reader/_hash_utils.py +0 -0
  98. {reader-3.15 → reader-3.16}/src/reader/_parser/__init__.py +0 -0
  99. {reader-3.15 → reader-3.16}/src/reader/_parser/_http_utils.py +0 -0
  100. {reader-3.15 → reader-3.16}/src/reader/_parser/_lazy.py +0 -0
  101. {reader-3.15 → reader-3.16}/src/reader/_parser/_url_utils.py +0 -0
  102. {reader-3.15 → reader-3.16}/src/reader/_parser/file.py +0 -0
  103. {reader-3.15 → reader-3.16}/src/reader/_parser/http.py +0 -0
  104. {reader-3.15 → reader-3.16}/src/reader/_parser/jsonfeed.py +0 -0
  105. {reader-3.15 → reader-3.16}/src/reader/_parser/requests/__init__.py +0 -0
  106. {reader-3.15 → reader-3.16}/src/reader/_parser/requests/_lazy.py +0 -0
  107. {reader-3.15 → reader-3.16}/src/reader/_plugins/__init__.py +0 -0
  108. {reader-3.15 → reader-3.16}/src/reader/_plugins/cli_status.py +0 -0
  109. {reader-3.15 → reader-3.16}/src/reader/_plugins/enclosure_tags.py +0 -0
  110. {reader-3.15 → reader-3.16}/src/reader/_plugins/preview_feed_list.py +0 -0
  111. {reader-3.15 → reader-3.16}/src/reader/_plugins/share.py +0 -0
  112. {reader-3.15 → reader-3.16}/src/reader/_plugins/sqlite_releases.py +0 -0
  113. {reader-3.15 → reader-3.16}/src/reader/_plugins/templates/preview_feed_list.html +0 -0
  114. {reader-3.15 → reader-3.16}/src/reader/_plugins/timer.py +0 -0
  115. {reader-3.15 → reader-3.16}/src/reader/_storage/__init__.py +0 -0
  116. {reader-3.15 → reader-3.16}/src/reader/_storage/_changes.py +0 -0
  117. {reader-3.15 → reader-3.16}/src/reader/_storage/_feeds.py +0 -0
  118. {reader-3.15 → reader-3.16}/src/reader/_storage/_html_utils.py +0 -0
  119. {reader-3.15 → reader-3.16}/src/reader/_storage/_sql_utils.py +0 -0
  120. {reader-3.15 → reader-3.16}/src/reader/_storage/_tags.py +0 -0
  121. {reader-3.15 → reader-3.16}/src/reader/_vendor/__init__.py +0 -0
  122. {reader-3.15 → reader-3.16}/src/reader/_vendor/feedparser/__init__.py +0 -0
  123. {reader-3.15 → reader-3.16}/src/reader/_vendor/feedparser/api.py +0 -0
  124. {reader-3.15 → reader-3.16}/src/reader/_vendor/feedparser/datetimes/__init__.py +0 -0
  125. {reader-3.15 → reader-3.16}/src/reader/_vendor/feedparser/datetimes/asctime.py +0 -0
  126. {reader-3.15 → reader-3.16}/src/reader/_vendor/feedparser/datetimes/greek.py +0 -0
  127. {reader-3.15 → reader-3.16}/src/reader/_vendor/feedparser/datetimes/hungarian.py +0 -0
  128. {reader-3.15 → reader-3.16}/src/reader/_vendor/feedparser/datetimes/iso8601.py +0 -0
  129. {reader-3.15 → reader-3.16}/src/reader/_vendor/feedparser/datetimes/korean.py +0 -0
  130. {reader-3.15 → reader-3.16}/src/reader/_vendor/feedparser/datetimes/perforce.py +0 -0
  131. {reader-3.15 → reader-3.16}/src/reader/_vendor/feedparser/datetimes/rfc822.py +0 -0
  132. {reader-3.15 → reader-3.16}/src/reader/_vendor/feedparser/datetimes/w3dtf.py +0 -0
  133. {reader-3.15 → reader-3.16}/src/reader/_vendor/feedparser/encodings.py +0 -0
  134. {reader-3.15 → reader-3.16}/src/reader/_vendor/feedparser/exceptions.py +0 -0
  135. {reader-3.15 → reader-3.16}/src/reader/_vendor/feedparser/html.py +0 -0
  136. {reader-3.15 → reader-3.16}/src/reader/_vendor/feedparser/http.py +0 -0
  137. {reader-3.15 → reader-3.16}/src/reader/_vendor/feedparser/mixin.py +0 -0
  138. {reader-3.15 → reader-3.16}/src/reader/_vendor/feedparser/namespaces/__init__.py +0 -0
  139. {reader-3.15 → reader-3.16}/src/reader/_vendor/feedparser/namespaces/_base.py +0 -0
  140. {reader-3.15 → reader-3.16}/src/reader/_vendor/feedparser/namespaces/admin.py +0 -0
  141. {reader-3.15 → reader-3.16}/src/reader/_vendor/feedparser/namespaces/cc.py +0 -0
  142. {reader-3.15 → reader-3.16}/src/reader/_vendor/feedparser/namespaces/dc.py +0 -0
  143. {reader-3.15 → reader-3.16}/src/reader/_vendor/feedparser/namespaces/georss.py +0 -0
  144. {reader-3.15 → reader-3.16}/src/reader/_vendor/feedparser/namespaces/itunes.py +0 -0
  145. {reader-3.15 → reader-3.16}/src/reader/_vendor/feedparser/namespaces/mediarss.py +0 -0
  146. {reader-3.15 → reader-3.16}/src/reader/_vendor/feedparser/namespaces/psc.py +0 -0
  147. {reader-3.15 → reader-3.16}/src/reader/_vendor/feedparser/parsers/__init__.py +0 -0
  148. {reader-3.15 → reader-3.16}/src/reader/_vendor/feedparser/parsers/json.py +0 -0
  149. {reader-3.15 → reader-3.16}/src/reader/_vendor/feedparser/parsers/loose.py +0 -0
  150. {reader-3.15 → reader-3.16}/src/reader/_vendor/feedparser/parsers/strict.py +0 -0
  151. {reader-3.15 → reader-3.16}/src/reader/_vendor/feedparser/py.typed +0 -0
  152. {reader-3.15 → reader-3.16}/src/reader/_vendor/feedparser/sanitizer.py +0 -0
  153. {reader-3.15 → reader-3.16}/src/reader/_vendor/feedparser/sgml.py +0 -0
  154. {reader-3.15 → reader-3.16}/src/reader/_vendor/feedparser/urls.py +0 -0
  155. {reader-3.15 → reader-3.16}/src/reader/_vendor/feedparser/util.py +0 -0
  156. {reader-3.15 → reader-3.16}/src/reader/exceptions.py +0 -0
  157. {reader-3.15 → reader-3.16}/src/reader/plugins/__init__.py +0 -0
  158. {reader-3.15 → reader-3.16}/src/reader/plugins/enclosure_dedupe.py +0 -0
  159. {reader-3.15 → reader-3.16}/src/reader/plugins/entry_dedupe.py +0 -0
  160. {reader-3.15 → reader-3.16}/src/reader/plugins/mark_as_read.py +0 -0
  161. {reader-3.15 → reader-3.16}/src/reader/plugins/readtime.py +0 -0
  162. {reader-3.15 → reader-3.16}/src/reader/plugins/ua_fallback.py +0 -0
  163. {reader-3.15 → reader-3.16}/src/reader/py.typed +0 -0
  164. {reader-3.15 → reader-3.16}/src/reader.egg-info/dependency_links.txt +0 -0
  165. {reader-3.15 → reader-3.16}/src/reader.egg-info/requires.txt +0 -0
  166. {reader-3.15 → reader-3.16}/src/reader.egg-info/top_level.txt +0 -0
  167. {reader-3.15 → reader-3.16}/tests/conftest.py +0 -0
  168. {reader-3.15 → reader-3.16}/tests/data/10.json +0 -0
  169. {reader-3.15 → reader-3.16}/tests/data/10.json.py +0 -0
  170. {reader-3.15 → reader-3.16}/tests/data/custom +0 -0
  171. {reader-3.15 → reader-3.16}/tests/data/empty.atom +0 -0
  172. {reader-3.15 → reader-3.16}/tests/data/empty.atom.py +0 -0
  173. {reader-3.15 → reader-3.16}/tests/data/empty.json +0 -0
  174. {reader-3.15 → reader-3.16}/tests/data/empty.json.py +0 -0
  175. {reader-3.15 → reader-3.16}/tests/data/empty.rss +0 -0
  176. {reader-3.15 → reader-3.16}/tests/data/empty.rss.py +0 -0
  177. {reader-3.15 → reader-3.16}/tests/data/full.json +0 -0
  178. {reader-3.15 → reader-3.16}/tests/data/full.json.py +0 -0
  179. {reader-3.15 → reader-3.16}/tests/data/invalid.json +0 -0
  180. {reader-3.15 → reader-3.16}/tests/data/invalid.json.py +0 -0
  181. {reader-3.15 → reader-3.16}/tests/data/relative.atom +0 -0
  182. {reader-3.15 → reader-3.16}/tests/data/relative.atom.py +0 -0
  183. {reader-3.15 → reader-3.16}/tests/data/sqlite_releases.html +0 -0
  184. {reader-3.15 → reader-3.16}/tests/data/unknown.json +0 -0
  185. {reader-3.15 → reader-3.16}/tests/data/unknown.json.py +0 -0
  186. {reader-3.15 → reader-3.16}/tests/fakeparser.py +0 -0
  187. {reader-3.15 → reader-3.16}/tests/reader_methods.py +0 -0
  188. {reader-3.15 → reader-3.16}/tests/reader_test_plugins/__init__.py +0 -0
  189. {reader-3.15 → reader-3.16}/tests/reader_test_plugins/good.py +0 -0
  190. {reader-3.15 → reader-3.16}/tests/reader_test_plugins/init_error.py +0 -0
  191. {reader-3.15 → reader-3.16}/tests/reader_test_plugins/missing_dependency.py +0 -0
  192. {reader-3.15 → reader-3.16}/tests/reader_test_plugins/missing_entry_point.py +0 -0
  193. /reader-3.15/tests/test_utils.py → /reader-3.16/tests/test__utils.py +0 -0
  194. {reader-3.15 → reader-3.16}/tests/test_app.py +0 -0
  195. {reader-3.15 → reader-3.16}/tests/test_app_wsgi.py +0 -0
  196. {reader-3.15 → reader-3.16}/tests/test_bench.py +0 -0
  197. {reader-3.15 → reader-3.16}/tests/test_changes.py +0 -0
  198. {reader-3.15 → reader-3.16}/tests/test_config.py +0 -0
  199. {reader-3.15 → reader-3.16}/tests/test_exceptions.py +0 -0
  200. {reader-3.15 → reader-3.16}/tests/test_hash_utils.py +0 -0
  201. {reader-3.15 → reader-3.16}/tests/test_html_utils.py +0 -0
  202. {reader-3.15 → reader-3.16}/tests/test_lazy_imports.py +0 -0
  203. {reader-3.15 → reader-3.16}/tests/test_parser.py +0 -0
  204. {reader-3.15 → reader-3.16}/tests/test_plugins_cli_status.py +0 -0
  205. {reader-3.15 → reader-3.16}/tests/test_plugins_enclosure_dedupe.py +0 -0
  206. {reader-3.15 → reader-3.16}/tests/test_plugins_entry_dedupe.py +0 -0
  207. {reader-3.15 → reader-3.16}/tests/test_plugins_mark_as_read.py +0 -0
  208. {reader-3.15 → reader-3.16}/tests/test_plugins_preview_feed_list.py +0 -0
  209. {reader-3.15 → reader-3.16}/tests/test_plugins_readtime.py +0 -0
  210. {reader-3.15 → reader-3.16}/tests/test_plugins_sqlite_releases.py +0 -0
  211. {reader-3.15 → reader-3.16}/tests/test_plugins_ua_fallback.py +0 -0
  212. {reader-3.15 → reader-3.16}/tests/test_reader_context.py +0 -0
  213. {reader-3.15 → reader-3.16}/tests/test_reader_counts.py +0 -0
  214. {reader-3.15 → reader-3.16}/tests/test_reader_deprecations.py +0 -0
  215. {reader-3.15 → reader-3.16}/tests/test_reader_hooks.py +0 -0
  216. {reader-3.15 → reader-3.16}/tests/test_reader_plugins.py +0 -0
  217. {reader-3.15 → reader-3.16}/tests/test_reader_private.py +0 -0
  218. {reader-3.15 → reader-3.16}/tests/test_reader_sort.py +0 -0
  219. {reader-3.15 → reader-3.16}/tests/test_reader_update.py +0 -0
  220. {reader-3.15 → reader-3.16}/tests/test_search.py +0 -0
  221. {reader-3.15 → reader-3.16}/tests/test_sql_utils.py +0 -0
  222. {reader-3.15 → reader-3.16}/tests/test_sqlite_utils.py +0 -0
  223. {reader-3.15 → reader-3.16}/tests/test_tags.py +0 -0
  224. {reader-3.15 → reader-3.16}/tests/test_test_utils.py +0 -0
  225. {reader-3.15 → reader-3.16}/tests/test_types.py +0 -0
  226. {reader-3.15 → reader-3.16}/tests/utils.py +0 -0
  227. {reader-3.15 → reader-3.16}/tox.ini +0 -0
@@ -6,6 +6,60 @@ Changelog
6
6
  :no-index:
7
7
 
8
8
 
9
+ Version 3.16
10
+ ------------
11
+
12
+ Released 2024-12-08
13
+
14
+ * Parse and store :attr:`Entry.source` for Atom and RSS feeds. (:issue:`267`)
15
+
16
+ * Add :attr:`Entry.feed_resolved_title` and :attr:`Feed.resolved_title` properties.
17
+ * The ``feed`` search column now indexes :attr:`Entry.feed_resolved_title`,
18
+ instead of feed :attr:`~Feed.user_title` or :attr:`~Feed.title`.
19
+ * Allow filtering entries by the entry source.
20
+
21
+ * Add :func:`reader.utils.archive_entries` for
22
+ copying entries to an "archived" feed.
23
+ (:issue:`290`)
24
+
25
+ * Add :meth:`~Reader.copy_entry`.
26
+ * Allow archiving entries from the web app.
27
+
28
+ * Fix bug causing :class:`Reader` operations
29
+ from a thread other than the one that created the instance
30
+ to happen with foreign key constraint enforcement disabled
31
+ (e.g. deleting a feed from another thread would not delete its entries).
32
+
33
+ This bug exists since using :class:`Reader` instances from other threads
34
+ became allowed in `2.15 <Version 2.15_>`_.
35
+
36
+ Serving the web application with ``python -m reader serve``
37
+ is known to be affected.
38
+ Serving it with uWSGI without threads (the default)
39
+ should not be affected.
40
+
41
+ .. attention::
42
+
43
+ **Your database may be in an inconsistent state because of this bug.**
44
+
45
+ It is recommended you run `PRAGMA foreign_key_check`_ on your database.
46
+
47
+ If you are upgrading from a version prior to 3.16
48
+ (i.e. were not using a pre-release version of *reader*),
49
+ the migration will do so for you.
50
+ If there are inconsistencies, you will get this error::
51
+
52
+ StorageError: integrity error: after migrating to version 43:
53
+ integrity error: FOREIGN KEY constraint failed
54
+
55
+ * Fix :meth:`~Reader.enable_search` / :meth:`~Reader.update_search`
56
+ not working when the search database is missing but change tracking is enabled
57
+ (e.g. when restoring the main database from backup).
58
+ (:issue:`362`)
59
+
60
+ .. _PRAGMA foreign_key_check: https://www.sqlite.org/pragma.html#pragma_foreign_key_check
61
+
62
+
9
63
  Version 3.15
10
64
  ------------
11
65
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: reader
3
- Version: 3.15
3
+ Version: 3.16
4
4
  Summary: A Python feed reader library.
5
5
  Author: lemon24
6
6
  License: BSD-3-Clause
@@ -40,6 +40,9 @@ Data objects
40
40
  .. autoclass:: Enclosure
41
41
  :members:
42
42
 
43
+ .. autoclass:: EntrySource
44
+ :members:
45
+
43
46
  .. autoclass:: EntrySearchResult
44
47
  :members:
45
48
 
@@ -187,3 +190,10 @@ Constants
187
190
  .. autodata:: reader.core.DEFAULT_RESERVED_NAME_SCHEME
188
191
 
189
192
  .. autodata:: reader.plugins.DEFAULT_PLUGINS
193
+
194
+
195
+
196
+ Utilities
197
+ ---------
198
+
199
+ .. autofunction:: reader.utils.archive_entries
@@ -34,6 +34,9 @@ but I will prioritize supporting :doc:`contributors <contributing>`
34
34
 
35
35
  * OPML support, :issue:`165`
36
36
  * :ref:`deleting entries <deleting entries>`
37
+
38
+ * archiving important entries of deleted feeds, :issue:`290`
39
+
37
40
  * :ref:`feed interaction statistics <counts api>`
38
41
 
39
42
  * security
@@ -69,7 +72,6 @@ but I will prioritize supporting :doc:`contributors <contributing>`
69
72
 
70
73
  * add more fields to data objects
71
74
 
72
- * entry source, :issue:`276`
73
75
  * extra data, as an escape hatch, :issue:`277`
74
76
 
75
77
  * :ref:`multiple storage implementations <multiple storage implementations>`
@@ -512,6 +514,16 @@ From the initial issue:
512
514
  * use cases: :issue:`149#issuecomment-700066794`
513
515
  * initial requirements: :issue:`149#issuecomment-700532183`
514
516
 
517
+ Splitting a feed into two feeds is discussed in :issue:`221`.
518
+ The main takeaway at the time (2021) was that
519
+ not all entry data can be retrieved through the storage API,
520
+ so it's not fully possible to round-trip an entry.
521
+ Based on a quick 2024 survey,
522
+ the only affected attributes
523
+ are :attr:`~.EntryUpdateIntent.feed_order` (can't get; not critical)
524
+ and :attr:`~Entry.original_feed_url` (can't set; will be required for
525
+ archiving important entries of deleted feeds, :issue:`290`).
526
+
515
527
 
516
528
  Resource tags / metadata
517
529
  ~~~~~~~~~~~~~~~~~~~~~~~~
@@ -708,16 +720,16 @@ Wrapping underlying storage exceptions
708
720
 
709
721
  Which exception to wrap, and which not: :issue:`21#issuecomment-365442439`.
710
722
 
711
- In version 3.10 (November 2023), all internal APIs were changed
712
- to use timezone-aware datetimes, with the timezone set to UTC,
713
- in preparation for support for any timezone.
714
-
715
723
 
716
724
  Timezone handling
717
725
  ~~~~~~~~~~~~~~~~~
718
726
 
719
727
  Aware vs. naive, and what's needed to go fully aware: :issue:`233#issuecomment-881618002`.
720
728
 
729
+ In version 3.10 (November 2023), all internal APIs were changed
730
+ to use timezone-aware datetimes, with the timezone set to UTC,
731
+ in preparation for support for any timezone.
732
+
721
733
 
722
734
  OPML support
723
735
  ~~~~~~~~~~~~
@@ -37,7 +37,7 @@ Print the titles of the unread entries::
37
37
 
38
38
  """
39
39
 
40
- __version__ = '3.15'
40
+ __version__ = '3.16'
41
41
 
42
42
  # isort: off
43
43
 
@@ -52,6 +52,7 @@ from .types import (
52
52
  Entry as Entry,
53
53
  Content as Content,
54
54
  Enclosure as Enclosure,
55
+ EntrySource as EntrySource,
55
56
  EntrySearchResult as EntrySearchResult,
56
57
  HighlightedString as HighlightedString,
57
58
  FeedCounts as FeedCounts,
@@ -38,6 +38,7 @@ from reader import ReaderError
38
38
  from reader._plugins import Loader
39
39
  from reader.types import _get_entry_content
40
40
  from reader.types import TristateFilterInput
41
+ from reader.utils import archive_entries
41
42
 
42
43
  from .api_thing import APIError
43
44
  from .api_thing import APIThing
@@ -157,12 +158,8 @@ class EntryProxy:
157
158
  def title(self):
158
159
  highlight = self._search_result.metadata.get('.title')
159
160
  if highlight:
160
- return str(highlight)
161
- return None
162
-
163
- @property
164
- def feed(self):
165
- return FeedProxy(self._search_result, self._entry)
161
+ return highlighted(highlight)
162
+ return self._entry.title
166
163
 
167
164
  @property
168
165
  def summary(self):
@@ -184,21 +181,12 @@ class EntryProxy:
184
181
  def get_content(self, prefer_summary=False):
185
182
  return _get_entry_content(self, prefer_summary)
186
183
 
187
-
188
- @dataclass(frozen=True)
189
- class FeedProxy:
190
- _search_result: EntrySearchResult
191
- _entry: Entry
192
-
193
- def __getattr__(self, name):
194
- return getattr(self._entry.feed, name)
195
-
196
184
  @property
197
- def title(self):
198
- highlight = self._search_result.metadata.get('.feed.title')
185
+ def feed_resolved_title(self):
186
+ highlight = self._search_result.metadata.get('.feed_resolved_title')
199
187
  if highlight:
200
- return str(highlight)
201
- return self._entry.feed.title
188
+ return highlighted(highlight)
189
+ return self._entry.feed_resolved_url
202
190
 
203
191
 
204
192
  @dataclass
@@ -643,6 +631,14 @@ def mark_all_as_unread(data):
643
631
  get_reader().mark_entry_as_unread((feed_url, entry_id))
644
632
 
645
633
 
634
+ @form_api(really=True)
635
+ @readererror_to_apierror()
636
+ def archive_all(data):
637
+ feed_url = data['feed-url']
638
+ entry_ids = json.loads(data['entry-id'])
639
+ archive_entries(get_reader(), [(feed_url, eid) for eid in entry_ids])
640
+
641
+
646
642
  @form_api
647
643
  @readererror_to_apierror()
648
644
  def mark_as_important(data):
@@ -172,6 +172,9 @@
172
172
  {{- macros.confirm_button('.form_api', 'mark-all-as-unread', 'all unread', leave_disabled=true, next=next, context={'feed-url': feed.url, 'entry-id': entries_data | tojson}) -}}
173
173
  {% endif %}
174
174
 
175
+ {{- macros.confirm_button('.form_api', 'archive-all', 'archive all', leave_disabled=true, next=url_for('.entries', feed='reader:archived'), context={'feed-url': feed.url, 'entry-id': entries_data | tojson}) -}}
176
+ {# TODO: should redirect to archived # entry #}
177
+
175
178
  {{ macros.confirm_button('.form_api', 'delete-feed', 'delete feed', leave_disabled=true, next=url_for('.entries'), context={'feed-url': feed.url}) }}
176
179
 
177
180
  {{ macros.text_input_button('.form_api', 'update-feed-title', 'update feed title', 'feed-title', 'feed title', leave_disabled=true, next=next, context={'feed-url': feed.url}, value=feed.user_title) }}
@@ -265,7 +268,7 @@
265
268
  <ul class="controls">
266
269
 
267
270
  <li>
268
- <a href="{{ url_for('.entries', feed=entry.feed.url) }}">{{ macros.feed_title(entry.feed) }}</a>
271
+ <a href="{{ url_for('.entries', feed=entry.feed.url) }}">{{ entry.feed_resolved_title or feed.url }}</a>
269
272
  <li>
270
273
  {%- set published = entry.published or entry.updated_not_none -%}
271
274
  <span title="{{ published }}">{{ published | humanize_naturaltime }}</span>
@@ -19,7 +19,7 @@
19
19
 
20
20
  <li>
21
21
  {% if entry.author %} by {{ entry.author }}{% endif %}
22
- in <a href="{{ url_for('.entries', feed=entry.feed.url) }}">{{ macros.feed_title(entry.feed) }}</a>
22
+ in <a href="{{ url_for('.entries', feed=entry.feed.url) }}">{{ entry.feed_resolved_title or feed.url }}</a>
23
23
  <li>
24
24
  {%- set published = entry.published or entry.updated_not_none -%}
25
25
  <span title="{{ published }}">{{ published | humanize_naturaltime }}</span>
@@ -6,17 +6,11 @@
6
6
  {%- endmacro %}
7
7
 
8
8
  {% macro feed_title(feed) %}
9
- {%- if feed.user_title and feed.user_title.strip() -%}
10
- {{ feed.user_title }}
11
- {%- elif feed.title and feed.title.strip() -%}
12
- {{ feed.title }}
13
- {%- else -%}
14
- {{ feed.url }}
15
- {%- endif -%}
9
+ {{- feed.resolved_title or feed.url -}}
16
10
  {% endmacro %}
17
11
 
18
12
  {% macro feed_title_secondary(feed) %}
19
- {%- if feed.user_title and feed.user_title.strip() and feed.title and feed.title.strip() -%}
13
+ {%- if feed.title and feed.resolved_title != feed.title -%}
20
14
  {{ feed.title }}
21
15
  {%- endif -%}
22
16
  {% endmacro %}
@@ -16,6 +16,7 @@ from .._types import FeedData
16
16
  from ..exceptions import ParseError
17
17
  from ..types import Content
18
18
  from ..types import Enclosure
19
+ from ..types import EntrySource
19
20
  from ._http_utils import parse_accept_header
20
21
  from ._http_utils import unparse_accept_header
21
22
 
@@ -166,6 +167,24 @@ def _process_entry(feed_url: str, entry: Any, is_rss: bool) -> EntryData:
166
167
  del data['length']
167
168
  enclosures.append(Enclosure(**data))
168
169
 
170
+ source = None
171
+ if data := entry.get('source'):
172
+ links_by_rel: dict[str, list[Any]] = {}
173
+ for link in data.get('links', ()):
174
+ links_by_rel.setdefault(link.get('rel'), []).append(link)
175
+ source_url = data.get('url') or links_by_rel.get('self', [{}])[0].get('href')
176
+ source_title = data.get('title')
177
+ # choice of the set of required attributes is somewhat arbitrary...
178
+ if source_url or source_title:
179
+ source = EntrySource(
180
+ source_url,
181
+ _get_datetime_attr(data, 'updated_parsed'),
182
+ source_title,
183
+ data.get('link'),
184
+ data.get('author'),
185
+ data.get('subtitle'),
186
+ )
187
+
169
188
  return EntryData(
170
189
  feed_url,
171
190
  id,
@@ -177,4 +196,5 @@ def _process_entry(feed_url: str, entry: Any, is_rss: bool) -> EntryData:
177
196
  entry.get('summary'),
178
197
  tuple(content),
179
198
  tuple(enclosures),
199
+ source,
180
200
  )
@@ -57,16 +57,12 @@ class StorageBase:
57
57
  if timeout is not None:
58
58
  kwargs['timeout'] = timeout
59
59
 
60
- self.factory = _sqlite_utils.LocalConnectionFactory(path, **kwargs)
61
- db = self.factory()
62
- try:
63
- self.setup_db(db)
64
- except BaseException:
65
- db.close()
66
- raise
67
-
68
- self.path = path
69
- self.timeout = timeout
60
+ # at least the "PRAGMA foreign_keys = ON" part of setup_db
61
+ # has to run for every connection (in every thread),
62
+ # since it's not persisted across connections
63
+ self.factory = _sqlite_utils.LocalConnectionFactory(
64
+ path, self.setup_db, **kwargs
65
+ )
70
66
 
71
67
  def get_db(self) -> sqlite3.Connection:
72
68
  return self.factory()
@@ -26,6 +26,7 @@ from ..types import Enclosure
26
26
  from ..types import Entry
27
27
  from ..types import EntryCounts
28
28
  from ..types import EntrySort
29
+ from ..types import EntrySource
29
30
  from ._base import wrap_exceptions
30
31
  from ._feeds import feed_factory
31
32
  from ._sql_utils import Query
@@ -251,12 +252,14 @@ class EntriesMixin(StorageBase):
251
252
  summary,
252
253
  content,
253
254
  enclosures,
255
+ source,
254
256
  read,
255
257
  last_updated,
256
258
  first_updated,
257
259
  first_updated_epoch,
258
260
  feed_order,
259
261
  recent_sort,
262
+ original_feed,
260
263
  data_hash,
261
264
  data_hash_changed,
262
265
  added_by
@@ -271,12 +274,14 @@ class EntriesMixin(StorageBase):
271
274
  :summary,
272
275
  :content,
273
276
  :enclosures,
277
+ :source,
274
278
  0, -- read (should be not null in the schema, but isn't)
275
279
  :last_updated,
276
280
  :first_updated,
277
281
  :first_updated_epoch,
278
282
  :feed_order,
279
283
  :recent_sort,
284
+ :original_feed,
280
285
  :data_hash,
281
286
  :data_hash_changed,
282
287
  :added_by
@@ -296,10 +301,11 @@ class EntriesMixin(StorageBase):
296
301
  summary = :summary,
297
302
  content = :content,
298
303
  enclosures = :enclosures,
304
+ source = :source,
299
305
  last_updated = :last_updated,
300
306
  feed_order = :feed_order,
301
307
  recent_sort = :recent_sort,
302
- original_feed = NULL,
308
+ original_feed = :original_feed,
303
309
  data_hash = :data_hash,
304
310
  data_hash_changed = :data_hash_changed,
305
311
  added_by = :added_by
@@ -425,6 +431,7 @@ def get_entries_query(
425
431
  entries.summary
426
432
  entries.content
427
433
  entries.enclosures
434
+ entries.source
428
435
  entries.read
429
436
  entries.read_modified
430
437
  entries.important
@@ -456,6 +463,7 @@ def entry_factory(row: tuple[Any, ...]) -> Entry:
456
463
  summary,
457
464
  content,
458
465
  enclosures,
466
+ source,
459
467
  read,
460
468
  read_modified,
461
469
  important,
@@ -465,7 +473,15 @@ def entry_factory(row: tuple[Any, ...]) -> Entry:
465
473
  last_updated,
466
474
  original_feed,
467
475
  sequence,
468
- ) = row[14:32]
476
+ ) = row[14:33]
477
+
478
+ source_obj = None
479
+ if source:
480
+ source_dict = json.loads(source)
481
+ if source_dict['updated']:
482
+ source_dict['updated'] = convert_timestamp(source_dict['updated'])
483
+ source_obj = EntrySource(**source_dict)
484
+
469
485
  return Entry(
470
486
  id,
471
487
  convert_timestamp(updated) if updated else None,
@@ -476,6 +492,7 @@ def entry_factory(row: tuple[Any, ...]) -> Entry:
476
492
  summary,
477
493
  tuple(Content(**d) for d in json.loads(content)) if content else (),
478
494
  tuple(Enclosure(**d) for d in json.loads(enclosures)) if enclosures else (),
495
+ source_obj,
479
496
  read == 1,
480
497
  convert_timestamp(read_modified) if read_modified else None,
481
498
  important == 1 if important is not None else None,
@@ -503,7 +520,9 @@ def entry_filter(
503
520
  query: Query, filter: EntryFilter, keyword: str = 'WHERE'
504
521
  ) -> dict[str, Any]:
505
522
  add = getattr(query, keyword)
506
- feed_url, entry_id, read, important, has_enclosures, tags, feed_tags = filter
523
+ feed_url, entry_id, read, important, has_enclosures, source_url, tags, feed_tags = (
524
+ filter
525
+ )
507
526
 
508
527
  context = {}
509
528
 
@@ -529,6 +548,10 @@ def entry_filter(
529
548
  """
530
549
  )
531
550
 
551
+ if source_url:
552
+ add("json_extract(entries.source, '$.url') = :source_url")
553
+ context['source_url'] = source_url
554
+
532
555
  context.update(entry_tags_filter(query, tags, keyword=keyword))
533
556
  context.update(feed_tags_filter(query, feed_tags, 'entries.feed', keyword=keyword))
534
557
 
@@ -660,7 +683,15 @@ def entry_update_intent_to_dict(intent: EntryUpdateIntent) -> dict[str, Any]:
660
683
  else None
661
684
  ),
662
685
  recent_sort=adapt_datetime(intent.recent_sort) if intent.recent_sort else None,
686
+ original_feed=intent.original_feed_url,
663
687
  data_hash=entry.hash,
664
688
  data_hash_changed=context.pop('hash_changed'),
665
689
  )
690
+
691
+ if entry.source:
692
+ source_dict = entry.source._asdict()
693
+ if entry.source.updated:
694
+ source_dict['updated'] = adapt_datetime(entry.source.updated)
695
+ context['source'] = json.dumps(source_dict)
696
+
666
697
  return context
@@ -48,6 +48,7 @@ CREATE TABLE entries (
48
48
  summary TEXT,
49
49
  content TEXT,
50
50
  enclosures TEXT,
51
+ source TEXT,
51
52
  original_feed TEXT, -- null if the feed was never moved
52
53
  data_hash BLOB, -- derived from entry data
53
54
  data_hash_changed INTEGER, -- metadata about data_hash
@@ -318,7 +319,12 @@ def update_from_41_to_42(db: sqlite3.Connection, /) -> None: # pragma: no cover
318
319
  entry_tags_by_key_index.create(db)
319
320
 
320
321
 
321
- VERSION = 42
322
+ def update_from_42_to_43(db: sqlite3.Connection, /) -> None: # pragma: no cover
323
+ # https://github.com/lemon24/reader/issues/276
324
+ db.execute("ALTER TABLE entries ADD COLUMN source TEXT;")
325
+
326
+
327
+ VERSION = 43
322
328
 
323
329
  MIGRATIONS = {
324
330
  # 1-9 removed before 0.1 (last in e4769d8ba77c61ec1fe2fbe99839e1826c17ace7)
@@ -331,6 +337,7 @@ MIGRATIONS = {
331
337
  39: update_from_39_to_40,
332
338
  40: update_from_40_to_41,
333
339
  41: update_from_41_to_42,
340
+ 42: update_from_42_to_43,
334
341
  }
335
342
  MISSING_SUFFIX = (
336
343
  "; you may have skipped some required migrations, see "
@@ -19,6 +19,7 @@ from .._types import Action
19
19
  from .._types import Change
20
20
  from .._types import EntryFilter
21
21
  from .._utils import exactly_one
22
+ from .._utils import resolve_path
22
23
  from .._utils import zero_or_one
23
24
  from ..exceptions import ChangeTrackingNotEnabledError
24
25
  from ..exceptions import EntryNotFoundError
@@ -64,6 +65,13 @@ QUERY_EXC = dict.fromkeys(
64
65
  # 255 letters / 4.7 letters per word (average in English)
65
66
  TOKENS = 54
66
67
 
68
+ FEED_TITLE_PATHS = [
69
+ '.feed.title',
70
+ '.feed.user_title',
71
+ '.source.title',
72
+ '.feed_resolved_title',
73
+ ]
74
+
67
75
 
68
76
  class Search:
69
77
  """Search provider tightly coupled to the SQLite storage.
@@ -108,15 +116,18 @@ class Search:
108
116
 
109
117
  @wrap_exceptions()
110
118
  def enable(self) -> None:
111
- self.storage.changes.enable()
112
119
  try:
113
120
  with ddl_transaction(self.get_db()) as db:
114
121
  self._enable(db, self.schema)
115
122
  except sqlite3.OperationalError as e:
116
- if "table entries_search already exists" in str(e).lower():
117
- return
118
- else: # pragma: no cover
123
+ if "table entries_search already exists" not in str(e): # pragma: no cover
119
124
  raise
125
+ else:
126
+ # if search was not already enabled, make sure changes.enable()
127
+ # is not a no-op and we backfill all the resources
128
+ # (changes can be enabled and search not when restoring from backup)
129
+ self.storage.changes.disable()
130
+ self.storage.changes.enable()
120
131
 
121
132
  @classmethod
122
133
  def _enable(cls, db: sqlite3.Connection, schema: str = 'main') -> None:
@@ -211,19 +222,22 @@ class Search:
211
222
  def is_enabled(self) -> bool:
212
223
  return self._is_enabled(self.get_db())
213
224
 
214
- @staticmethod
215
- def _is_enabled(db: sqlite3.Connection) -> bool:
225
+ @classmethod
226
+ def _is_enabled(cls, db: sqlite3.Connection) -> bool:
216
227
  # Private API, may be called from migrations.
217
228
  try:
218
- list(db.execute("SELECT * FROM entries_search LIMIT 0;"))
229
+ cls._enabled_check(db)
219
230
  except sqlite3.OperationalError as e:
220
- if "no such table: entries_search" in str(e):
221
- return False
222
- else: # pragma: no cover
231
+ if "no such table: entries_search" not in str(e): # pragma: no cover
223
232
  raise
233
+ return False
224
234
  else:
225
235
  return True
226
236
 
237
+ @staticmethod
238
+ def _enabled_check(db: sqlite3.Connection) -> None:
239
+ list(db.execute("SELECT * FROM entries_search LIMIT 0;"))
240
+
227
241
  @wrap_exceptions(ENABLED_EXC)
228
242
  def update(self) -> None:
229
243
  try:
@@ -232,6 +246,10 @@ class Search:
232
246
  except ChangeTrackingNotEnabledError as e:
233
247
  raise SearchNotEnabledError() from e
234
248
 
249
+ # if changes is enabled but search is not (e.g. restoring from backup),
250
+ # make sure we get a SearchNotEnabledError even if there are no changes
251
+ self._enabled_check(self.get_db())
252
+
235
253
  def _delete_from_search(self) -> None:
236
254
  # The loop is done outside of the chunk logic to help testing.
237
255
  while changes := self.storage.changes.get(Action.DELETE):
@@ -327,8 +345,15 @@ class Search:
327
345
  final.append((None, None))
328
346
 
329
347
  stripped_title = self.strip_html(entry.title)
330
- feed_title = entry.feed.user_title or entry.feed.title
331
- is_feed_user_title = bool(entry.feed.user_title)
348
+
349
+ feed_resolved_title = entry.feed_resolved_title
350
+ for is_feed_user_title, path in enumerate(FEED_TITLE_PATHS): # noqa: B007
351
+ if feed_title := resolve_path(entry, path):
352
+ if feed_title == feed_resolved_title:
353
+ break
354
+ else:
355
+ feed_title = None
356
+ is_feed_user_title = 0
332
357
  stripped_feed_title = self.strip_html(feed_title)
333
358
 
334
359
  stripped[change] = [
@@ -579,7 +604,7 @@ def entry_search_result_factory(
579
604
  if title:
580
605
  metadata['.title'] = extract(title)
581
606
  if feed_title:
582
- path = '.feed.title' if not is_feed_user_title else '.feed.user_title'
607
+ path = FEED_TITLE_PATHS[is_feed_user_title]
583
608
  metadata[path] = extract(feed_title)
584
609
 
585
610
  rv_content = {c['path']: extract(c['value']) for c in content if c['path']}
@@ -426,8 +426,11 @@ class LocalConnectionFactory:
426
426
 
427
427
  INLINE_OPTIMIZE_TIMEOUT = 0.1
428
428
 
429
- def __init__(self, path: str, **kwargs: Any):
429
+ def __init__(
430
+ self, path: str, setup_db: _DBFunction = lambda _: None, **kwargs: Any
431
+ ):
430
432
  self.path = path
433
+ self.setup_db = setup_db
431
434
  self.kwargs = kwargs
432
435
  if kwargs.get('uri'): # pragma: no cover
433
436
  raise NotImplementedError("is_private() does not work for uri=True")
@@ -463,6 +466,12 @@ class LocalConnectionFactory:
463
466
  assert db is not None, "for mypy"
464
467
  self._local.call_count = 0
465
468
 
469
+ try:
470
+ self.setup_db(db)
471
+ except BaseException:
472
+ db.close()
473
+ raise
474
+
466
475
  # http://threebean.org/blog/atexit-for-threads/
467
476
  # works on cpython (finalizer runs in thread),
468
477
  # but not on pypy (finalizer runs in main thread);