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.
- {reader-3.16 → reader-3.17}/CHANGES.rst +30 -0
- {reader-3.16 → reader-3.17}/MANIFEST.in +1 -0
- {reader-3.16/src/reader.egg-info → reader-3.17}/PKG-INFO +4 -2
- reader-3.17/docs/.DS_Store +0 -0
- {reader-3.16 → reader-3.17}/docs/app.rst +25 -0
- {reader-3.16 → reader-3.17}/docs/dev.rst +11 -1
- reader-3.17/docs/screenshots/entries-v2-dark.png +0 -0
- reader-3.17/docs/screenshots/entries-v2-filters-light.png +0 -0
- {reader-3.16 → reader-3.17}/pyproject.toml +3 -0
- {reader-3.16 → reader-3.17}/src/reader/__init__.py +1 -1
- {reader-3.16 → reader-3.17}/src/reader/_app/__init__.py +5 -0
- {reader-3.16 → reader-3.17}/src/reader/_app/templates/layout.html +1 -0
- reader-3.17/src/reader/_app/v2/__init__.py +123 -0
- reader-3.17/src/reader/_app/v2/forms.py +159 -0
- reader-3.17/src/reader/_app/v2/static/style.css +27 -0
- reader-3.17/src/reader/_app/v2/static/theme.js +91 -0
- reader-3.17/src/reader/_app/v2/templates/v2/entries.html +232 -0
- reader-3.17/src/reader/_app/v2/templates/v2/layout.html +116 -0
- reader-3.17/src/reader/_app/v2/templates/v2/macros.html +47 -0
- {reader-3.16 → reader-3.17}/src/reader/_parser/feedparser.py +2 -2
- {reader-3.16 → reader-3.17}/src/reader/_plugins/cli_status.py +25 -14
- {reader-3.16 → reader-3.17}/src/reader/_storage/_sqlite_utils.py +3 -1
- {reader-3.16 → reader-3.17}/src/reader/types.py +8 -1
- {reader-3.16 → reader-3.17/src/reader.egg-info}/PKG-INFO +4 -2
- {reader-3.16 → reader-3.17}/src/reader.egg-info/SOURCES.txt +10 -0
- {reader-3.16 → reader-3.17}/src/reader.egg-info/requires.txt +2 -0
- {reader-3.16 → reader-3.17}/tests/conftest.py +15 -1
- {reader-3.16 → reader-3.17}/tests/fakeparser.py +7 -31
- {reader-3.16 → reader-3.17}/tests/test_app.py +11 -29
- {reader-3.16 → reader-3.17}/tests/test_changes.py +15 -17
- {reader-3.16 → reader-3.17}/tests/test_plugins_cli_status.py +36 -27
- {reader-3.16 → reader-3.17}/tests/test_plugins_enclosure_dedupe.py +1 -3
- {reader-3.16 → reader-3.17}/tests/test_plugins_entry_dedupe.py +14 -21
- {reader-3.16 → reader-3.17}/tests/test_plugins_mark_as_read.py +9 -21
- {reader-3.16 → reader-3.17}/tests/test_plugins_readtime.py +4 -13
- {reader-3.16 → reader-3.17}/tests/test_reader.py +78 -194
- {reader-3.16 → reader-3.17}/tests/test_reader_context.py +1 -3
- {reader-3.16 → reader-3.17}/tests/test_reader_hooks.py +3 -9
- {reader-3.16 → reader-3.17}/tests/test_reader_private.py +11 -13
- {reader-3.16 → reader-3.17}/tests/test_reader_search.py +18 -49
- {reader-3.16 → reader-3.17}/tests/test_reader_sort.py +8 -10
- {reader-3.16 → reader-3.17}/tests/test_reader_update.py +17 -25
- {reader-3.16 → reader-3.17}/tests/test_reader_utils.py +1 -3
- {reader-3.16 → reader-3.17}/tests/test_storage.py +6 -10
- {reader-3.16 → reader-3.17}/tests/test_tags.py +5 -10
- {reader-3.16 → reader-3.17}/tests/test_types.py +31 -0
- {reader-3.16 → reader-3.17}/tests/utils.py +34 -0
- {reader-3.16 → reader-3.17}/tox.ini +3 -3
- {reader-3.16 → reader-3.17}/LICENSE +0 -0
- {reader-3.16 → reader-3.17}/README.rst +0 -0
- {reader-3.16 → reader-3.17}/docs/Makefile +0 -0
- {reader-3.16 → reader-3.17}/docs/api.rst +0 -0
- {reader-3.16 → reader-3.17}/docs/changelog.rst +0 -0
- {reader-3.16 → reader-3.17}/docs/cli.rst +0 -0
- {reader-3.16 → reader-3.17}/docs/conf.py +0 -0
- {reader-3.16 → reader-3.17}/docs/config.rst +0 -0
- {reader-3.16 → reader-3.17}/docs/contributing.rst +0 -0
- {reader-3.16 → reader-3.17}/docs/dev-app.rst +0 -0
- {reader-3.16 → reader-3.17}/docs/guide.rst +0 -0
- {reader-3.16 → reader-3.17}/docs/images/redesign-01.png +0 -0
- {reader-3.16 → reader-3.17}/docs/index.rst +0 -0
- {reader-3.16 → reader-3.17}/docs/install.rst +0 -0
- {reader-3.16 → reader-3.17}/docs/internal.rst +0 -0
- {reader-3.16 → reader-3.17}/docs/make.bat +0 -0
- {reader-3.16 → reader-3.17}/docs/plugins.rst +0 -0
- {reader-3.16 → reader-3.17}/docs/screenshots/dillo.png +0 -0
- {reader-3.16 → reader-3.17}/docs/screenshots/entries-feed.png +0 -0
- {reader-3.16 → reader-3.17}/docs/screenshots/entries.png +0 -0
- {reader-3.16 → reader-3.17}/docs/screenshots/entry-one.png +0 -0
- {reader-3.16 → reader-3.17}/docs/screenshots/entry-two.png +0 -0
- {reader-3.16 → reader-3.17}/docs/screenshots/feeds.png +0 -0
- {reader-3.16 → reader-3.17}/docs/screenshots/lynx.png +0 -0
- {reader-3.16 → reader-3.17}/docs/screenshots/search.png +0 -0
- {reader-3.16 → reader-3.17}/docs/tutorial.rst +0 -0
- {reader-3.16 → reader-3.17}/docs/why.rst +0 -0
- {reader-3.16 → reader-3.17}/examples/config.yaml +0 -0
- {reader-3.16 → reader-3.17}/examples/custom_headers.py +0 -0
- {reader-3.16 → reader-3.17}/examples/feed_slugs.py +0 -0
- {reader-3.16 → reader-3.17}/examples/parser_only.py +0 -0
- {reader-3.16 → reader-3.17}/examples/podcast.py +0 -0
- {reader-3.16 → reader-3.17}/examples/terminal.py +0 -0
- {reader-3.16 → reader-3.17}/run.sh +0 -0
- {reader-3.16 → reader-3.17}/scripts/.coverage +0 -0
- {reader-3.16 → reader-3.17}/scripts/backup.sh +0 -0
- {reader-3.16 → reader-3.17}/scripts/bench.py +0 -0
- {reader-3.16 → reader-3.17}/scripts/debug_storage_stats.py +0 -0
- {reader-3.16 → reader-3.17}/scripts/generate_import_all.py +0 -0
- {reader-3.16 → reader-3.17}/scripts/jscontrols.html +0 -0
- {reader-3.16 → reader-3.17}/scripts/jscontrols.py +0 -0
- {reader-3.16 → reader-3.17}/scripts/lines.sh +0 -0
- {reader-3.16 → reader-3.17}/scripts/release.py +0 -0
- {reader-3.16 → reader-3.17}/setup.cfg +0 -0
- {reader-3.16 → reader-3.17}/src/reader/__main__.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_app/api_thing.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_app/cli.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_app/static/controls.js +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_app/static/style.css +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_app/templates/add_entry.html +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_app/templates/entries.html +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_app/templates/entry.html +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_app/templates/feeds.html +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_app/templates/macros.html +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_app/templates/metadata.html +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_app/templates/tags.html +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_app/wsgi.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_cli.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_config.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_hash_utils.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_parser/__init__.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_parser/_http_utils.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_parser/_lazy.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_parser/_url_utils.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_parser/file.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_parser/http.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_parser/jsonfeed.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_parser/requests/__init__.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_parser/requests/_lazy.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_plugins/__init__.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_plugins/enclosure_tags.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_plugins/preview_feed_list.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_plugins/share.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_plugins/sqlite_releases.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_plugins/templates/preview_feed_list.html +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_plugins/timer.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_storage/__init__.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_storage/_base.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_storage/_changes.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_storage/_entries.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_storage/_feeds.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_storage/_html_utils.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_storage/_schema.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_storage/_search.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_storage/_sql_utils.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_storage/_tags.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_types.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_update.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_utils.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_vendor/__init__.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/__init__.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/api.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/datetimes/__init__.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/datetimes/asctime.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/datetimes/greek.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/datetimes/hungarian.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/datetimes/iso8601.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/datetimes/korean.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/datetimes/perforce.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/datetimes/rfc822.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/datetimes/w3dtf.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/encodings.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/exceptions.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/html.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/http.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/mixin.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/namespaces/__init__.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/namespaces/_base.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/namespaces/admin.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/namespaces/cc.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/namespaces/dc.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/namespaces/georss.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/namespaces/itunes.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/namespaces/mediarss.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/namespaces/psc.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/parsers/__init__.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/parsers/json.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/parsers/loose.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/parsers/strict.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/py.typed +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/sanitizer.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/sgml.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/urls.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/_vendor/feedparser/util.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/core.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/exceptions.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/plugins/__init__.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/plugins/enclosure_dedupe.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/plugins/entry_dedupe.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/plugins/mark_as_read.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/plugins/readtime.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/plugins/ua_fallback.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader/py.typed +0 -0
- {reader-3.16 → reader-3.17}/src/reader/utils.py +0 -0
- {reader-3.16 → reader-3.17}/src/reader.egg-info/dependency_links.txt +0 -0
- {reader-3.16 → reader-3.17}/src/reader.egg-info/top_level.txt +0 -0
- {reader-3.16 → reader-3.17}/tests/data/10.json +0 -0
- {reader-3.16 → reader-3.17}/tests/data/10.json.py +0 -0
- {reader-3.16 → reader-3.17}/tests/data/custom +0 -0
- {reader-3.16 → reader-3.17}/tests/data/empty.atom +0 -0
- {reader-3.16 → reader-3.17}/tests/data/empty.atom.py +0 -0
- {reader-3.16 → reader-3.17}/tests/data/empty.json +0 -0
- {reader-3.16 → reader-3.17}/tests/data/empty.json.py +0 -0
- {reader-3.16 → reader-3.17}/tests/data/empty.rss +0 -0
- {reader-3.16 → reader-3.17}/tests/data/empty.rss.py +0 -0
- {reader-3.16 → reader-3.17}/tests/data/full.atom +0 -0
- {reader-3.16 → reader-3.17}/tests/data/full.atom.py +0 -0
- {reader-3.16 → reader-3.17}/tests/data/full.json +0 -0
- {reader-3.16 → reader-3.17}/tests/data/full.json.py +0 -0
- {reader-3.16 → reader-3.17}/tests/data/full.rss +0 -0
- {reader-3.16 → reader-3.17}/tests/data/full.rss.py +0 -0
- {reader-3.16 → reader-3.17}/tests/data/invalid.json +0 -0
- {reader-3.16 → reader-3.17}/tests/data/invalid.json.py +0 -0
- {reader-3.16 → reader-3.17}/tests/data/relative.atom +0 -0
- {reader-3.16 → reader-3.17}/tests/data/relative.atom.py +0 -0
- {reader-3.16 → reader-3.17}/tests/data/relative.rss +0 -0
- {reader-3.16 → reader-3.17}/tests/data/relative.rss.py +0 -0
- {reader-3.16 → reader-3.17}/tests/data/sqlite_releases.html +0 -0
- {reader-3.16 → reader-3.17}/tests/data/unknown.json +0 -0
- {reader-3.16 → reader-3.17}/tests/data/unknown.json.py +0 -0
- {reader-3.16 → reader-3.17}/tests/reader_methods.py +0 -0
- {reader-3.16 → reader-3.17}/tests/reader_test_plugins/__init__.py +0 -0
- {reader-3.16 → reader-3.17}/tests/reader_test_plugins/good.py +0 -0
- {reader-3.16 → reader-3.17}/tests/reader_test_plugins/init_error.py +0 -0
- {reader-3.16 → reader-3.17}/tests/reader_test_plugins/missing_dependency.py +0 -0
- {reader-3.16 → reader-3.17}/tests/reader_test_plugins/missing_entry_point.py +0 -0
- {reader-3.16 → reader-3.17}/tests/test__types.py +0 -0
- {reader-3.16 → reader-3.17}/tests/test__utils.py +0 -0
- {reader-3.16 → reader-3.17}/tests/test_app_wsgi.py +0 -0
- {reader-3.16 → reader-3.17}/tests/test_bench.py +0 -0
- {reader-3.16 → reader-3.17}/tests/test_cli.py +0 -0
- {reader-3.16 → reader-3.17}/tests/test_config.py +0 -0
- {reader-3.16 → reader-3.17}/tests/test_exceptions.py +0 -0
- {reader-3.16 → reader-3.17}/tests/test_hash_utils.py +0 -0
- {reader-3.16 → reader-3.17}/tests/test_html_utils.py +0 -0
- {reader-3.16 → reader-3.17}/tests/test_lazy_imports.py +0 -0
- {reader-3.16 → reader-3.17}/tests/test_parser.py +0 -0
- {reader-3.16 → reader-3.17}/tests/test_plugins_preview_feed_list.py +0 -0
- {reader-3.16 → reader-3.17}/tests/test_plugins_sqlite_releases.py +0 -0
- {reader-3.16 → reader-3.17}/tests/test_plugins_ua_fallback.py +0 -0
- {reader-3.16 → reader-3.17}/tests/test_reader_counts.py +0 -0
- {reader-3.16 → reader-3.17}/tests/test_reader_deprecations.py +0 -0
- {reader-3.16 → reader-3.17}/tests/test_reader_filter.py +0 -0
- {reader-3.16 → reader-3.17}/tests/test_reader_integration.py +0 -0
- {reader-3.16 → reader-3.17}/tests/test_reader_plugins.py +0 -0
- {reader-3.16 → reader-3.17}/tests/test_search.py +0 -0
- {reader-3.16 → reader-3.17}/tests/test_sql_utils.py +0 -0
- {reader-3.16 → reader-3.17}/tests/test_sqlite_utils.py +0 -0
- {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,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.2
|
|
2
2
|
Name: reader
|
|
3
|
-
Version: 3.
|
|
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
|
-
|
|
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).
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
+
})()
|