reader 3.16__tar.gz → 3.17__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 (237) hide show
  1. {reader-3.16 → reader-3.17}/CHANGES.rst +30 -0
  2. {reader-3.16 → reader-3.17}/MANIFEST.in +1 -0
  3. {reader-3.16/src/reader.egg-info → reader-3.17}/PKG-INFO +4 -2
  4. reader-3.17/docs/.DS_Store +0 -0
  5. {reader-3.16 → reader-3.17}/docs/app.rst +25 -0
  6. {reader-3.16 → reader-3.17}/docs/dev.rst +11 -1
  7. reader-3.17/docs/screenshots/entries-v2-dark.png +0 -0
  8. reader-3.17/docs/screenshots/entries-v2-filters-light.png +0 -0
  9. {reader-3.16 → reader-3.17}/pyproject.toml +3 -0
  10. {reader-3.16 → reader-3.17}/src/reader/__init__.py +1 -1
  11. {reader-3.16 → reader-3.17}/src/reader/_app/__init__.py +5 -0
  12. {reader-3.16 → reader-3.17}/src/reader/_app/templates/layout.html +1 -0
  13. reader-3.17/src/reader/_app/v2/__init__.py +123 -0
  14. reader-3.17/src/reader/_app/v2/forms.py +159 -0
  15. reader-3.17/src/reader/_app/v2/static/style.css +27 -0
  16. reader-3.17/src/reader/_app/v2/static/theme.js +91 -0
  17. reader-3.17/src/reader/_app/v2/templates/v2/entries.html +232 -0
  18. reader-3.17/src/reader/_app/v2/templates/v2/layout.html +116 -0
  19. reader-3.17/src/reader/_app/v2/templates/v2/macros.html +47 -0
  20. {reader-3.16 → reader-3.17}/src/reader/_parser/feedparser.py +2 -2
  21. {reader-3.16 → reader-3.17}/src/reader/_plugins/cli_status.py +25 -14
  22. {reader-3.16 → reader-3.17}/src/reader/_storage/_sqlite_utils.py +3 -1
  23. {reader-3.16 → reader-3.17}/src/reader/types.py +8 -1
  24. {reader-3.16 → reader-3.17/src/reader.egg-info}/PKG-INFO +4 -2
  25. {reader-3.16 → reader-3.17}/src/reader.egg-info/SOURCES.txt +10 -0
  26. {reader-3.16 → reader-3.17}/src/reader.egg-info/requires.txt +2 -0
  27. {reader-3.16 → reader-3.17}/tests/conftest.py +15 -1
  28. {reader-3.16 → reader-3.17}/tests/fakeparser.py +7 -31
  29. {reader-3.16 → reader-3.17}/tests/test_app.py +11 -29
  30. {reader-3.16 → reader-3.17}/tests/test_changes.py +15 -17
  31. {reader-3.16 → reader-3.17}/tests/test_plugins_cli_status.py +36 -27
  32. {reader-3.16 → reader-3.17}/tests/test_plugins_enclosure_dedupe.py +1 -3
  33. {reader-3.16 → reader-3.17}/tests/test_plugins_entry_dedupe.py +14 -21
  34. {reader-3.16 → reader-3.17}/tests/test_plugins_mark_as_read.py +9 -21
  35. {reader-3.16 → reader-3.17}/tests/test_plugins_readtime.py +4 -13
  36. {reader-3.16 → reader-3.17}/tests/test_reader.py +78 -194
  37. {reader-3.16 → reader-3.17}/tests/test_reader_context.py +1 -3
  38. {reader-3.16 → reader-3.17}/tests/test_reader_hooks.py +3 -9
  39. {reader-3.16 → reader-3.17}/tests/test_reader_private.py +11 -13
  40. {reader-3.16 → reader-3.17}/tests/test_reader_search.py +18 -49
  41. {reader-3.16 → reader-3.17}/tests/test_reader_sort.py +8 -10
  42. {reader-3.16 → reader-3.17}/tests/test_reader_update.py +17 -25
  43. {reader-3.16 → reader-3.17}/tests/test_reader_utils.py +1 -3
  44. {reader-3.16 → reader-3.17}/tests/test_storage.py +6 -10
  45. {reader-3.16 → reader-3.17}/tests/test_tags.py +5 -10
  46. {reader-3.16 → reader-3.17}/tests/test_types.py +31 -0
  47. {reader-3.16 → reader-3.17}/tests/utils.py +34 -0
  48. {reader-3.16 → reader-3.17}/tox.ini +3 -3
  49. {reader-3.16 → reader-3.17}/LICENSE +0 -0
  50. {reader-3.16 → reader-3.17}/README.rst +0 -0
  51. {reader-3.16 → reader-3.17}/docs/Makefile +0 -0
  52. {reader-3.16 → reader-3.17}/docs/api.rst +0 -0
  53. {reader-3.16 → reader-3.17}/docs/changelog.rst +0 -0
  54. {reader-3.16 → reader-3.17}/docs/cli.rst +0 -0
  55. {reader-3.16 → reader-3.17}/docs/conf.py +0 -0
  56. {reader-3.16 → reader-3.17}/docs/config.rst +0 -0
  57. {reader-3.16 → reader-3.17}/docs/contributing.rst +0 -0
  58. {reader-3.16 → reader-3.17}/docs/dev-app.rst +0 -0
  59. {reader-3.16 → reader-3.17}/docs/guide.rst +0 -0
  60. {reader-3.16 → reader-3.17}/docs/images/redesign-01.png +0 -0
  61. {reader-3.16 → reader-3.17}/docs/index.rst +0 -0
  62. {reader-3.16 → reader-3.17}/docs/install.rst +0 -0
  63. {reader-3.16 → reader-3.17}/docs/internal.rst +0 -0
  64. {reader-3.16 → reader-3.17}/docs/make.bat +0 -0
  65. {reader-3.16 → reader-3.17}/docs/plugins.rst +0 -0
  66. {reader-3.16 → reader-3.17}/docs/screenshots/dillo.png +0 -0
  67. {reader-3.16 → reader-3.17}/docs/screenshots/entries-feed.png +0 -0
  68. {reader-3.16 → reader-3.17}/docs/screenshots/entries.png +0 -0
  69. {reader-3.16 → reader-3.17}/docs/screenshots/entry-one.png +0 -0
  70. {reader-3.16 → reader-3.17}/docs/screenshots/entry-two.png +0 -0
  71. {reader-3.16 → reader-3.17}/docs/screenshots/feeds.png +0 -0
  72. {reader-3.16 → reader-3.17}/docs/screenshots/lynx.png +0 -0
  73. {reader-3.16 → reader-3.17}/docs/screenshots/search.png +0 -0
  74. {reader-3.16 → reader-3.17}/docs/tutorial.rst +0 -0
  75. {reader-3.16 → reader-3.17}/docs/why.rst +0 -0
  76. {reader-3.16 → reader-3.17}/examples/config.yaml +0 -0
  77. {reader-3.16 → reader-3.17}/examples/custom_headers.py +0 -0
  78. {reader-3.16 → reader-3.17}/examples/feed_slugs.py +0 -0
  79. {reader-3.16 → reader-3.17}/examples/parser_only.py +0 -0
  80. {reader-3.16 → reader-3.17}/examples/podcast.py +0 -0
  81. {reader-3.16 → reader-3.17}/examples/terminal.py +0 -0
  82. {reader-3.16 → reader-3.17}/run.sh +0 -0
  83. {reader-3.16 → reader-3.17}/scripts/.coverage +0 -0
  84. {reader-3.16 → reader-3.17}/scripts/backup.sh +0 -0
  85. {reader-3.16 → reader-3.17}/scripts/bench.py +0 -0
  86. {reader-3.16 → reader-3.17}/scripts/debug_storage_stats.py +0 -0
  87. {reader-3.16 → reader-3.17}/scripts/generate_import_all.py +0 -0
  88. {reader-3.16 → reader-3.17}/scripts/jscontrols.html +0 -0
  89. {reader-3.16 → reader-3.17}/scripts/jscontrols.py +0 -0
  90. {reader-3.16 → reader-3.17}/scripts/lines.sh +0 -0
  91. {reader-3.16 → reader-3.17}/scripts/release.py +0 -0
  92. {reader-3.16 → reader-3.17}/setup.cfg +0 -0
  93. {reader-3.16 → reader-3.17}/src/reader/__main__.py +0 -0
  94. {reader-3.16 → reader-3.17}/src/reader/_app/api_thing.py +0 -0
  95. {reader-3.16 → reader-3.17}/src/reader/_app/cli.py +0 -0
  96. {reader-3.16 → reader-3.17}/src/reader/_app/static/controls.js +0 -0
  97. {reader-3.16 → reader-3.17}/src/reader/_app/static/style.css +0 -0
  98. {reader-3.16 → reader-3.17}/src/reader/_app/templates/add_entry.html +0 -0
  99. {reader-3.16 → reader-3.17}/src/reader/_app/templates/entries.html +0 -0
  100. {reader-3.16 → reader-3.17}/src/reader/_app/templates/entry.html +0 -0
  101. {reader-3.16 → reader-3.17}/src/reader/_app/templates/feeds.html +0 -0
  102. {reader-3.16 → reader-3.17}/src/reader/_app/templates/macros.html +0 -0
  103. {reader-3.16 → reader-3.17}/src/reader/_app/templates/metadata.html +0 -0
  104. {reader-3.16 → reader-3.17}/src/reader/_app/templates/tags.html +0 -0
  105. {reader-3.16 → reader-3.17}/src/reader/_app/wsgi.py +0 -0
  106. {reader-3.16 → reader-3.17}/src/reader/_cli.py +0 -0
  107. {reader-3.16 → reader-3.17}/src/reader/_config.py +0 -0
  108. {reader-3.16 → reader-3.17}/src/reader/_hash_utils.py +0 -0
  109. {reader-3.16 → reader-3.17}/src/reader/_parser/__init__.py +0 -0
  110. {reader-3.16 → reader-3.17}/src/reader/_parser/_http_utils.py +0 -0
  111. {reader-3.16 → reader-3.17}/src/reader/_parser/_lazy.py +0 -0
  112. {reader-3.16 → reader-3.17}/src/reader/_parser/_url_utils.py +0 -0
  113. {reader-3.16 → reader-3.17}/src/reader/_parser/file.py +0 -0
  114. {reader-3.16 → reader-3.17}/src/reader/_parser/http.py +0 -0
  115. {reader-3.16 → reader-3.17}/src/reader/_parser/jsonfeed.py +0 -0
  116. {reader-3.16 → reader-3.17}/src/reader/_parser/requests/__init__.py +0 -0
  117. {reader-3.16 → reader-3.17}/src/reader/_parser/requests/_lazy.py +0 -0
  118. {reader-3.16 → reader-3.17}/src/reader/_plugins/__init__.py +0 -0
  119. {reader-3.16 → reader-3.17}/src/reader/_plugins/enclosure_tags.py +0 -0
  120. {reader-3.16 → reader-3.17}/src/reader/_plugins/preview_feed_list.py +0 -0
  121. {reader-3.16 → reader-3.17}/src/reader/_plugins/share.py +0 -0
  122. {reader-3.16 → reader-3.17}/src/reader/_plugins/sqlite_releases.py +0 -0
  123. {reader-3.16 → reader-3.17}/src/reader/_plugins/templates/preview_feed_list.html +0 -0
  124. {reader-3.16 → reader-3.17}/src/reader/_plugins/timer.py +0 -0
  125. {reader-3.16 → reader-3.17}/src/reader/_storage/__init__.py +0 -0
  126. {reader-3.16 → reader-3.17}/src/reader/_storage/_base.py +0 -0
  127. {reader-3.16 → reader-3.17}/src/reader/_storage/_changes.py +0 -0
  128. {reader-3.16 → reader-3.17}/src/reader/_storage/_entries.py +0 -0
  129. {reader-3.16 → reader-3.17}/src/reader/_storage/_feeds.py +0 -0
  130. {reader-3.16 → reader-3.17}/src/reader/_storage/_html_utils.py +0 -0
  131. {reader-3.16 → reader-3.17}/src/reader/_storage/_schema.py +0 -0
  132. {reader-3.16 → reader-3.17}/src/reader/_storage/_search.py +0 -0
  133. {reader-3.16 → reader-3.17}/src/reader/_storage/_sql_utils.py +0 -0
  134. {reader-3.16 → reader-3.17}/src/reader/_storage/_tags.py +0 -0
  135. {reader-3.16 → reader-3.17}/src/reader/_types.py +0 -0
  136. {reader-3.16 → reader-3.17}/src/reader/_update.py +0 -0
  137. {reader-3.16 → reader-3.17}/src/reader/_utils.py +0 -0
  138. {reader-3.16 → reader-3.17}/src/reader/_vendor/__init__.py +0 -0
  139. {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/__init__.py +0 -0
  140. {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/api.py +0 -0
  141. {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/datetimes/__init__.py +0 -0
  142. {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/datetimes/asctime.py +0 -0
  143. {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/datetimes/greek.py +0 -0
  144. {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/datetimes/hungarian.py +0 -0
  145. {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/datetimes/iso8601.py +0 -0
  146. {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/datetimes/korean.py +0 -0
  147. {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/datetimes/perforce.py +0 -0
  148. {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/datetimes/rfc822.py +0 -0
  149. {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/datetimes/w3dtf.py +0 -0
  150. {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/encodings.py +0 -0
  151. {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/exceptions.py +0 -0
  152. {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/html.py +0 -0
  153. {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/http.py +0 -0
  154. {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/mixin.py +0 -0
  155. {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/namespaces/__init__.py +0 -0
  156. {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/namespaces/_base.py +0 -0
  157. {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/namespaces/admin.py +0 -0
  158. {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/namespaces/cc.py +0 -0
  159. {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/namespaces/dc.py +0 -0
  160. {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/namespaces/georss.py +0 -0
  161. {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/namespaces/itunes.py +0 -0
  162. {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/namespaces/mediarss.py +0 -0
  163. {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/namespaces/psc.py +0 -0
  164. {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/parsers/__init__.py +0 -0
  165. {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/parsers/json.py +0 -0
  166. {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/parsers/loose.py +0 -0
  167. {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/parsers/strict.py +0 -0
  168. {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/py.typed +0 -0
  169. {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/sanitizer.py +0 -0
  170. {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/sgml.py +0 -0
  171. {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/urls.py +0 -0
  172. {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/util.py +0 -0
  173. {reader-3.16 → reader-3.17}/src/reader/core.py +0 -0
  174. {reader-3.16 → reader-3.17}/src/reader/exceptions.py +0 -0
  175. {reader-3.16 → reader-3.17}/src/reader/plugins/__init__.py +0 -0
  176. {reader-3.16 → reader-3.17}/src/reader/plugins/enclosure_dedupe.py +0 -0
  177. {reader-3.16 → reader-3.17}/src/reader/plugins/entry_dedupe.py +0 -0
  178. {reader-3.16 → reader-3.17}/src/reader/plugins/mark_as_read.py +0 -0
  179. {reader-3.16 → reader-3.17}/src/reader/plugins/readtime.py +0 -0
  180. {reader-3.16 → reader-3.17}/src/reader/plugins/ua_fallback.py +0 -0
  181. {reader-3.16 → reader-3.17}/src/reader/py.typed +0 -0
  182. {reader-3.16 → reader-3.17}/src/reader/utils.py +0 -0
  183. {reader-3.16 → reader-3.17}/src/reader.egg-info/dependency_links.txt +0 -0
  184. {reader-3.16 → reader-3.17}/src/reader.egg-info/top_level.txt +0 -0
  185. {reader-3.16 → reader-3.17}/tests/data/10.json +0 -0
  186. {reader-3.16 → reader-3.17}/tests/data/10.json.py +0 -0
  187. {reader-3.16 → reader-3.17}/tests/data/custom +0 -0
  188. {reader-3.16 → reader-3.17}/tests/data/empty.atom +0 -0
  189. {reader-3.16 → reader-3.17}/tests/data/empty.atom.py +0 -0
  190. {reader-3.16 → reader-3.17}/tests/data/empty.json +0 -0
  191. {reader-3.16 → reader-3.17}/tests/data/empty.json.py +0 -0
  192. {reader-3.16 → reader-3.17}/tests/data/empty.rss +0 -0
  193. {reader-3.16 → reader-3.17}/tests/data/empty.rss.py +0 -0
  194. {reader-3.16 → reader-3.17}/tests/data/full.atom +0 -0
  195. {reader-3.16 → reader-3.17}/tests/data/full.atom.py +0 -0
  196. {reader-3.16 → reader-3.17}/tests/data/full.json +0 -0
  197. {reader-3.16 → reader-3.17}/tests/data/full.json.py +0 -0
  198. {reader-3.16 → reader-3.17}/tests/data/full.rss +0 -0
  199. {reader-3.16 → reader-3.17}/tests/data/full.rss.py +0 -0
  200. {reader-3.16 → reader-3.17}/tests/data/invalid.json +0 -0
  201. {reader-3.16 → reader-3.17}/tests/data/invalid.json.py +0 -0
  202. {reader-3.16 → reader-3.17}/tests/data/relative.atom +0 -0
  203. {reader-3.16 → reader-3.17}/tests/data/relative.atom.py +0 -0
  204. {reader-3.16 → reader-3.17}/tests/data/relative.rss +0 -0
  205. {reader-3.16 → reader-3.17}/tests/data/relative.rss.py +0 -0
  206. {reader-3.16 → reader-3.17}/tests/data/sqlite_releases.html +0 -0
  207. {reader-3.16 → reader-3.17}/tests/data/unknown.json +0 -0
  208. {reader-3.16 → reader-3.17}/tests/data/unknown.json.py +0 -0
  209. {reader-3.16 → reader-3.17}/tests/reader_methods.py +0 -0
  210. {reader-3.16 → reader-3.17}/tests/reader_test_plugins/__init__.py +0 -0
  211. {reader-3.16 → reader-3.17}/tests/reader_test_plugins/good.py +0 -0
  212. {reader-3.16 → reader-3.17}/tests/reader_test_plugins/init_error.py +0 -0
  213. {reader-3.16 → reader-3.17}/tests/reader_test_plugins/missing_dependency.py +0 -0
  214. {reader-3.16 → reader-3.17}/tests/reader_test_plugins/missing_entry_point.py +0 -0
  215. {reader-3.16 → reader-3.17}/tests/test__types.py +0 -0
  216. {reader-3.16 → reader-3.17}/tests/test__utils.py +0 -0
  217. {reader-3.16 → reader-3.17}/tests/test_app_wsgi.py +0 -0
  218. {reader-3.16 → reader-3.17}/tests/test_bench.py +0 -0
  219. {reader-3.16 → reader-3.17}/tests/test_cli.py +0 -0
  220. {reader-3.16 → reader-3.17}/tests/test_config.py +0 -0
  221. {reader-3.16 → reader-3.17}/tests/test_exceptions.py +0 -0
  222. {reader-3.16 → reader-3.17}/tests/test_hash_utils.py +0 -0
  223. {reader-3.16 → reader-3.17}/tests/test_html_utils.py +0 -0
  224. {reader-3.16 → reader-3.17}/tests/test_lazy_imports.py +0 -0
  225. {reader-3.16 → reader-3.17}/tests/test_parser.py +0 -0
  226. {reader-3.16 → reader-3.17}/tests/test_plugins_preview_feed_list.py +0 -0
  227. {reader-3.16 → reader-3.17}/tests/test_plugins_sqlite_releases.py +0 -0
  228. {reader-3.16 → reader-3.17}/tests/test_plugins_ua_fallback.py +0 -0
  229. {reader-3.16 → reader-3.17}/tests/test_reader_counts.py +0 -0
  230. {reader-3.16 → reader-3.17}/tests/test_reader_deprecations.py +0 -0
  231. {reader-3.16 → reader-3.17}/tests/test_reader_filter.py +0 -0
  232. {reader-3.16 → reader-3.17}/tests/test_reader_integration.py +0 -0
  233. {reader-3.16 → reader-3.17}/tests/test_reader_plugins.py +0 -0
  234. {reader-3.16 → reader-3.17}/tests/test_search.py +0 -0
  235. {reader-3.16 → reader-3.17}/tests/test_sql_utils.py +0 -0
  236. {reader-3.16 → reader-3.17}/tests/test_sqlite_utils.py +0 -0
  237. {reader-3.16 → reader-3.17}/tests/test_test_utils.py +0 -0
@@ -6,6 +6,36 @@ Changelog
6
6
  :no-index:
7
7
 
8
8
 
9
+ Version 3.17
10
+ ------------
11
+
12
+ Released 2025-03-08
13
+
14
+ .. attention::
15
+
16
+ This is the last release to support Python 3.10;
17
+ see :issue:`354` for details.
18
+
19
+ * Support PyPy 3.11. (:issue:`354`)
20
+
21
+ * Start working on a web app :ref:`re-design <app roadmap>`
22
+ based on `htmx`_ and `Bootstrap`_,
23
+ beginning with a new version of the main entries page,
24
+ and dark mode support;
25
+ some :ref:`screenshots <app screenshots>`.
26
+ (:issue:`318`)
27
+
28
+ * :attr:`Entry.feed_resolved_title`:
29
+ return both the source and feed titles only if they are different.
30
+
31
+ * The :mod:`~reader._plugins.cli_status` plugin
32
+ now creates only one entry per command,
33
+ with the newest run first.
34
+
35
+ .. _htmx: https://htmx.org/
36
+ .. _Bootstrap: https://getbootstrap.com/
37
+
38
+
9
39
  Version 3.16
10
40
  ------------
11
41
 
@@ -1,5 +1,6 @@
1
1
  recursive-include src/reader/_app/templates *
2
2
  recursive-include src/reader/_app/static *
3
+ recursive-include src/reader/_app/v2 *
3
4
  recursive-include src/reader/_plugins/templates *
4
5
  recursive-include src/reader *.pyi
5
6
  recursive-include src/reader py.typed
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.2
2
2
  Name: reader
3
- Version: 3.16
3
+ Version: 3.17
4
4
  Summary: A Python feed reader library.
5
5
  Author: lemon24
6
6
  License: BSD-3-Clause
@@ -50,6 +50,8 @@ Provides-Extra: app
50
50
  Requires-Dist: flask>=0.10; extra == "app"
51
51
  Requires-Dist: humanize!=4.7.*,>=4; extra == "app"
52
52
  Requires-Dist: PyYAML; extra == "app"
53
+ Requires-Dist: WTForms; extra == "app"
54
+ Requires-Dist: jinja2-fragments; extra == "app"
53
55
  Provides-Extra: unstable-plugins
54
56
  Requires-Dist: requests; extra == "unstable-plugins"
55
57
  Requires-Dist: mutagen; extra == "unstable-plugins"
Binary file
@@ -67,9 +67,34 @@ If running on a personal computer, you can use cron to run ``serve`` at boot::
67
67
  .. _Werkzeug's development server: http://werkzeug.pocoo.org/docs/0.14/serving/#werkzeug.serving.run_simple
68
68
 
69
69
 
70
+ .. _app screenshots:
71
+
70
72
  Screenshots
71
73
  -----------
72
74
 
75
+ Following are screenshots of the web app re-design
76
+ mentioned in the :ref:`roadmap <app roadmap>`.
77
+ For the legacy web app, see `Screenshots (legacy)`_.
78
+
79
+ Main page
80
+ ~~~~~~~~~
81
+
82
+ .. figure:: screenshots/entries-v2-dark.png
83
+ :width: 240px
84
+
85
+ main page (dark mode)
86
+
87
+ .. figure:: screenshots/entries-v2-filters-light.png
88
+ :width: 240px
89
+
90
+ main page – more filters (light mode)
91
+
92
+
93
+ Screenshots (legacy)
94
+ --------------------
95
+
96
+ Following are screenshots of the original (legacy) web app.
97
+
73
98
  Main page
74
99
  ~~~~~~~~~
75
100
 
@@ -122,8 +122,14 @@ Long term, I'd like to:
122
122
  * switch to `htmx`_ instead of using a home-grown solution
123
123
  * spin it off into a separate package/project
124
124
 
125
- .. _htmx: https://htmx.org/
125
+ **2025 update**:
126
+ I've started working on a re-design based on `htmx`_ and `Bootstrap`_;
127
+ some :ref:`screenshots <app screenshots>`.
128
+ The new app will be available in parallel with the old one
129
+ until it reaches feature parity.
126
130
 
131
+ .. _htmx: https://htmx.org/
132
+ .. _Bootstrap: https://getbootstrap.com/
127
133
 
128
134
 
129
135
  .. _compat:
@@ -362,6 +368,8 @@ Speeding up ``get_entry_counts(feed=...)``:
362
368
  * having an index on entries(feed) yielded a 4x improvement: :issue:`251`
363
369
  * even better, we should cache commonly-used counts: :issue:`306#issuecomment-1694655504`
364
370
 
371
+ Official guidance: https://www.sqlite.org/queryplanner-ng.html#howtofix
372
+
365
373
 
366
374
  Parser
367
375
  ~~~~~~
@@ -663,6 +671,8 @@ Why I want to postpone batch update/set methods:
663
671
  tl:dr: Performance is likely a non-issue with SQLite,
664
672
  convenience can be added on top as a plugin.
665
673
 
674
+ (2025) Why it may be worth adding batch interfaces anyway (even if underneath the storage implementation doesn't actually batch) – it allows for future optimization: https://blog.glyph.im/2022/12/potato-programming.html
675
+
666
676
  See the 2.12 reader._app.ResourceTags class for an idea of how to
667
677
  represent a bunch of tags in a reserved-name-scheme-agnostic way
668
678
  (useful e.g. for when get_entries() should return tags x, y, z of each entry).
@@ -77,6 +77,9 @@ app = [
77
77
  "humanize>=4,!=4.7.*",
78
78
  # for config
79
79
  "PyYAML",
80
+ # for v2
81
+ "WTForms",
82
+ "jinja2-fragments",
80
83
  ]
81
84
 
82
85
  # UNSTABLE PLUGINS
@@ -37,7 +37,7 @@ Print the titles of the unread entries::
37
37
 
38
38
  """
39
39
 
40
- __version__ = '3.16'
40
+ __version__ = '3.17'
41
41
 
42
42
  # isort: off
43
43
 
@@ -105,6 +105,7 @@ def get_reader():
105
105
  def stream_template(template_name_or_list, **kwargs):
106
106
  template = current_app.jinja_env.get_template(template_name_or_list)
107
107
  stream = template.stream(**kwargs)
108
+ # TODO: increase to at least 1-2k, like this we have 50% overhead
108
109
  stream.enable_buffering(50)
109
110
  return Response(stream_with_context(stream))
110
111
 
@@ -836,6 +837,10 @@ def create_app(config):
836
837
 
837
838
  app.register_blueprint(blueprint)
838
839
 
840
+ from . import v2
841
+
842
+ app.register_blueprint(v2.blueprint, url_prefix='/v2')
843
+
839
844
  # NOTE: this is part of the app extension API
840
845
  app.reader_additional_enclosure_links = []
841
846
  app.reader_additional_links = []
@@ -21,6 +21,7 @@ window.onload = function () {
21
21
  <a href="{{ url_for('reader.feeds') }}">feeds</a>
22
22
  <a href="{{ url_for('reader.tags') }}">tags</a>
23
23
  <a href="{{ url_for('reader.metadata') }}">metadata</a>
24
+ <a href="{{ url_for('v2.entries') }}">v2</a>
24
25
 
25
26
  {{ macros.text_input_button_get(
26
27
  'reader.preview', 'add feed', 'url', 'url',
@@ -0,0 +1,123 @@
1
+ import itertools
2
+ from functools import partial
3
+
4
+ from flask import abort
5
+ from flask import Blueprint
6
+ from flask import current_app
7
+ from flask import redirect
8
+ from flask import request
9
+ from flask import url_for
10
+ from jinja2_fragments.flask import render_block
11
+
12
+ from reader import InvalidSearchQueryError
13
+
14
+ from .. import EntryProxy
15
+ from .. import get_reader
16
+ from .. import stream_template
17
+ from .forms import EntryFilter
18
+ from .forms import SearchEntryFilter
19
+
20
+
21
+ blueprint = Blueprint(
22
+ 'v2', __name__, template_folder='templates', static_folder='static'
23
+ )
24
+
25
+
26
+ @blueprint.route('/')
27
+ def entries():
28
+ reader = get_reader()
29
+
30
+ # TODO: search improvements
31
+ # TODO: paqgination
32
+ # TODO: read time
33
+
34
+ if request.args.get('q', '').strip():
35
+ form = SearchEntryFilter(request.args)
36
+ else:
37
+ form = EntryFilter(request.args)
38
+
39
+ form_args = form.args
40
+ if q := form_args.pop('Q', ''):
41
+ form_args['q'] = q
42
+ return redirect(url_for('.entries', **form_args))
43
+ if form_args != request.args.to_dict():
44
+ return redirect(url_for('.entries', **form_args))
45
+
46
+ feed = None
47
+ if form.feed.data:
48
+ feed = reader.get_feed(form.feed.data, None)
49
+ if not feed:
50
+ abort(404)
51
+
52
+ kwargs = dict(form.data)
53
+ if query := kwargs.pop('search', None):
54
+
55
+ def get_entries(**kwargs):
56
+ for sr in reader.search_entries(query, **kwargs):
57
+ yield EntryProxy(sr, reader.get_entry(sr))
58
+
59
+ else:
60
+ get_entries = reader.get_entries
61
+
62
+ entries = []
63
+ if form.validate():
64
+ try:
65
+ entries = eager_iterator(get_entries(**kwargs, limit=64))
66
+ except StopIteration:
67
+ pass
68
+ except InvalidSearchQueryError as e:
69
+ form.search.errors.append(f"invalid query: {e}")
70
+
71
+ return stream_template(
72
+ 'v2/entries.html',
73
+ form=form,
74
+ entries=entries,
75
+ feed=feed,
76
+ )
77
+
78
+
79
+ def eager_iterator(it):
80
+ it = iter(it)
81
+ try:
82
+ return itertools.chain([next(it)], it)
83
+ except StopIteration:
84
+ return it
85
+
86
+
87
+ @blueprint.route('/mark-as', methods=['POST'])
88
+ def mark_as():
89
+ reader = get_reader()
90
+
91
+ entry = request.form['feed-url'], request.form['entry-id']
92
+
93
+ if 'read' in request.form:
94
+ match request.form['read']:
95
+ case 'true':
96
+ reader.set_entry_read(entry, True)
97
+ case 'false':
98
+ reader.set_entry_read(entry, False)
99
+ case _:
100
+ abort(422)
101
+
102
+ if 'important' in request.form:
103
+ match request.form['important']:
104
+ case 'true':
105
+ reader.set_entry_important(entry, True)
106
+ case 'false':
107
+ reader.set_entry_important(entry, False)
108
+ case 'none':
109
+ reader.set_entry_important(entry, None)
110
+ case _:
111
+ abort(422)
112
+
113
+ if request.headers.get('hx-request') == 'true':
114
+ return render_block(
115
+ 'v2/entries.html',
116
+ 'entry_form',
117
+ entry=reader.get_entry(entry),
118
+ next=request.form['next'],
119
+ # equivalent to {% import "v2/macros.html" as macros %}
120
+ macros=current_app.jinja_env.get_template('v2/macros.html').module,
121
+ )
122
+
123
+ return redirect(request.form['next'], code=303)
@@ -0,0 +1,159 @@
1
+ from dataclasses import dataclass
2
+
3
+ import yaml
4
+ from wtforms import Form
5
+ from wtforms import HiddenField
6
+ from wtforms import RadioField
7
+ from wtforms import SearchField
8
+ from wtforms import StringField
9
+
10
+ from reader._types import tag_filter_argument
11
+
12
+
13
+ class TagFilterField(StringField):
14
+
15
+ def process_formdata(self, valuelist):
16
+ if not valuelist:
17
+ return
18
+ value = valuelist[0]
19
+ if '[' not in value:
20
+ value = f'[{value}]'
21
+ try:
22
+ data = yaml.safe_load(value)
23
+ except yaml.error.MarkedYAMLError as e:
24
+ raise ValueError(f"invalid YAML: {e.problem or e.context}") from e
25
+ tag_filter_argument(data)
26
+ self.data = data
27
+
28
+ def _value(self):
29
+ if self.raw_data:
30
+ return self.raw_data[0]
31
+ if not self.data:
32
+ return ''
33
+ return yaml.safe_dump(self.data, default_flow_style=True).rstrip()
34
+
35
+
36
+ class PresetsMixin:
37
+
38
+ @property
39
+ def presets(self):
40
+ form_args = {}
41
+ form_preset_args = {}
42
+ for field in self:
43
+ value = get_formdata(field)
44
+ form_args[field.name] = value
45
+ if field.name in self.PRESET_FIELDS:
46
+ form_preset_args[field.name] = value
47
+
48
+ for name, args_raw in self.PRESETS.items():
49
+ args = {}
50
+ preset_args = {}
51
+ for field in self:
52
+
53
+ if field.name in self.PRESET_FIELDS:
54
+ value = args_raw.get(field.name, field.default)
55
+ preset_args[field.name] = value
56
+ else:
57
+ value = form_args[field.name]
58
+
59
+ if value and (not field.default or value != field.default):
60
+ args[field.name] = value
61
+
62
+ yield Preset(name, args, preset_args == form_preset_args)
63
+
64
+ @property
65
+ def active_presets(self):
66
+ return [p for p in self.presets if p.active]
67
+
68
+ @property
69
+ def args(self):
70
+ rv = {}
71
+ for field in self:
72
+ value = get_formdata(field)
73
+ if value and (not field.default or value != field.default):
74
+ rv[field.name] = value
75
+ return rv
76
+
77
+
78
+ @dataclass
79
+ class Preset:
80
+ name: str
81
+ args: dict[str, str]
82
+ active: bool = False
83
+
84
+
85
+ def get_formdata(field):
86
+ try:
87
+ return field._value()
88
+ except AttributeError:
89
+ values = [option._value() for option in field if option.checked]
90
+ if values:
91
+ value, *rest = values
92
+ if rest:
93
+ raise NotImplementedError("multiple choices not supported") from None
94
+ return value
95
+ return field.default
96
+
97
+
98
+ def radio_field(*args, choices, **kwargs):
99
+ """Like RadioField, but choices is a list of (value, value_str),
100
+ (value, value_str, label), or (value, value_str, label, render_kw) tuples.
101
+
102
+ """
103
+ return RadioField(
104
+ *args,
105
+ choices=[c[1] if len(c) == 2 else c[1:] for c in choices],
106
+ coerce={c[1]: c[0] for c in choices}.get,
107
+ **kwargs,
108
+ )
109
+
110
+
111
+ BOOL_CHOICES = [(True, 'yes'), (False, 'no'), (None, 'all')]
112
+ TRISTATE_CHOICES = [('notfalse', 'maybe')] + BOOL_CHOICES
113
+ ENTRY_SORT_CHOICES = ['recent', 'random']
114
+
115
+
116
+ class EntryFilter(PresetsMixin, Form):
117
+ feed = HiddenField("feed")
118
+ search = SearchField("search", name='Q')
119
+ feed_tags = TagFilterField("tags", name='tags')
120
+ read = radio_field("read", choices=BOOL_CHOICES, default='no')
121
+ important = radio_field("important", choices=TRISTATE_CHOICES, default='maybe')
122
+ has_enclosures = radio_field(
123
+ "enclosures", name='enclosures', choices=BOOL_CHOICES, default='all'
124
+ )
125
+ sort = RadioField("sort", choices=ENTRY_SORT_CHOICES, default='recent')
126
+
127
+ PRESET_FIELDS = {'read', 'important', 'enclosures', 'sort'}
128
+ PRESETS = {
129
+ 'unread': {},
130
+ 'important': {'read': 'all', 'important': 'yes'},
131
+ 'podcast': {'enclosures': 'yes'},
132
+ 'random': {'sort': 'random'},
133
+ }
134
+
135
+
136
+ class SearchEntryFilter(EntryFilter):
137
+ search = SearchField("search", name='q')
138
+ sort = RadioField(
139
+ "sort", choices=['relevant'] + ENTRY_SORT_CHOICES, default='relevant'
140
+ )
141
+
142
+
143
+ if __name__ == '__main__':
144
+ from werkzeug.datastructures import MultiDict
145
+
146
+ args = MultiDict(dict(tags='1'))
147
+ for FormCls in EntryFilter, SearchEntryFilter:
148
+ form = FormCls(args)
149
+ for field in form:
150
+ print(field())
151
+ print()
152
+ print(form.data)
153
+ print(form.to_formdata())
154
+ print()
155
+
156
+ print(form.feed_tags.__dict__)
157
+ import IPython
158
+
159
+ IPython.embed()
@@ -0,0 +1,27 @@
1
+
2
+ .navbar {
3
+ --bs-navbar-padding-y: 0.25rem;
4
+ --bs-navbar-toggler-padding-y: 0.25rem;
5
+ --bs-navbar-toggler-padding-x: 0.25rem;
6
+ --bs-navbar-toggler-font-size: 1rem;
7
+ --bs-navbar-toggler-border-color: rgba(0, 0, 0, 0);
8
+ }
9
+
10
+ .nav.controls {
11
+ --bs-nav-link-padding-x: 0;
12
+ --bs-nav-link-padding-y: 0;
13
+ gap: 1rem;
14
+ }
15
+ .nav.controls .nav-link.active {
16
+ color: var(--bs-navbar-active-color);
17
+ }
18
+
19
+ .htmx-indicator {
20
+ display: none;
21
+ }
22
+ .htmx-request .htmx-indicator, .htmx-request.htmx-indicator {
23
+ display: inline-block;
24
+ }
25
+ .htmx-request .label, .htmx-request.label {
26
+ display: none;
27
+ }
@@ -0,0 +1,91 @@
1
+ /*!
2
+ * Color mode toggler for Bootstrap's docs (https://getbootstrap.com/)
3
+ * Copyright 2011-2024 The Bootstrap Authors
4
+ * Licensed under the Creative Commons Attribution 3.0 Unported License.
5
+ */
6
+
7
+ /*
8
+ * Modified to use the Bootstrap Icons font, instead of SVG sprites.
9
+ */
10
+
11
+ (() => {
12
+ 'use strict'
13
+
14
+ const getStoredTheme = () => localStorage.getItem('theme')
15
+ const setStoredTheme = theme => localStorage.setItem('theme', theme)
16
+
17
+ const getPreferredTheme = () => {
18
+ const storedTheme = getStoredTheme()
19
+ if (storedTheme) {
20
+ return storedTheme
21
+ }
22
+
23
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
24
+ }
25
+
26
+ const setTheme = theme => {
27
+ if (theme === 'auto') {
28
+ document.documentElement.setAttribute('data-bs-theme', (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'))
29
+ } else {
30
+ document.documentElement.setAttribute('data-bs-theme', theme)
31
+ }
32
+ }
33
+
34
+ const getIconCls = btn => {
35
+ return btn.querySelector('.bi').classList.values().find(x => x.startsWith('bi-'))
36
+ }
37
+
38
+ setTheme(getPreferredTheme())
39
+
40
+ const showActiveTheme = (theme, focus = false) => {
41
+ const themeSwitcher = document.querySelector('#theme')
42
+
43
+ if (!themeSwitcher) {
44
+ return
45
+ }
46
+
47
+ const themeSwitcherText = document.querySelector('#theme-text')
48
+ const activeThemeIcon = document.querySelector('.theme-icon-active')
49
+ const btnToActive = document.querySelector(`[data-bs-theme-value="${theme}"]`)
50
+ const clsOfActiveBtn = btnToActive.querySelector('.bi').classList.values().find(x => x.startsWith('bi-'))
51
+
52
+ document.querySelectorAll('[data-bs-theme-value]').forEach(element => {
53
+ element.classList.remove('active')
54
+ element.setAttribute('aria-pressed', 'false')
55
+ })
56
+
57
+ btnToActive.classList.add('active')
58
+ btnToActive.setAttribute('aria-pressed', 'true')
59
+ activeThemeIcon.classList.remove(
60
+ activeThemeIcon.classList.values().find(x => x.startsWith('bi-'))
61
+ )
62
+ activeThemeIcon.classList.add(clsOfActiveBtn)
63
+ const themeSwitcherLabel = `${themeSwitcherText.textContent} (${btnToActive.dataset.bsThemeValue})`
64
+ themeSwitcher.setAttribute('aria-label', themeSwitcherLabel)
65
+
66
+ if (focus) {
67
+ themeSwitcher.focus()
68
+ }
69
+ }
70
+
71
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
72
+ const storedTheme = getStoredTheme()
73
+ if (storedTheme !== 'light' && storedTheme !== 'dark') {
74
+ setTheme(getPreferredTheme())
75
+ }
76
+ })
77
+
78
+ window.addEventListener('DOMContentLoaded', () => {
79
+ showActiveTheme(getPreferredTheme())
80
+
81
+ document.querySelectorAll('[data-bs-theme-value]')
82
+ .forEach(toggle => {
83
+ toggle.addEventListener('click', () => {
84
+ const theme = toggle.getAttribute('data-bs-theme-value')
85
+ setStoredTheme(theme)
86
+ setTheme(theme)
87
+ showActiveTheme(theme, true)
88
+ })
89
+ })
90
+ })
91
+ })()