├── .github └── workflows │ ├── ci.yml │ └── pypi.yml ├── .gitignore ├── .nvmrc ├── .yvmrc ├── LICENSE ├── MANIFEST.in ├── README.rst ├── docs ├── commands.rst ├── conf.py ├── contributing.rst ├── index.rst ├── installation.rst ├── upgrading.rst └── usage.rst ├── example ├── README.rst ├── example │ ├── __init__.py │ ├── context_processors.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── manage.py └── templates │ └── base.html ├── frontend ├── components │ ├── _index.scss │ ├── control │ │ ├── _index.scss │ │ ├── icons │ │ │ ├── arrow-down-s-line.svg │ │ │ ├── arrow-left-s-line.svg │ │ │ ├── arrow-right-s-line.svg │ │ │ ├── arrow-up-s-line.svg │ │ │ ├── article-line.svg │ │ │ ├── eye-line.svg │ │ │ ├── list-check-2.svg │ │ │ ├── mail-line.svg │ │ │ ├── mail-star-line.svg │ │ │ ├── mail-unread-line.svg │ │ │ ├── rss-line.svg │ │ │ ├── settings-3-line.svg │ │ │ ├── sort-asc.svg │ │ │ └── sort-desc.svg │ │ └── index.js │ ├── entry │ │ ├── _index.scss │ │ ├── _list.scss │ │ └── _styles.scss │ ├── feed │ │ └── _index.scss │ ├── sidebar │ │ ├── _index.scss │ │ └── icons │ │ │ ├── add-circle-line.svg │ │ │ └── tools-fill.svg │ └── status │ │ ├── _index.scss │ │ └── index.js ├── index.js ├── index.scss ├── layout │ ├── _common.scss │ ├── _index.scss │ └── _ui.scss ├── pages │ ├── _feeds.scss │ ├── _index.scss │ ├── index.js │ └── list_entries.js ├── utils │ ├── _index.scss │ ├── constants.js │ ├── multiton.js │ ├── styles │ │ ├── _colours.scss │ │ ├── _fonts.scss │ │ ├── _index.scss │ │ ├── _static.scss │ │ └── _variables.scss │ └── stylesheet.js └── yarr.js ├── package.json ├── requirements.in ├── requirements.txt ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── apps.py ├── feed1-wellformed.xml ├── feed2-malformed.xml ├── feed4-with-img.xml ├── settings.py ├── test_opml.py ├── test_yarr.py └── urls.py ├── tox.ini ├── webpack.config.js ├── yarn.lock └── yarr ├── __init__.py ├── admin.py ├── apps.py ├── constants.py ├── decorators.py ├── forms.py ├── management ├── __init__.py └── commands │ ├── __init__.py │ ├── check_feeds.py │ ├── import_opml.py │ └── yarr_clean.py ├── managers.py ├── migrations ├── 0001_initial.py └── __init__.py ├── models.py ├── settings.py ├── static └── yarr │ ├── index.css │ ├── index.js │ └── index.js.map ├── templates └── yarr │ ├── base.html │ ├── base_all.html │ ├── base_manage.html │ ├── confirm.html │ ├── feed_add.html │ ├── feed_edit.html │ ├── feeds.html │ ├── include │ ├── entry.html │ ├── form_feed_add.html │ └── form_feed_edit.html │ └── list_entries.html ├── urls.py ├── utils.py └── views.py /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | test: 9 | name: py-${{ matrix.python }} dj-${{ matrix.django }} 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | include: 14 | # Django LTS on Python oldest to latest 15 | - python: 3.8 16 | django: 3.2 17 | - python: 3.9 18 | django: 3.2 19 | - python: "3.10" 20 | django: 3.2 21 | # Django supported on Python latest 22 | - python: "3.10" 23 | django: 4.0 24 | 25 | steps: 26 | - uses: actions/checkout@v2 27 | - name: Set up Python ${{ matrix.python }} 28 | uses: actions/setup-python@v2 29 | with: 30 | python-version: ${{ matrix.python }} 31 | - name: Install dependencies 32 | run: | 33 | python -m pip install --upgrade pip 34 | pip install -r requirements.txt 35 | pip install "django~=${{ matrix.django }}.0" 36 | - name: Set Python path 37 | run: | 38 | echo "PYTHONPATH=." >> $GITHUB_ENV 39 | - name: Test 40 | run: | 41 | pytest 42 | - name: Upload coverage to Codecov 43 | uses: codecov/codecov-action@v1 44 | with: 45 | name: ${{ matrix.python }}-${{ matrix.django }} 46 | -------------------------------------------------------------------------------- /.github/workflows/pypi.yml: -------------------------------------------------------------------------------- 1 | name: PyPI 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | publish: 10 | name: Build and publish to PyPI 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Set up Python 3.9 15 | uses: actions/setup-python@v2 16 | with: 17 | python-version: 3.9 18 | - name: Install dependencies 19 | run: | 20 | python -m pip install --upgrade pip 21 | pip install setuptools wheel 22 | - name: Build a binary wheel and a source tarball 23 | run: | 24 | python setup.py sdist bdist_wheel 25 | - name: Publish to PyPI 26 | if: startsWith(github.ref, 'refs/tags') 27 | uses: pypa/gh-action-pypi-publish@master 28 | with: 29 | password: ${{ secrets.pypi_password }} 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Build 2 | *.pyc 3 | django_yarr.egg-info 4 | build 5 | dist 6 | 7 | # Test 8 | .tox 9 | .coverage* 10 | htmlcov 11 | 12 | # Frontend 13 | node_modules/* 14 | 15 | # Docs 16 | docs/_* 17 | 18 | # Example 19 | example/db.sqlite3 20 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 12.18.3 2 | -------------------------------------------------------------------------------- /.yvmrc: -------------------------------------------------------------------------------- 1 | 1.22.5 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | django-yarr is licensed under the BSD License 2 | ============================================= 3 | 4 | Copyright (c) 2013, Richard Terry, http://radiac.net/ 5 | All rights reserved. 6 | 7 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 8 | 9 | Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 10 | Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 11 | Neither the name of the software nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 14 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | recursive-include yarr/static * 3 | recursive-include yarr/templates * 4 | include tests * 5 | recursive-exclude tests *.pyc 6 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ==================================== 2 | Django Yarr - Yet Another RSS Reader 3 | ==================================== 4 | 5 | A lightweight customisable RSS reader for Django. 6 | 7 | * Project site: https://radiac.net/projects/django-yarr/ 8 | * Source code: https://github.com/radiac/django-yarr 9 | 10 | .. image:: https://github.com/radiac/django-yarr/actions/workflows/ci.yml/badge.svg 11 | :target: https://github.com/radiac/django-yarr/actions/workflows/ci.yml 12 | 13 | .. image:: https://codecov.io/gh/radiac/django-yarr/branch/develop/graph/badge.svg?token=5VZNPABZ7E 14 | :target: https://codecov.io/gh/radiac/django-yarr 15 | 16 | 17 | Features 18 | ======== 19 | 20 | * Easy to install - simple requirements, just drops into your site 21 | * Import and export list of feeds using OPML 22 | * View all, by feed, just unread or saved items 23 | * List or expanded layout 24 | * Mark items as read or saved 25 | * Infinite scrolling, with keyboard support and automatic mark as read 26 | * Support for multiple users 27 | * Manage subscriptions through user views or admin site 28 | * No social nonsense 29 | 30 | Supports Django 2.2 and later, on Python 3.5 and later. 31 | 32 | See `Installation `_ for installation instructions. 33 | 34 | See `Upgrading `_ for changelog and upgrade instructions 35 | -------------------------------------------------------------------------------- /docs/commands.rst: -------------------------------------------------------------------------------- 1 | =================== 2 | Management Commands 3 | =================== 4 | 5 | Check feeds 6 | =========== 7 | 8 | Sees which feeds are due to be checked, and checks them for updates. 9 | 10 | Usage:: 11 | 12 | python manage.py check_feeds [--force] [--read] [--purge] [--url=] 13 | 14 | * ``--force`` forces all feeds to update (slow) 15 | * ``--read`` marks new items as read (useful when first importing feeds) 16 | * ``--purge`` purges all existing entries 17 | * ``--verbose`` displays information about feeds as they are being checked 18 | * ``--url=`` specifies the feed URL to update (must be in the database) 19 | 20 | Specifying a feed URL will filter the feeds before any action is taken, so if 21 | used with ``purge``, only that feed will be purged. If no feed URL is 22 | specified, all feeds will be processed. 23 | 24 | Individual feeds can be given a custom checking frequency (default is 24 25 | hours), so ``check_feeds`` needs to run at least as frequently as that; i.e. if 26 | you want a feed to be checked every 15 minutes, set your cron job to run every 27 | 15 minutes. 28 | 29 | Although multiple ``check_feed`` calls can run at the same time without 30 | interfering with each other, if you are running the command manually you may 31 | want to temporarily disable your cron job to avoid checking feeds 32 | unnecessarily. 33 | 34 | 35 | Import OPML 36 | =========== 37 | 38 | Imports feeds from an OPML file into the specified username. 39 | 40 | Usage:: 41 | 42 | python manage.py import_opml /path/to/subscriptions.xml username [--purge] 43 | 44 | * ``/path/to/subscriptions.xml`` should be the path to the OPML file 45 | * ``username`` is the username to associate the feeds with; the user must exist 46 | * ``--purge`` purges all existing feeds 47 | 48 | Only tested with the OPML from a Google Reader takeaway, but should work with 49 | any OPML file where the feeds are specified using the attribute ``xmlUrl``. 50 | 51 | 52 | Clean Yarr 53 | ========== 54 | 55 | Primarily for use during upgrades - performs maintenance tasks to ensure the Yarr 56 | database is clean. Upgrade instructions will tell you when to run this. 57 | 58 | Usage:: 59 | 60 | python manage.py yarr_clean [--delete_read] [--update_cache] 61 | 62 | * ``--delete_read`` will delete all read entries which haven't been saved 63 | * ``--update_cache`` will update the cached feed unread and total counts 64 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # This file does only contain a selection of the most common options. For a 6 | # full list see the documentation: 7 | # http://www.sphinx-doc.org/en/master/config 8 | 9 | # -- Path setup -------------------------------------------------------------- 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | # 15 | # import os 16 | # import sys 17 | # sys.path.insert(0, os.path.abspath('.')) 18 | 19 | import re 20 | from pathlib import Path 21 | 22 | 23 | def find_version(*paths): 24 | path = Path(*paths) 25 | content = path.read_text() 26 | match = re.search(r"^__version__\s*=\s*['\"]([^'\"]*)['\"]", content, re.M) 27 | if match: 28 | return match.group(1) 29 | raise RuntimeError("Unable to find version string.") 30 | 31 | 32 | # -- Project information ----------------------------------------------------- 33 | 34 | project = "django-yarr" 35 | copyright = "2020, Richard Terry" 36 | author = "Richard Terry" 37 | 38 | # The short X.Y version 39 | version = "" 40 | # The full version, including alpha/beta/rc tags 41 | release = find_version("..", "yarr", "__init__.py") 42 | 43 | 44 | # -- General configuration --------------------------------------------------- 45 | 46 | # If your documentation needs a minimal Sphinx version, state it here. 47 | # 48 | # needs_sphinx = '1.0' 49 | 50 | # Add any Sphinx extension module names here, as strings. They can be 51 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 52 | # ones. 53 | extensions = [] 54 | 55 | # Add any paths that contain templates here, relative to this directory. 56 | templates_path = ["_templates"] 57 | 58 | # The suffix(es) of source filenames. 59 | # You can specify multiple suffix as a list of string: 60 | # 61 | # source_suffix = ['.rst', '.md'] 62 | source_suffix = ".rst" 63 | 64 | # The master toctree document. 65 | master_doc = "index" 66 | 67 | # The language for content autogenerated by Sphinx. Refer to documentation 68 | # for a list of supported languages. 69 | # 70 | # This is also used if you do content translation via gettext catalogs. 71 | # Usually you set "language" from the command line for these cases. 72 | language = None 73 | 74 | # List of patterns, relative to source directory, that match files and 75 | # directories to ignore when looking for source files. 76 | # This pattern also affects html_static_path and html_extra_path . 77 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 78 | 79 | # The name of the Pygments (syntax highlighting) style to use. 80 | pygments_style = "sphinx" 81 | 82 | 83 | # -- Options for HTML output ------------------------------------------------- 84 | 85 | # The theme to use for HTML and HTML Help pages. See the documentation for 86 | # a list of builtin themes. 87 | # 88 | html_theme = "alabaster" 89 | 90 | # Theme options are theme-specific and customize the look and feel of a theme 91 | # further. For a list of options available for each theme, see the 92 | # documentation. 93 | # 94 | # html_theme_options = {} 95 | 96 | # Add any paths that contain custom static files (such as style sheets) here, 97 | # relative to this directory. They are copied after the builtin static files, 98 | # so a file named "default.css" will overwrite the builtin "default.css". 99 | html_static_path = ["_static"] 100 | 101 | # Custom sidebar templates, must be a dictionary that maps document names 102 | # to template names. 103 | # 104 | # The default sidebars (for documents that don't match any pattern) are 105 | # defined by theme itself. Builtin themes are using these templates by 106 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 107 | # 'searchbox.html']``. 108 | # 109 | # html_sidebars = {} 110 | 111 | 112 | # -- Options for HTMLHelp output --------------------------------------------- 113 | 114 | # Output file base name for HTML help builder. 115 | html_theme = "classic" 116 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Contributing 3 | ============ 4 | 5 | Contributions are welcome, preferably via pull request. Check the github issues and 6 | project `Roadmap`_ to see what needs work. If you're thinking about adding a new 7 | feature, it's worth opening a new ticket to check it's not already being worked on 8 | elsewhere. 9 | 10 | 11 | Building the frontend 12 | ===================== 13 | 14 | If you need to make changes to the JavaScript, you can build it with:: 15 | 16 | nvm use 17 | yvm use 18 | yarn install 19 | npm run build 20 | 21 | **Please do not submit your built resources** to reduces commit and diff noise. 22 | 23 | If you want to use HMR you can run:: 24 | 25 | npm run watch 26 | 27 | with the example project. 28 | 29 | We aim to support the latest versions of browsers through progressive enhancement; 30 | ideally old browsers should still be able to access all functionality, even if the 31 | experience isn't quite as smooth. 32 | 33 | 34 | Testing 35 | ======= 36 | 37 | It is greatly appreciated when contributions come with unit tests. 38 | 39 | Use ``pytest`` to run the tests on your current installation, or ``tox`` to run it on 40 | the supported variants:: 41 | 42 | pytest 43 | tox 44 | 45 | These will also generate a ``coverage`` HTML report. 46 | 47 | 48 | Roadmap 49 | ======= 50 | 51 | * Support custom user models 52 | * Improve test coverage 53 | * Feed categorisation and entry tags 54 | * De-dupe multiple feeds with the same URL before checking 55 | * Customise HTML sanitiser to support: 56 | * Support whitelist of embedded media (eg youtube) 57 | * Delay image loading 58 | * Improve render time 59 | * Option to ping for unread count updates 60 | * Refresh button 61 | * Adaptive feed check frequency, with smarter 62 | 63 | 64 | Credits 65 | ======= 66 | 67 | Thanks to all contributors who are listed in ``yarr.__credits__``. 68 | 69 | Thanks to existing projects which have been used as references to avoid common 70 | pitfalls: 71 | 72 | * http://code.google.com/p/django-reader 73 | * https://bitbucket.org/tghw/django-feedreader 74 | 75 | The icons are from Remix Icon, https://remixicon.com/ 76 | 77 | The pirate pony started life on http://www.mylittledjango.com/ before putting 78 | on clipart from clker.com and openclipart.org 79 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | =========== 2 | Django Yarr 3 | =========== 4 | 5 | A lightweight customisable RSS reader for Django. 6 | 7 | 8 | Contents 9 | ======== 10 | 11 | .. toctree:: 12 | :maxdepth: 2 13 | 14 | installation 15 | usage 16 | commands 17 | upgrading 18 | contributing 19 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Installation 3 | ============ 4 | 5 | #. Install ``django-yarr``:: 6 | 7 | pip install django-yarr 8 | 9 | 10 | #. Add to ``INSTALLED_APPS``:: 11 | 12 | INSTALLED_APPS = ( 13 | ... 14 | 'yarr', 15 | ) 16 | 17 | 3. Include the URLconf in your project's urls.py:: 18 | 19 | url(r'^yarr/', include('yarr.urls', namespace='yarr')), 20 | 21 | 4. Make sure your ``base.html`` template has the necessary blocks, or override Yarr's 22 | base, ``yarr/base.html`` (see `Templates`_ below). You will also want to create a 23 | link somewhere to ``yarr:index`` so users can access it. 24 | 25 | 5. Add the models to the database:: 26 | 27 | python manage.py migrate yarr 28 | 29 | 6. **Optional**: Import feeds for a user from an OPML file, load all items, and mark 30 | them as read:: 31 | 32 | python manage.py import_opml /path/to/subscriptions.xml username 33 | python manage.py check_feeds --read 34 | 35 | Feeds can currently only be managed through the admin section - see CHANGES for full 36 | roadmap. 37 | 38 | 7. Schedule the ``check_feeds`` management command. By default Yarr expects it to be run 39 | once an hour, but you can change the ``YARR_MINIMUM_INTERVAL`` setting to alter this. 40 | You could use one of these cron examples:: 41 | 42 | # Once a day (at 8am) 43 | * 8 * * * /usr/bin/python /path/to/project/manage.py check_feeds 44 | 45 | :: 46 | 47 | # Every 15 minutes (0, 15, 30 and 45) 48 | */15 * * * * /usr/bin/python /path/to/project/manage.py check_feeds 49 | 50 | :: 51 | 52 | # Once an hour (at 10 past every hour), in a virtual environment 53 | 10 * * * * /path/to/virtualenv/bin/python /path/to/project/manage.py check_feeds 54 | 55 | 56 | Configuration 57 | ============= 58 | 59 | Add these settings to your ``settings.py`` file to override the defaults. 60 | 61 | To manage the web interface: 62 | 63 | ``YARR_HOME``: 64 | Page to open at the Yarr root url 65 | 66 | This setting will probably be removed in a future version. 67 | 68 | Default: ``yarr-list_unread`` 69 | 70 | ``YARR_PAGE_LENGTH``: 71 | The maximum number of entries to show on one page 72 | 73 | Default: ``25`` 74 | 75 | ``YARR_API_PAGE_LENGTH``: 76 | The maximum number of entries to return when infinite scrolling with AJAX 77 | 78 | Default: ``5`` 79 | 80 | ``YARR_LAYOUT_FIXED``: 81 | If True, use the default fixed layout - control bar at the top, feed list on the 82 | left, and content to the right. 83 | 84 | The control bar and will switch to ``position: fixed`` when scrolling down moves it 85 | off the page, the feed list will grow to take up the full available height, and a 86 | button will be added to the control bar to slide the feed list on or off to the left 87 | (changing the width of ``yarr_feed_list`` and the left margin of ``#yarr_content``. 88 | 89 | Default: ``True`` 90 | 91 | ``YARR_ADD_JQUERY``: 92 | If True, adds the bundled version of jQuery when required 93 | 94 | Default: ``True`` 95 | 96 | 97 | To control feed updates: 98 | 99 | ``YARR_SOCKET_TIMEOUT``: 100 | The default socket timeout, in seconds 101 | 102 | Highly recommended that this is **not** set to ``None``, which would block 103 | 104 | Default: ``30`` 105 | 106 | 107 | ``YARR_MINIMUM_INTERVAL``: 108 | The minimum interval for checking a feed, in minutes. 109 | 110 | This should match the interval that the cron job runs at, to ensure all feeds are 111 | checked on time. 112 | 113 | Default: ``60`` 114 | 115 | ``YARR_MAXIMUM_INTERVAL``: 116 | The maximum interval for checking a feed, in minutes - no feeds should go longer 117 | than this without a check. 118 | 119 | Default: ``24 * 60`` 120 | 121 | ``YARR_FREQUENCY``: 122 | The default frequency to check a feed, in minutes 123 | 124 | Default: ``24 * 60`` 125 | 126 | ``YARR_ITEM_EXPIRY``: 127 | The number of days to keep a read item which is no longer in the feed. 128 | 129 | Set this to ``0`` to expire immediately, ``-1`` to never expire. 130 | 131 | If changing this from ``-1``, you will probably want to add expiry dates to all 132 | relevant entries by forcing an update: 133 | 134 | python manage.py check_feeds --force 135 | 136 | Default: ``1`` 137 | 138 | 139 | 140 | The bleach settings can also be customised - see bleach docs for details: 141 | 142 | ``YARR_ALLOWED_TAGS``: 143 | Allowed HTML tags 144 | 145 | ``YARR_ALLOWED_ATTRIBUTES``: 146 | Allowed HTML tag attributes 147 | 148 | ``YARR_ALLOWED_STYLES``: 149 | Allowed styles 150 | 151 | Note that the default Yarr templates use ``STATIC_URL``, so your 152 | ``TEMPLATE_CONTEXT_PROCESSORS`` should include 153 | ``django.core.context_processors.static`` - it is there by default. 154 | 155 | 156 | 157 | Templates 158 | ========= 159 | 160 | The Yarr templates extend ``yarr/base.html``, which in turn extends ``base.html``. 161 | 162 | They will expect the following blocks: 163 | 164 | * ``js`` for inserting JavaScript 165 | * ``css`` for inserting CSS 166 | * ``title`` for inserting the title (plain text) - or ``{{ title }}`` instead 167 | * ``content`` for the body content 168 | 169 | You will need to add these to your base.html template. Alternatively, if you already 170 | have the blocks but with different names, create yarr/base.html in your own templates 171 | folder and map them; for example:: 172 | 173 | {% block script %} 174 | {{ block.super }} 175 | {% block js %}{% endblock %} 176 | {% endblock %} 177 | 178 | Once you have mapped these blocks, the default settings and templates should work out of 179 | the box with most designs. 180 | 181 | The ``content`` block in ``list_entries.html`` template contains three further blocks 182 | for you to override: 183 | 184 | * ``yarr_control`` for the control bar 185 | * ``yarr_feed_list`` for the feed list 186 | * ``yarr_content`` for the list of entries 187 | 188 | Note: the url to the arrow sprite is hard-coded in styles.css for the default static 189 | url, ``/static/yarr/images/arrows.png``. Override ``.yarr_control .yarr_nav a`` in your 190 | stylesheet if your static url is different. 191 | 192 | Forms are given basic styling using the selector ``form.yarr_form``; override the files 193 | in ``templates/yarr/include`` to display them in the same way you do elsewhere on your 194 | site. 195 | 196 | Form success messages use the messages framework by default, so you should display the 197 | ``messages`` list somewhere in your template, or override the urls to add a 198 | ``success_url`` view argument to redirect to a custom page. 199 | 200 | Yarr also uses the global javascript variables ``YARR`` and ``YARR_CONFIG``. 201 | -------------------------------------------------------------------------------- /docs/upgrading.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | Upgrading 3 | ========= 4 | 5 | For an overview of what has changed between versions, see the :ref:`changelog`. 6 | 7 | 8 | Instructions 9 | ============ 10 | 11 | 1. Check which version of Yarr you are upgrading from:: 12 | 13 | python 14 | >>> import yarr 15 | >>> yarr.__version__ 16 | 17 | 2. Upgrade the Yarr package:: 18 | 19 | pip install --upgrade django-yarr 20 | 21 | 3. Scroll down to the earliest instructions relevant to your version, and follow them up 22 | to the latest version. 23 | 24 | For example, to upgrade from 0.3.8, follow the instruction for 0.3.13, then 0.3.12, 25 | but not 0.3.6 or earlier. 26 | 27 | 28 | 29 | Upgrading from 0.5.0 30 | -------------------- 31 | 32 | Version 0.6.0 makes significant frontend changes so you will need to update your site 33 | template and stylesheets. In particular, Yarr now uses a pure CSS for its layout, and 34 | will expand to fit within its container element. It's recommended to make this container 35 | the full width and height of the page (less any persistent header etc). 36 | 37 | See the example project for details. 38 | 39 | 40 | Upgrading from 0.4.5 41 | -------------------- 42 | 43 | 1. This version switches from South migrations to Django migrations: 44 | 45 | 1. Remove ``south`` from your ``INSTALLED_APPS`` 46 | 2. Run ``python manage.py migrate yarr`` 47 | 48 | 2. URLs are now namespaced; the ``yarr-home`` page is now renamed to 49 | ``yarr:index`` 50 | 51 | 3. The setting ``YARR_HOME`` is now ``YARR_INDEX_URL`` 52 | 53 | 54 | Upgrading from 0.4.2 or earlier 55 | ------------------------------- 56 | 57 | If you have customised your installation of Yarr, you may be affected by the following 58 | changes: 59 | 60 | * CSS borders on ``.yarr_mode_list`` and ``.yarr_mode_list .yarr_entry`` have changed - 61 | same visible effect, but allows ``.yarr_content`` to force the scroll element's height 62 | in fixed layout. 63 | 64 | * Your Yarr URLs cannot contain a slug ``00``. AJAX mode now adds URLs to the browser's 65 | history by building URLs in Django with a feed pk of ``00``, then passing them to 66 | JavaScript which replaces ``/00/`` with the feed pk. In the unlikely event your Yarr 67 | URL does contain ``00``, the AJAX site will still work, but the URLs it generates will 68 | be invalid. 69 | 70 | 71 | Upgrading from 0.4.1 or earlier 72 | ------------------------------- 73 | 74 | Run:: 75 | 76 | python manage.py migrate yarr 77 | 78 | 79 | Upgrading from 0.4.0 or earlier 80 | ------------------------------- 81 | 82 | A bug in older versions may have led to incorrect unread counts on feeds. The count will 83 | be corrected as soon as an item in that feed is read or unread, but you can correct all 84 | feeds immediately with:: 85 | 86 | python manage.py yarr_clean --update_cache 87 | 88 | 89 | Upgrading from 0.3.13 or earlier 90 | -------------------------------- 91 | 92 | New settings are available: 93 | 94 | * ``YARR_TITLE_TEMPLATE`` to update the document title (window and tabs) 95 | * ``YARR_TITLE_SELECTOR`` to update the page title (in your template) 96 | 97 | 98 | If you have customised your installation of Yarr, you may be affected by the following 99 | changes: 100 | 101 | * In ``list_entries.html``: 102 | 103 | + The elements ``div.yarr_control`` and ``div.yarr_feed_list`` have had 104 | several significant changes to structure, style and js enhancements 105 | + The data attributes on ``div.yarr_con`` have been removed 106 | 107 | * The ``Entry`` model attributes ``.read`` and ``.saved`` have been replaced 108 | by ``.state``, with corresponding constants in ``constants.py`` 109 | * The views ``mark_read`` and ``mark_saved`` have been replaced by 110 | ``entry_state`` 111 | * The named url ``yarr-mark_unsaved`` has been removed, and state urls now 112 | start with the prefix ``state/`` 113 | * The API calls for entries have changed to use the new state attribute 114 | * The template ``include/entry.html`` now sets the attr ``data-yarr-state``, 115 | instead of ``data-yarr-read`` and ``data-yarr-saved`` 116 | * The script ``static/yarr/js/list_entries.js`` has been refactored 117 | 118 | 119 | Upgrading from 0.3.12 or earlier 120 | -------------------------------- 121 | 122 | In earlier versions, entry expiry didn't function correctly. This release fixes 123 | the issue, but because expiry dates are set when a feed updates, you will have to wait 124 | for all feeds to change before expiry dates are set correctly (meaning some old entries 125 | will sit around in your database for longer than they need to, which could waste disk 126 | space if you have a lot of feeds). 127 | 128 | To address this, ``check_feeds --force`` has been changed to not just force a check of 129 | all feeds, but also to force a database update, which will set an expiry on all entries 130 | no longer in a feed. To force expiries onto entries that should expire:: 131 | 132 | python manage.py check_feeds --force 133 | 134 | Bear in mind that entries on dead feeds will not be touched; this is the intended 135 | behaviour (in case the feed is temporarily unavailable), but may mean that you are left 136 | with some entries which should have expired. If this is an issue for you, you can delete 137 | the feed (and all entries along with it), or manually delete read unsaved entries on 138 | inactive feeds with:: 139 | 140 | python manage.py yarr_clean --delete_read 141 | 142 | 143 | Upgrading from 0.3.6 or earlier 144 | ------------------------------- 145 | 146 | Changes to templates and static: 147 | 148 | * The old ``yarr/base.html`` has moved to ``yarr/base_all.html``, and the new 149 | ``yarr/base.html`` is empty. This will make it simpler to override the Yarr 150 | base template without needing to copy the cs and js blocks, which will change 151 | in future versions. 152 | * New global javascript variables ``YARR`` and ``YARR_CONFIG`` 153 | * Paths to static resources have changed 154 | 155 | 156 | Upgrading from 0.3.0 or earlier 157 | ------------------------------- 158 | 159 | Changes to templates: 160 | 161 | * Entries now render titles as ``

`` instead of ``

``, for valid HTML4. 162 | * Some elements have had their selectors changes (notably ``#yarr_content`` to 163 | ``.yarr_content``). 164 | 165 | Changes to settings, if you have overridden the defaults: 166 | 167 | * Rename ``YARR_CONTROL_FIXED`` to ``YARR_LAYOUT_FIXED`` 168 | * Note that default for ``YARR_FREQUENCY`` has changed to 24 hours now that 169 | feeds are checked before they are next due instead of after. 170 | 171 | 172 | Upgrading to 0.2.0 173 | ------------------ 174 | 175 | Change the following settings, if you have overridden the defaults: 176 | 177 | * Rename ``YARR_PAGINATION`` to ``YARR_PAGE_LENGTH`` 178 | * Rename ``YARR_API_PAGINATION`` to ``YARR_API_PAGE_LENGTH`` 179 | 180 | 181 | Changelog 182 | ========= 183 | 184 | 0.7.0, 2022-07-28 185 | ----------------- 186 | 187 | Changes: 188 | 189 | * Add support for Django 3.2 - 4.0 190 | * Dependency upgrades (backwards incompatible) 191 | 192 | Bugfix: 193 | 194 | * Fix JS compatibility issue (#79) 195 | 196 | Thanks to: 197 | 198 | * bichanna for #79 199 | 200 | 201 | 0.6.2, 2021-02-21 202 | ----------------- 203 | 204 | Changes: 205 | 206 | * Restyle active read entries so default title colour is darker 207 | 208 | 209 | Bugfix: 210 | 211 | * Remove missing images from manage table 212 | * Fix JS failure to mark as read 213 | * Fix ``check_feeds`` when multiple feeds share a url 214 | 215 | 216 | 0.6.1, 2020-11-25 217 | ----------------- 218 | 219 | Changes: 220 | 221 | * Update example project 222 | * Clean source 223 | 224 | 225 | Bugfix: 226 | 227 | * Add missing styles 228 | * Fix JS load order 229 | 230 | 231 | 0.6.0, 2020-11-18 232 | ----------------- 233 | 234 | Features: 235 | 236 | * Add support for Django 2.2 - 3.1 237 | * Reimplement frontend to use a CSS-based layout 238 | 239 | Changes: 240 | 241 | * Drop support for Django <2.1 242 | 243 | 244 | 0.5.0, 2014-11-09 245 | ----------------- 246 | 247 | Features: 248 | 249 | * Add support for Django 1.7 (#44) 250 | 251 | Thanks to 252 | 253 | * windedge for #44 254 | 255 | 256 | 0.4.5, 2014-05-10 257 | ----------------- 258 | 259 | Bugfix: 260 | 261 | * Use json instead of deprecated simplejson (fixes #42) 262 | 263 | 264 | 0.4.4, 2014-04-24 265 | ----------------- 266 | 267 | Features: 268 | 269 | * Added ``check_feeds --url`` 270 | 271 | Bugfix: 272 | 273 | * Fixed bug triggered when feed entries lacked guids 274 | 275 | 276 | 0.4.3, 2014-02-21 277 | ----------------- 278 | 279 | Features: 280 | 281 | * URL history updates to reflect state 282 | * Tox test support (#9, #39) 283 | 284 | Bugfix: 285 | 286 | * Control bar no longer jumps around when in fixed layout 287 | * Fixed reST syntax in upgrade notes (#38) 288 | * Fixed race condition when changing feeds while scrolled 289 | 290 | Thanks to: 291 | 292 | * Spencer Herzberg (sherzberg) for #9 293 | * Tom Most (twm) for #38 and #39 294 | 295 | 296 | 0.4.2, 2014-02-13 297 | ----------------- 298 | 299 | Bugfix: 300 | 301 | * Improved compatibility of raw SQL to update count cache 302 | 303 | Internal: 304 | 305 | * Changed count_unread and count_total to not null in db 306 | 307 | 308 | 0.4.1, 2014-02-13 309 | ----------------- 310 | 311 | Feature: 312 | 313 | * Added OPML export (#33) 314 | * Can now mark all read without reloading page 315 | * Added yarr_clean management command for help upgrading 316 | 317 | Bugfix: 318 | 319 | * Static read all button only changes state of unread 320 | * Fixed load status appearing at wrong time 321 | * Fixed list mode click having incorrect effect 322 | * Fixed scrollTo error 323 | * Expiry dates are reset when item state changes 324 | * Mark all read updates unread count correctly (#35) 325 | * Expiring entries updates total count correctly 326 | * Fixed dropdown bugs 327 | 328 | Internal: 329 | 330 | * Optimised unread and total count updates 331 | * All templates have div wrappers (#37) 332 | 333 | Thanks to: 334 | 335 | * Tom Most (twm) for #33 and #37 336 | 337 | 338 | 0.4.0, 2014-02-06 339 | ----------------- 340 | 341 | Feature: 342 | 343 | * Simplified control bar 344 | * Can now change feeds without reloading page (fixes #27) 345 | * Can now change filter and order without reloading page 346 | * Simplified save/read state, save indicated in list mode 347 | 348 | Bugfix: 349 | 350 | * Changed Entry .save and .read to .state (fixes #35) 351 | * Added Feed.text for user-customisable title (fixes #34) 352 | * Unread count updates correctly when reading items 353 | * Unread count shows next to abreviated feed 354 | * Feed toggle correctly determines feedlist width 355 | 356 | Internal: 357 | 358 | * Refactored list_entries.js 359 | 360 | 361 | 0.3.13, 2014-01-05 362 | ------------------ 363 | 364 | Feature: 365 | 366 | * Changed check_feeds --force to also force a db update 367 | * Allow more HTML tags in entries (#32) 368 | 369 | Bugfix: 370 | 371 | * Fixed entries not expiring correctly 372 | * Unread count at 0 removes class (#31) 373 | * Fixed urls.py for Django 1.6 (#30) 374 | 375 | Thanks to: 376 | 377 | * Chris Franklin (chrisfranklin) for #30 378 | * Tom Most (twm) for #31 and #32 379 | 380 | 381 | 0.3.12, 2013-11-19 382 | ------------------ 383 | 384 | Bugfix: 385 | 386 | * Fixed scroll buttons sprite 387 | 388 | 389 | 0.3.11, 2013-11-15 390 | ------------------ 391 | 392 | Feature: 393 | 394 | * Add unread count to feed list (#29) 395 | * Minor feed management tweaks (#26) 396 | * Add wrapper for checkbox style-ability (#25) 397 | * Longer entry snippets in list mode (#24) 398 | * Items only scroll on click in list mode (#23) 399 | * Added basic styling for unread count 400 | * Clarified parts of the instructions 401 | * Changed icons to ones based on Entypo 402 | 403 | Thanks to: 404 | 405 | * Tom Most (twm) for all above changes 406 | 407 | 408 | 0.3.10, 2013-10-23 409 | ------------------ 410 | 411 | Internal: 412 | 413 | * Use render(), not render_to_response() (#20) 414 | 415 | Bugfix: 416 | 417 | * Removed debug messages from feeds.js 418 | 419 | Thanks to: 420 | 421 | * Tuk Bredsdorff (tiktuk) for #20 422 | 423 | 424 | 0.3.9, 2013-09-20 425 | ----------------- 426 | 427 | Bugfix: 428 | 429 | * Fixed layout fixed setting in views.list_entries 430 | 431 | 432 | 0.3.8, 2013-09-15 433 | ----------------- 434 | 435 | Feature: 436 | 437 | * Added toggle to display feed items oldest first (#18) 438 | * Changed sanitiser to allow ```` (#16) 439 | 440 | Bugfix: 441 | 442 | * Fixed ``YARR_LAYOUT_FIXED = False`` (#17) 443 | * Added documentation regarding timezones (#15) 444 | 445 | Thanks to: 446 | 447 | * Tom Most (twm) for all changes 448 | 449 | 450 | 0.3.7, 2013-08-06 451 | ----------------- 452 | 453 | Internal: 454 | 455 | * Import feed refactor for better reuse (#10) 456 | 457 | Thanks to: 458 | 459 | * Spencer Herzberg (sherzberg) for all changes 460 | 461 | 462 | 0.3.6, 2013-07-20 463 | ----------------- 464 | 465 | Feature: 466 | 467 | * Added expandable info to feed manager list 468 | * Added shortcut key to open source URL in new window (#5) 469 | * Added setting to control how long old entries remain 470 | * Added link to delete feed on edit feed page 471 | 472 | Internal: 473 | 474 | * Added cached item counts to Feed 475 | 476 | Internal: 477 | 478 | * Restructured template inheritance to simplify overrides 479 | 480 | Bugfix: 481 | 482 | * Added missing code to update an item that has changed 483 | * Changed check_feeds to check for entries if feed broken 484 | 485 | Thanks to: 486 | 487 | * Aleksandr Pasechnik (russkey) for #5 488 | 489 | 490 | 0.3.5, 2013-07-17 491 | ----------------- 492 | 493 | Bugfix: 494 | 495 | * Changed "Mark as read" to mark a feed if selected (#4) 496 | 497 | 498 | 0.3.4, 2013-07-17 499 | ----------------- 500 | 501 | Feature: 502 | 503 | * Added cookie-based memory of visible/hidden feed list 504 | 505 | Bugfix: 506 | 507 | * Fixed detection of initial feed list visiblity 508 | 509 | Thanks to: 510 | 511 | * Aleksandr Pasechnik (russkey) 512 | 513 | 514 | 0.3.3, 2013-07-12 515 | ----------------- 516 | 517 | Bugfix: 518 | 519 | * Fixed bug in feed check that caused it to trigger early 520 | 521 | 522 | 0.3.2, 2013-07-10 523 | ----------------- 524 | 525 | Feature: 526 | 527 | * Added ``--verbose`` option to ``feed_check`` command 528 | 529 | Bugfix: 530 | 531 | * Feed last_checked value now always updated 532 | 533 | Thanks to: 534 | 535 | * chanshik: Idea for ``feed_check`` verbosity 536 | 537 | 538 | 0.3.1, 2013-07-09 539 | ----------------- 540 | 541 | Feature: 542 | 543 | * Added 'Problem' status to feed manager 544 | 545 | 546 | 0.3.0, 2013-07-09 547 | ----------------- 548 | 549 | Feature: 550 | 551 | * Added feed list, browse by feed 552 | * Added feed manager 553 | * Added cookie-based memory of expanded/list view 554 | 555 | Bugfix: 556 | 557 | * Changed check_feeds to check any due in the next period 558 | * Fixed infinite scroll still loading at end of scroll 559 | * Fixed mark as read to change item style without reload 560 | * Fixed double parsing by disabling feedparser sanitizer 561 | 562 | Change: 563 | 564 | * Changed roadmap 565 | 566 | 567 | 0.2.0, 2013-07-05 568 | ----------------- 569 | 570 | Feature: 571 | 572 | * Added list view 573 | * Replaced API with more sensible get/set model 574 | 575 | Bugfix: 576 | 577 | * Changed feed check to keep feed title if none provided 578 | * Fixed clicking on items in infinite scroll 579 | 580 | 581 | 0.1.5, 2013-07-05 582 | ----------------- 583 | 584 | Bugfix: 585 | 586 | * Replaced checks for updated_parsed, suppress warnings 587 | 588 | 589 | 0.1.4, 2013-07-05 590 | ----------------- 591 | 592 | Bugfix: 593 | 594 | * Changed URLFields to TextFields with URL validator 595 | 596 | 597 | 0.1.3, 2013-07-04 598 | ----------------- 599 | 600 | Feature: 601 | 602 | * Added tests 603 | 604 | Bugfix: 605 | 606 | * Changed title, guid and author fields to TextField 607 | * Fixed incorrect call to _feed_fetch 608 | * Added feedparser bozo flag handling 609 | * Added socket timeout 610 | * Fixed title field in template 611 | 612 | Change: 613 | 614 | * Changed roadmap 615 | 616 | Thanks to: 617 | 618 | * Andrew Rowson (growse): Model change and other bugfixes 619 | * chanshik: Raising socket timeout issue 620 | 621 | 0.1.2, 2013-06-30 622 | ----------------- 623 | 624 | Feature: 625 | 626 | * Added j/k shortcut keys 627 | 628 | 629 | 0.1.1, 2013-06-30 630 | ----------------- 631 | 632 | Bugfix: 633 | 634 | * Changed js to disable API when API URLs unavailable 635 | 636 | 637 | 0.1.0, 2013-06-29 638 | ----------------- 639 | 640 | Feature: 641 | 642 | * Initial release 643 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | Using Yarr 3 | ========== 4 | 5 | You can browse items by feed and/or unread/saved status. There are two display modes; 6 | expanded mode just lists the full items one after another, and list mode shows a list of 7 | titles which can be expanded to see the item. 8 | 9 | Items will be marked as read once they are opened in list mode, or when they are 10 | scrolled to or selected in expanded mode. Once something is marked as read, it can 11 | expire. An item can either be read or saved, but not both. 12 | 13 | Feeds can be managed on the ``Manage feeds`` page. If a feed had a problem, its status 14 | icon will be an orange warning, and if it is no longer available it will be a red error. 15 | To see the reason for a warning or error, click somewhere on the row. To edit the feed's 16 | settings, click on its title. 17 | 18 | 19 | Shortcut keys 20 | ============= 21 | 22 | * ``n`` or ``j``: Next item 23 | * ``p`` or ``k``: Previous item 24 | * ``v`` or ``ENTER``: View original (in new window) 25 | -------------------------------------------------------------------------------- /example/README.rst: -------------------------------------------------------------------------------- 1 | =============================== 2 | Example project for django-yarr 3 | =============================== 4 | 5 | This example project is configured for Django 2.2. 6 | 7 | To set it up and run the live version in a self-contained virtualenv:: 8 | 9 | virtualenv --python=python3.8 venv 10 | source venv/bin/activate 11 | git clone https://github.com/radiac/django-yarr.git repo 12 | cd repo/example 13 | pip install "django<3.0" 14 | pip install -r ../requirements.txt 15 | 16 | To run against the local Yarr source (still within the ``repo/example`` dir):: 17 | 18 | export PYTHONPATH=".." 19 | python manage.py migrate 20 | python manage.py createsuperuser 21 | python manage.py runserver 22 | 23 | You can then log in at http://localhost:8000/admin/ and visit the example Yarr 24 | installation at http://localhost:8000/. 25 | 26 | To see something happen, try: 27 | 28 | #. Add a feed at http://localhost:8000/feeds/ 29 | #. Run ``python manage.py check_feeds`` 30 | -------------------------------------------------------------------------------- /example/example/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radiac/django-yarr/0b087298311a0e7b3901f59ba74e6bd27278a5e9/example/example/__init__.py -------------------------------------------------------------------------------- /example/example/context_processors.py: -------------------------------------------------------------------------------- 1 | """ 2 | Webpack context processor 3 | 4 | If you are looking at this to see how to use powerwiki, you can ignore this file 5 | """ 6 | import logging 7 | import socket 8 | 9 | from django.conf import settings 10 | from django.http.request import split_domain_port 11 | 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | def webpack_dev_url(request): 17 | """ 18 | If webpack dev server is running, add HMR context processor so template can 19 | switch script import to HMR URL 20 | """ 21 | if not getattr(settings, "WEBPACK_DEV_URL", None): 22 | return {} 23 | 24 | data = {"host": split_domain_port(request._get_raw_host())[0]} 25 | 26 | hmr_socket = socket.socket() 27 | try: 28 | hmr_socket.connect( 29 | (settings.WEBPACK_DEV_HOST.format(**data), settings.WEBPACK_DEV_PORT) 30 | ) 31 | 32 | except socket.error: 33 | # No HMR server 34 | logger.warning("Webpack dev server not found\n") 35 | return {} 36 | 37 | finally: 38 | hmr_socket.close() 39 | 40 | # HMR server found 41 | logger.info("Webpack dev server found, HMR enabled\n") 42 | 43 | context = {"WEBPACK_DEV_URL": settings.WEBPACK_DEV_URL.format(**data)} 44 | return context 45 | -------------------------------------------------------------------------------- /example/example/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for example project. 3 | 4 | Generated by 'django-admin startproject' using Django 2.2.15. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/2.2/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | 16 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 17 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 18 | 19 | 20 | # Quick-start development settings - unsuitable for production 21 | # See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/ 22 | 23 | # SECURITY WARNING: keep the secret key used in production secret! 24 | SECRET_KEY = "secret" 25 | 26 | # SECURITY WARNING: don't run with debug turned on in production! 27 | DEBUG = True 28 | 29 | ALLOWED_HOSTS = ["*"] 30 | 31 | 32 | # Application definition 33 | 34 | INSTALLED_APPS = [ 35 | "django.contrib.admin", 36 | "django.contrib.auth", 37 | "django.contrib.contenttypes", 38 | "django.contrib.sessions", 39 | "django.contrib.messages", 40 | "django.contrib.staticfiles", 41 | "yarr", 42 | ] 43 | 44 | MIDDLEWARE = [ 45 | "django.middleware.security.SecurityMiddleware", 46 | "django.contrib.sessions.middleware.SessionMiddleware", 47 | "django.middleware.common.CommonMiddleware", 48 | "django.middleware.csrf.CsrfViewMiddleware", 49 | "django.contrib.auth.middleware.AuthenticationMiddleware", 50 | "django.contrib.messages.middleware.MessageMiddleware", 51 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 52 | ] 53 | 54 | ROOT_URLCONF = "example.urls" 55 | 56 | TEMPLATES = [ 57 | { 58 | "BACKEND": "django.template.backends.django.DjangoTemplates", 59 | "DIRS": ["templates"], 60 | "APP_DIRS": True, 61 | "OPTIONS": { 62 | "context_processors": [ 63 | "django.template.context_processors.debug", 64 | "django.template.context_processors.request", 65 | "django.contrib.auth.context_processors.auth", 66 | "django.contrib.messages.context_processors.messages", 67 | ], 68 | }, 69 | }, 70 | ] 71 | 72 | WSGI_APPLICATION = "example.wsgi.application" 73 | 74 | 75 | # Database 76 | # https://docs.djangoproject.com/en/2.2/ref/settings/#databases 77 | 78 | DATABASES = { 79 | "default": { 80 | "ENGINE": "django.db.backends.sqlite3", 81 | "NAME": os.path.join(BASE_DIR, "db.sqlite3"), 82 | } 83 | } 84 | 85 | 86 | # Password validation 87 | # https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators 88 | 89 | AUTH_PASSWORD_VALIDATORS = [ 90 | { 91 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 92 | }, 93 | {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, 94 | {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, 95 | {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, 96 | ] 97 | 98 | 99 | # Internationalization 100 | # https://docs.djangoproject.com/en/2.2/topics/i18n/ 101 | 102 | LANGUAGE_CODE = "en-us" 103 | 104 | TIME_ZONE = "UTC" 105 | 106 | USE_I18N = True 107 | 108 | USE_L10N = True 109 | 110 | USE_TZ = True 111 | 112 | 113 | # Static files (CSS, JavaScript, Images) 114 | # https://docs.djangoproject.com/en/2.2/howto/static-files/ 115 | 116 | STATIC_URL = "/static/" 117 | 118 | 119 | # 120 | # Add webpack HMR support for developers working on the frontend 121 | # 122 | WEBPACK_DEV_HOST = os.getenv("WEBPACK_DEV_HOST", default="localhost") 123 | WEBPACK_DEV_PORT = int(os.getenv("WEBPACK_DEV_PORT", default=8080)) 124 | WEBPACK_DEV_URL = os.getenv( 125 | "WEBPACK_DEV_URL", f"//{WEBPACK_DEV_HOST}:{WEBPACK_DEV_PORT}/static/yarr/" 126 | ) 127 | LOGGING = { 128 | "version": 1, 129 | "handlers": {"console": {"class": "logging.StreamHandler"}}, 130 | "loggers": { 131 | "example.context_processors": { 132 | "level": "DEBUG", 133 | "handlers": ["console"], 134 | "propagate": False, 135 | } 136 | }, 137 | } 138 | TEMPLATES[0]["OPTIONS"]["context_processors"].append( 139 | "example.context_processors.webpack_dev_url" 140 | ) 141 | 142 | 143 | # 144 | # Settings for using the example project to develop with docker 145 | # 146 | # If you are looking at this file to see how to use yarr, you can ignore everything 147 | # from here on 148 | # 149 | if os.getenv("DJANGO_CONFIGURATION") == "docker": 150 | # Use PostgreSQL instead of sqlite 151 | DATABASES = { 152 | "default": { 153 | "ENGINE": "django.db.backends.postgresql_psycopg2", 154 | "HOST": os.getenv("DATABASE_HOST", "localhost"), 155 | "NAME": os.getenv("DATABASE_NAME", "yarr"), 156 | "USER": os.getenv("DATABASE_USER", "yarr"), 157 | "PASSWORD": os.getenv("DATABASE_PASSWORD", "yarr"), 158 | "CONN_MAX_AGE": 600, 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /example/example/urls.py: -------------------------------------------------------------------------------- 1 | """example URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/2.2/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.contrib import admin 17 | from django.urls import include, path 18 | 19 | 20 | urlpatterns = [ 21 | path("admin/", admin.site.urls), 22 | path("", include("yarr.urls", namespace="yarr")), 23 | ] 24 | -------------------------------------------------------------------------------- /example/example/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for example project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | 15 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 16 | 17 | application = get_wsgi_application() 18 | -------------------------------------------------------------------------------- /example/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError as exc: 12 | raise ImportError( 13 | "Couldn't import Django. Are you sure it's installed and " 14 | "available on your PYTHONPATH environment variable? Did you " 15 | "forget to activate a virtual environment?" 16 | ) from exc 17 | execute_from_command_line(sys.argv) 18 | 19 | 20 | if __name__ == "__main__": 21 | main() 22 | -------------------------------------------------------------------------------- /example/templates/base.html: -------------------------------------------------------------------------------- 1 | {% spaceless %} 2 | {% load static %} 3 | 4 | 5 | {% endspaceless %} 6 | 7 | 8 | 9 | {{ title }} 10 | 11 | 12 | {% block css %}{% endblock %} 13 | {% comment %} 14 | When working on the frontend, delete the css block above and uncomment the 15 | following to enable HMR: 16 | 17 | {% if not WEBPACK_DEV_URL %} 18 | 19 | {% endif %} 20 | {% endcomment %} 21 | 22 | 23 | 83 | 84 | 85 | 86 | 87 | 88 |
89 |

{{ title }}

90 | 91 |
92 |
    93 | {% for message in messages %} 94 | {{ message|safe }} 95 | {% endfor %} 96 |
97 |
98 | {% if messages %} 99 | {% endif %} 100 |
101 | 102 |
103 | {% block content %}{% endblock %} 104 |
105 | 106 | {% block js %}{% endblock %} 107 | {% comment %} 108 | 109 | When working on the frontend, delete the js block above and uncomment the following to 110 | enable HMR: 111 | 112 | {% if WEBPACK_DEV_URL %} 113 | 114 | {% else %} 115 | 116 | {% endif %} 117 | 118 | {% endcomment %} 119 | 120 | 121 | 122 | 123 | -------------------------------------------------------------------------------- /frontend/components/_index.scss: -------------------------------------------------------------------------------- 1 | @import './sidebar'; 2 | @import './feed'; 3 | @import './entry'; 4 | @import './control'; 5 | @import './status'; 6 | -------------------------------------------------------------------------------- /frontend/components/control/_index.scss: -------------------------------------------------------------------------------- 1 | /* 2 | ** Styles for non-js control panel 3 | */ 4 | 5 | $li-height: 1.5rem; 6 | 7 | .yarr { 8 | & > .control { 9 | flex: 0 0 auto; 10 | order: 1; 11 | 12 | display: flex; 13 | 14 | .menu, 15 | .feednav { 16 | flex: 1 1 auto; 17 | margin: 0.5rem 0; 18 | 19 | @include media(' .control { 37 | padding: 0 1rem; 38 | border-bottom: 1px solid $colour-layout-border; 39 | 40 | span, 41 | a { 42 | display: block; 43 | box-sizing: content-box; 44 | height: $li-height; 45 | line-height: $li-height; 46 | font-size: 0.8em; 47 | font-weight: bold; 48 | text-decoration: none; 49 | } 50 | span { 51 | padding: 0.5rem 0.5rem; 52 | } 53 | a { 54 | padding: 0.5rem 1rem 0.5rem ($li-height +1rem); 55 | 56 | &:empty { 57 | padding-left: 1.5rem; 58 | } 59 | } 60 | 61 | // Style icons 62 | .menu_ctl, 63 | .menu_state, 64 | .menu_sort, 65 | .menu_layout, 66 | .menu_op, 67 | .menu_manage, 68 | .stepper { 69 | display: inline-block; 70 | vertical-align: top; 71 | position: relative; 72 | 73 | span::before, 74 | a::before { 75 | content: ' '; 76 | position: absolute; 77 | width: $li-height; 78 | height: $li-height; 79 | background-color: $colour-grey-dark; 80 | mask-size: cover; 81 | } 82 | span { 83 | width: $li-height; 84 | } 85 | 86 | span::before, 87 | a::before { 88 | top: 0.5rem; 89 | left: 0.5rem; 90 | } 91 | 92 | a, 93 | span { 94 | cursor: pointer; 95 | 96 | &:hover, 97 | &:focus { 98 | background-color: $colour-grey-mid; 99 | } 100 | } 101 | 102 | a.selected { 103 | background-color: $colour-grey-mid; 104 | } 105 | } 106 | 107 | // Style the toggling icon menu 108 | .menu_state, 109 | .menu_sort, 110 | .menu_layout, 111 | .menu_op { 112 | position: relative; 113 | 114 | ul { 115 | display: none; 116 | background-color: $colour-grey-light; 117 | width: 10rem; 118 | 119 | li { 120 | display: block; 121 | position: relative; 122 | } 123 | } 124 | 125 | &:hover, 126 | &:focus { 127 | ul { 128 | z-index: 1000; 129 | position: absolute; 130 | display: block; 131 | border: 1px solid $colour-layout-border; 132 | } 133 | } 134 | } 135 | 136 | // Style the non-toggled actions 137 | .menu_ctl, 138 | .menu_manage, 139 | .stepper { 140 | li { 141 | position: relative; 142 | display: inline-block; 143 | } 144 | } 145 | 146 | .menu_ctl { 147 | &-sidebar_toggle { 148 | &::before { 149 | mask-image: url('./icons/rss-line.svg'); 150 | } 151 | } 152 | } 153 | 154 | .menu_state { 155 | &-all::before { 156 | mask-image: url('./icons/mail-line.svg'); 157 | } 158 | &-unread::before { 159 | mask-image: url('./icons/mail-unread-line.svg'); 160 | } 161 | &-saved::before { 162 | mask-image: url('./icons/mail-star-line.svg'); 163 | } 164 | } 165 | 166 | .menu_sort { 167 | &-asc::before { 168 | mask-image: url('./icons/sort-asc.svg'); 169 | } 170 | &-desc::before { 171 | mask-image: url('./icons/sort-desc.svg'); 172 | } 173 | } 174 | 175 | .menu_layout { 176 | &-article::before { 177 | mask-image: url('./icons/article-line.svg'); 178 | } 179 | &-list::before { 180 | mask-image: url('./icons/list-check-2.svg'); 181 | } 182 | } 183 | 184 | // menu_layout icons handled in ../entry/layout/ 185 | .menu_op { 186 | &-mark_read::before { 187 | mask-image: url('./icons/eye-line.svg'); 188 | } 189 | } 190 | 191 | .menu_manage { 192 | &-read_feeds::before { 193 | mask-image: url('./icons/article-line.svg'); 194 | } 195 | &-manage_feeds::before { 196 | mask-image: url('./icons/settings-3-line.svg'); 197 | } 198 | } 199 | 200 | .stepper { 201 | a { 202 | padding: 0.5rem 1rem 0.5rem 1.5rem; 203 | } 204 | &-previous::before { 205 | mask-image: url('./icons/arrow-up-s-line.svg'); 206 | } 207 | &-next::before { 208 | mask-image: url('./icons/arrow-down-s-line.svg'); 209 | } 210 | } 211 | 212 | 213 | .feednav { 214 | li { 215 | display: inline-block; 216 | } 217 | ul.paginated { 218 | span { 219 | padding: 0.5rem 1rem; 220 | background-color: $colour-grey-light; 221 | } 222 | a { 223 | padding: 0.5rem 1rem; 224 | &:hover { 225 | background-color: $colour-grey-light; 226 | } 227 | } 228 | } 229 | } 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /frontend/components/control/icons/arrow-down-s-line.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/components/control/icons/arrow-left-s-line.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/components/control/icons/arrow-right-s-line.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/components/control/icons/arrow-up-s-line.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/components/control/icons/article-line.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/components/control/icons/eye-line.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/components/control/icons/list-check-2.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/components/control/icons/mail-line.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/components/control/icons/mail-star-line.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/components/control/icons/mail-unread-line.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/components/control/icons/rss-line.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/components/control/icons/settings-3-line.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/components/control/icons/sort-asc.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/components/control/icons/sort-desc.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/components/control/index.js: -------------------------------------------------------------------------------- 1 | export { Control } from './Control'; 2 | 3 | import './default.scss'; 4 | -------------------------------------------------------------------------------- /frontend/components/entry/_index.scss: -------------------------------------------------------------------------------- 1 | @import './styles'; 2 | @import './list'; -------------------------------------------------------------------------------- /frontend/components/entry/_list.scss: -------------------------------------------------------------------------------- 1 | .yarr__conf-layout_list { 2 | $summary-height: 1.4rem; 3 | 4 | input[name="layout_list"] { 5 | display: none; 6 | } 7 | 8 | .entry { 9 | margin: 0; 10 | 11 | &:not(:first-child) { 12 | border-top: 0; 13 | } 14 | 15 | input[name="layout_list"] { 16 | & ~ .article { 17 | display: none; 18 | } 19 | 20 | // When radio selecteed, show the article 21 | // Show the .summary--close over the top of the .summary 22 | &:checked { 23 | & ~ .article { 24 | position: relative; 25 | display: block; 26 | border-top: 1px solid $colour-border; 27 | 28 | .summary--close { 29 | position: absolute; 30 | top: -$summary-height; 31 | height: $summary-height; 32 | width: 100%; 33 | } 34 | } 35 | } 36 | } 37 | 38 | .summary { 39 | height: $summary-height; 40 | line-height: $summary-height; 41 | display: flex; 42 | padding: 0 0.5rem; 43 | 44 | .feed { 45 | flex: 0 0 12rem; 46 | } 47 | 48 | .title { 49 | flex: 1 1 auto; 50 | padding: 0 0.5rem; 51 | } 52 | 53 | .feed, 54 | .title { 55 | white-space: nowrap; 56 | overflow: hidden; 57 | text-overflow: ellipsis; 58 | } 59 | 60 | .date { 61 | flex: 0 0 10rem; 62 | text-align: right; 63 | } 64 | 65 | @include media('=tablet') { 88 | .meta { 89 | // Use flex to manage layout 90 | display: flex; 91 | flex-direction: row; 92 | width: 100%; 93 | 94 | p.date { 95 | order: 2; 96 | flex: 0 0 10rem; 97 | text-align: right; 98 | } 99 | 100 | p.feed { 101 | order: 1; 102 | flex: 1 0 auto; 103 | 104 | // Set width to 0 so flex only grows to parent width 105 | width: 0; 106 | } 107 | } 108 | } 109 | } 110 | 111 | .control { 112 | border-top: 1px solid $colour-border; 113 | background: $colour-control-bg; 114 | padding: ($padding-content / 2) $padding-content; 115 | 116 | ul { 117 | display: flex; 118 | flex-direction: row; 119 | flex-wrap: wrap; 120 | list-style: none; 121 | margin: 0; 122 | padding: 0; 123 | 124 | li { 125 | flex: 0 0 auto; 126 | 127 | a, span { 128 | margin-right: 1rem; 129 | text-decoration: none; 130 | font-size: $font-size-control; 131 | color: $colour-control-fg; 132 | } 133 | 134 | span { 135 | font-weight: $weight-bold; 136 | } 137 | 138 | a:hover { 139 | text-decoration: underline; 140 | } 141 | } 142 | } 143 | } 144 | 145 | 146 | /* 147 | ** State-based styles 148 | */ 149 | 150 | & { 151 | border-left: 3px solid $colour-unread-border; 152 | 153 | .header { 154 | border-bottom: 1px solid $colour-unread-header-border; 155 | 156 | h2 { 157 | a { 158 | color: $colour-unread-h2; 159 | } 160 | } 161 | 162 | .meta { 163 | a { 164 | color: $colour-unread-header-link; 165 | } 166 | } 167 | } 168 | 169 | &.active { 170 | border-left-color: $colour-active-border; 171 | 172 | .header { 173 | background: $colour-unread-header-bg; 174 | } 175 | } 176 | } 177 | 178 | &.read { 179 | border-left-color: $colour-read-border; 180 | 181 | .header { 182 | border-bottom-color: $colour-read-header-border; 183 | 184 | h2 { 185 | a { 186 | color: $colour-read-h2; 187 | } 188 | } 189 | 190 | .meta { 191 | a { 192 | color: $colour-read-header-link; 193 | } 194 | } 195 | } 196 | 197 | &.active { 198 | border-left-color: $colour-active-border; 199 | 200 | .header { 201 | background: $colour-read-header-bg; 202 | h2 { 203 | a { 204 | color: $colour-read-active-h2; 205 | } 206 | } 207 | 208 | .meta { 209 | a { 210 | color: $colour-read-active-header-link; 211 | } 212 | } 213 | } 214 | } 215 | } 216 | 217 | &.saved { 218 | border-left-color: $colour-saved-border; 219 | 220 | .header { 221 | border-bottom-color: $colour-saved-header-border; 222 | 223 | h2 { 224 | a { 225 | color: $colour-saved-h2; 226 | } 227 | } 228 | 229 | .meta { 230 | a { 231 | color: $colour-saved-header-link; 232 | } 233 | } 234 | } 235 | 236 | &.active { 237 | border-left-color: $colour-active-border; 238 | 239 | .header { 240 | background: $colour-saved-header-bg; 241 | } 242 | } 243 | } 244 | 245 | 246 | /* 247 | ** Entry content 248 | */ 249 | 250 | .content { 251 | h1, h2, h3, h4, h5, h6 { 252 | font-weight: $weight-bold; 253 | margin-top: $padding-content * 1.5; 254 | 255 | &:first-child { 256 | margin-top: 0; 257 | } 258 | } 259 | 260 | h1 { 261 | font-size: $font-size-h2 * 0.95; 262 | } 263 | 264 | h2 { 265 | font-size: $font-size-h2 * 0.90; 266 | } 267 | 268 | h3 { 269 | font-size: $font-size-h2 * 0.85; 270 | } 271 | 272 | p { 273 | margin: $padding-content 0; 274 | 275 | &:first-child { 276 | margin-top: 0; 277 | } 278 | 279 | &:last-child { 280 | margin-bottom: 0; 281 | } 282 | } 283 | 284 | img { 285 | max-width: 100%; 286 | } 287 | 288 | ul { 289 | list-style: initial; 290 | margin: $padding-content $padding-content * 2; 291 | 292 | li { 293 | display: list-item; 294 | } 295 | } 296 | 297 | a { 298 | color: $colour-content-a; 299 | 300 | &:hover { 301 | color: $colour-content-a-hover; 302 | } 303 | } 304 | 305 | } 306 | } 307 | } 308 | -------------------------------------------------------------------------------- /frontend/components/feed/_index.scss: -------------------------------------------------------------------------------- 1 | $colour-feed-unread: $colour-grey-dark; 2 | 3 | .yarr { 4 | .feed-list { 5 | .selected { 6 | .name { 7 | font-weight: $weight-bold; 8 | } 9 | } 10 | .unread { 11 | margin-left: 0.5rem; 12 | font-size: 0.9rem; 13 | color: $colour-feed-unread; 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /frontend/components/sidebar/_index.scss: -------------------------------------------------------------------------------- 1 | /* 2 | ** Styles for non-js sidebar 3 | */ 4 | 5 | .yarr { 6 | .sidebar { 7 | display: flex; 8 | flex-flow: row nowrap; 9 | } 10 | 11 | .sidebar-body { 12 | flex: 1 1 auto; 13 | // Margin except on right - gap comes from content padding 14 | margin: $padding-content 0 $padding-content $padding-content; 15 | overflow-y: auto; 16 | border: 1px solid $colour-layout-border; 17 | 18 | ul.feed_menu { 19 | border-bottom: 1px solid $colour-layout-border; 20 | } 21 | 22 | ul { 23 | list-style: none; 24 | margin: 0; 25 | padding: $padding-content; 26 | } 27 | 28 | .count_unread { 29 | vertical-align: bottom; 30 | display: inline-block; 31 | font-size: 0.8em; 32 | font-weight: normal; 33 | color: #666; 34 | margin-left: 0.4rem; 35 | } 36 | .count_unread:before { 37 | content: "("; 38 | } 39 | .count_unread:after { 40 | content: ")"; 41 | } 42 | } 43 | } 44 | 45 | .yarr__conf-sidebar_override { 46 | .sidebar { 47 | display: none; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /frontend/components/sidebar/icons/add-circle-line.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/components/sidebar/icons/tools-fill.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/components/status/_index.scss: -------------------------------------------------------------------------------- 1 | /* 2 | ** Status notifications 3 | */ 4 | 5 | $colour-status-fg: $colour-white !default; 6 | $colour-status-bg-ok: $colour-green !default; 7 | $colour-status-bg-error: $colour-red-dark !default; 8 | 9 | 10 | .yarr { 11 | .status { 12 | padding: 0.6rem 1rem; 13 | font-weight: bold; 14 | color: $colour-status-fg; 15 | background-color: $colour-status-bg-ok; 16 | box-shadow: 0px 0px 5px 0px rgba(0, 0, 0, 0.7); 17 | 18 | &.error { 19 | background-color: $colour-status-bg-error; 20 | } 21 | 22 | @include media('<=tablet') { 23 | top: auto; 24 | bottom: 0; 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /frontend/components/status/index.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery'; 2 | 3 | export class Status { 4 | constructor() { 5 | this.con = null; 6 | this.$el = null; 7 | this.timeout = null; 8 | } 9 | ensureDom() { 10 | // Ensure the status element is in the dom 11 | // Delayed creation allows pages to override the container 12 | if (this.$el) { 13 | return; 14 | } 15 | 16 | this.$el = $('
').appendTo(this.con).hide(); 17 | } 18 | set(msg, isError) { 19 | this.ensureDom(); 20 | 21 | if (this.timeout) { 22 | clearTimeout(this.timeout); 23 | this.timeout = null; 24 | } 25 | 26 | if (!msg) { 27 | this.$el.hide(); 28 | return; 29 | } 30 | 31 | this.$el.text(msg).show(); 32 | this.$el.toggleClass('error', isError); 33 | 34 | this.timeout = setTimeout(() => this.close(), 1000); 35 | } 36 | close() { 37 | this.$el.fadeOut(); 38 | this.timeout = null; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /frontend/index.js: -------------------------------------------------------------------------------- 1 | import './pages/index.js'; 2 | -------------------------------------------------------------------------------- /frontend/index.scss: -------------------------------------------------------------------------------- 1 | /* 2 | ** CSS for Yarr 3 | */ 4 | @import 'include-media/dist/include-media'; 5 | 6 | @import './utils'; 7 | @import './layout'; 8 | @import './components'; 9 | @import './pages'; 10 | -------------------------------------------------------------------------------- /frontend/layout/_common.scss: -------------------------------------------------------------------------------- 1 | /** 2 | Common styles 3 | */ 4 | 5 | .yarr { 6 | table { 7 | width: 100%; 8 | 9 | th, td { 10 | text-align: left; 11 | vertical-align: top; 12 | } 13 | th { 14 | font-weight: $weight-bold; 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /frontend/layout/_index.scss: -------------------------------------------------------------------------------- 1 | @import './ui.scss'; 2 | @import './common.scss'; 3 | -------------------------------------------------------------------------------- /frontend/layout/_ui.scss: -------------------------------------------------------------------------------- 1 | /* 2 | ** Layout 3 | */ 4 | 5 | // Sizes 6 | $control-height: 3.5rem !default; 7 | $sidebar-width: 18em !default; 8 | 9 | .yarr { 10 | /* children are rows */ 11 | display: flex; 12 | flex-flow: column nowrap; 13 | 14 | height: 100%; 15 | 16 | & > .body { 17 | /* grow and shrink regardless of content */ 18 | flex: 1 1 auto; 19 | order: 2; 20 | 21 | /* provide an anchor for absolute children */ 22 | position: relative; 23 | 24 | .status { 25 | $status-width: 16rem; 26 | 27 | position: absolute; 28 | top: 0; 29 | left: 50%; 30 | width: $status-width; 31 | margin-left: -($status-width / 2); 32 | z-index: $z-index-status; 33 | } 34 | 35 | &.with-sidebar { 36 | /* children are columns */ 37 | display: flex; 38 | flex-flow: row nowrap; 39 | 40 | /* give it a fake height to force flex to calculate it */ 41 | height: 0; 42 | 43 | .sidebar { 44 | flex: 0 0 $sidebar-width; 45 | order: 1; 46 | z-index: $z-index-content; 47 | } 48 | 49 | .content { 50 | flex: 1 1 auto; 51 | order: 2; 52 | overflow-y: auto; 53 | z-index: $z-index-content; 54 | } 55 | } 56 | 57 | /* Content has no horizontal padding unless it's a top level element */ 58 | .content { 59 | padding: $padding-content 0; 60 | margin: 0; 61 | } 62 | 63 | & > .content { 64 | padding: $padding-content; 65 | order: 2; 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /frontend/pages/_feeds.scss: -------------------------------------------------------------------------------- 1 | .yarr { 2 | form.feed_form { 3 | table { 4 | width: 100%; 5 | 6 | tr { 7 | & > *:first-child { 8 | width: 10rem; 9 | text-align: right; 10 | padding-right: 0.5rem; 11 | } 12 | } 13 | input[type="text"] { 14 | width: 100%; 15 | } 16 | .helptext { 17 | font-size: 0.8rem; 18 | color: $colour-grey-dark; 19 | } 20 | } 21 | } 22 | 23 | table.feed_manage { 24 | width: 100%; 25 | 26 | tr { 27 | th { 28 | background-color: $colour-grey-dark; 29 | color: $colour-white; 30 | padding: 0.2rem; 31 | } 32 | td { 33 | padding: 0.2rem; 34 | } 35 | 36 | &:nth-child(2n) { 37 | background-color: $colour-grey-mid; 38 | } 39 | 40 | td:nth-child(2) { 41 | width: 4rem; 42 | } 43 | td:nth-child(3) { 44 | width: 4rem; 45 | } 46 | td:nth-child(4) { 47 | width: 12rem; 48 | } 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /frontend/pages/_index.scss: -------------------------------------------------------------------------------- 1 | @import './feeds'; 2 | -------------------------------------------------------------------------------- /frontend/pages/index.js: -------------------------------------------------------------------------------- 1 | import './list_entries.js'; 2 | -------------------------------------------------------------------------------- /frontend/utils/_index.scss: -------------------------------------------------------------------------------- 1 | 2 | @import './styles'; -------------------------------------------------------------------------------- /frontend/utils/constants.js: -------------------------------------------------------------------------------- 1 | /* 2 | ** Yarr Constants - also defined in Python in yarr/constants.py 3 | */ 4 | 5 | export const ORDER_ASC = 'asc'; 6 | export const ORDER_DESC = 'desc'; 7 | 8 | export const ENTRY_UNREAD = 0; 9 | export const ENTRY_READ = 1; 10 | export const ENTRY_SAVED = 2; 11 | -------------------------------------------------------------------------------- /frontend/utils/multiton.js: -------------------------------------------------------------------------------- 1 | /** Multiton class factory 2 | Turns a class constructor into a Multiton 3 | 4 | Call with an abstract class constructor 5 | Returns the constructor with a new .get() object method 6 | To get or create instance of class, call .get() with arguments; 7 | must pass at least one argument, which must be the key. 8 | All arguments are then passed on to the constructor. 9 | */ 10 | export const multiton = (cls) => { 11 | // Somewhere to store instances 12 | let registry = {}; 13 | 14 | // A wrapper class to pass arbitrary arguments on to the constructor 15 | function Cls(args) { 16 | return cls.apply(this, args); 17 | } 18 | 19 | cls.get = function (key) { 20 | // Copy across prototype in case it has changed 21 | Cls.prototype = cls.prototype; 22 | 23 | // Instantiate if necessary 24 | if (!(key in registry)) { 25 | registry[key] = new Cls(arguments); 26 | } 27 | return registry[key]; 28 | }; 29 | return cls; 30 | }; 31 | -------------------------------------------------------------------------------- /frontend/utils/styles/_colours.scss: -------------------------------------------------------------------------------- 1 | /* 2 | ** Named colours 3 | */ 4 | 5 | $colour-white: #fff; 6 | $colour-black: #000; 7 | 8 | $colour-green: #6a4; 9 | $colour-red: #ff7c60; 10 | $colour-red-dark: #a54; 11 | $colour-blue: #46a; 12 | 13 | $colour-grey: #ccc; 14 | $colour-grey-dark: #666; 15 | $colour-grey-mid: #eee; 16 | $colour-grey-light: #f8f8f8; 17 | 18 | 19 | // Global colours 20 | $colour-layout-border: $colour-grey !default; 21 | -------------------------------------------------------------------------------- /frontend/utils/styles/_fonts.scss: -------------------------------------------------------------------------------- 1 | /* 2 | ** Fonts 3 | */ 4 | 5 | $weight-light: 100; 6 | $weight-normal: normal; 7 | $weight-bold: bold; 8 | $weight-boldest: 900; 9 | 10 | // Character codes 11 | $char-burger: '\2261'; 12 | $char-cross: '\00D7'; 13 | $char-arrow-up: '\25B4'; 14 | $char-arrow-right: '\25B8'; 15 | $char-arrow-down: '\25BE'; 16 | $char-arrow-left: '\25C2'; 17 | -------------------------------------------------------------------------------- /frontend/utils/styles/_index.scss: -------------------------------------------------------------------------------- 1 | /* 2 | ** Settings and functions 3 | */ 4 | 5 | @import 'colours'; 6 | @import 'fonts'; 7 | @import 'static'; 8 | @import 'variables'; 9 | -------------------------------------------------------------------------------- /frontend/utils/styles/_static.scss: -------------------------------------------------------------------------------- 1 | /* 2 | ** Utils 3 | */ 4 | 5 | @function static($rel_path) { 6 | @return $static_path + '/' + $rel_path; 7 | } 8 | -------------------------------------------------------------------------------- /frontend/utils/styles/_variables.scss: -------------------------------------------------------------------------------- 1 | /* 2 | ** Variables 3 | */ 4 | 5 | // Media breakpoints 6 | $breakpoints: ( 7 | phone: 20rem, 8 | tablet: 48rem, 9 | desktop: 64rem 10 | ) !default; 11 | 12 | 13 | // Default static path for ``static()`` 14 | $static_path: '/static' !default; 15 | 16 | // Layers 17 | $z-index-content: 1 !default; 18 | $z-index-status: 2 !default; 19 | $z-index-dropdown: 3 !default; 20 | 21 | // Padding 22 | $padding-content: 1rem; 23 | -------------------------------------------------------------------------------- /frontend/utils/stylesheet.js: -------------------------------------------------------------------------------- 1 | /* 2 | ** Dynamic stylesheet 3 | */ 4 | 5 | export class Sheet { 6 | constructor() { 7 | document.addEventListener( 8 | 'DOMContentLoaded', 9 | () => { 10 | this._create(); 11 | }, 12 | false 13 | ); 14 | 15 | this.pending = []; 16 | this.el = null; 17 | } 18 | 19 | _create() { 20 | // Create last stylesheet 21 | this.el = document.createElement('style'); 22 | this.el.appendChild(document.createTextNode('')); // for webkit 23 | document.head.appendChild(this.el); 24 | for (var i=0; i < this.pending.length; i++) { 25 | var rule = this.pending[i][0]; 26 | var index = this.pending[i][1]; 27 | this._insertRule(rule, index); 28 | } 29 | } 30 | 31 | insertRule(rule, index=null) { 32 | if (!this.el) { 33 | this.pending.push([rule, index]); 34 | return; 35 | } 36 | this._insertRule(rule, index); 37 | } 38 | 39 | _insertRule(rule, index) { 40 | if (index === null) { 41 | index = this.el.sheet.cssRules.length; 42 | } 43 | this.el.sheet.insertRule(rule, index); 44 | } 45 | } 46 | 47 | // Singleton 48 | const sheet = new Sheet(); 49 | export default sheet; 50 | -------------------------------------------------------------------------------- /frontend/yarr.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery'; 2 | import { ENTRY_UNREAD, ENTRY_READ, ENTRY_SAVED } from './utils/constants'; 3 | import { multiton } from './utils/multiton'; 4 | import { Status } from './components/status'; 5 | 6 | 7 | const config = { 8 | con: '.yarr', 9 | ...(window.YARR_CONFIG || {}) 10 | }; 11 | 12 | // Prep globals 13 | export const Yarr = { 14 | status: new Status(), 15 | config: config 16 | }; 17 | 18 | // Additional initialisation on DOM ready 19 | $(function () { 20 | Yarr.el = { 21 | con: document.querySelector(config.con), 22 | control: document.querySelector(`${config.con} > .control`), 23 | body: document.querySelector(`${config.con} > .body`) 24 | }; 25 | Yarr.$con = $(config.con); 26 | Yarr.status.con = Yarr.el.body; 27 | }); 28 | 29 | /** API abstraction layer */ 30 | Yarr.API = (function () { 31 | var root_url = config.api, 32 | requestQueue = [], 33 | requesting = false 34 | ; 35 | 36 | function request(api_call, data, successFn, failFn) { 37 | if (!root_url) { 38 | Yarr.status.set('API not available', true); 39 | return; 40 | } 41 | 42 | if (requesting) { 43 | requestQueue.push([api_call, data, successFn, failFn]); 44 | return; 45 | } 46 | requesting = true; 47 | 48 | $.getJSON(root_url + api_call + '/', data) 49 | .done(function (json) { 50 | Yarr.status.set(json.msg, !json.success); 51 | if (json.success) { 52 | if (successFn) { 53 | successFn(json); 54 | } 55 | } else if (failFn) { 56 | failFn(json.msg); 57 | } 58 | nextRequest(); 59 | }) 60 | .fail(function (jqxhr, textStatus, error) { 61 | Yarr.status.set(textStatus + ': ' + error, true); 62 | if (failFn) { 63 | failFn(textStatus); 64 | } 65 | nextRequest(); 66 | }) 67 | ; 68 | } 69 | function nextRequest() { 70 | requesting = false; 71 | if (requestQueue.length === 0) { 72 | return; 73 | } 74 | request.apply(this, requestQueue.shift()); 75 | } 76 | 77 | // Hash for faster lookup 78 | var dates = { 'last_checked': 1, 'last_updated': 1, 'next_check': 1 }; 79 | return { 80 | getFeed: function (feed, successFn, failFn) { 81 | Yarr.API.getFeeds([feed.pk], successFn, failFn); 82 | }, 83 | getFeeds: function (feed_pks, successFn, failFn) { 84 | request( 85 | 'feed/get', { 'feed_pks': feed_pks.join(',') }, 86 | function (json) { 87 | // Load data into Feed instances 88 | var pk, feed, feeds = [], key, data; 89 | for (pk in json.feeds) { 90 | data = json.feeds[pk]; 91 | feed = Yarr.Feed.get(pk); 92 | for (key in data) { 93 | if (data[key] && key in dates) { 94 | data[key] = new Date(data[key]); 95 | } 96 | feed[key] = data[key]; 97 | } 98 | feed.loaded = true; 99 | feeds.push(feed); 100 | } 101 | if (successFn) { 102 | successFn(feeds); 103 | } 104 | }, failFn 105 | ); 106 | }, 107 | 108 | getFeedPks: function (feed, state, order, successFn, failFn) { 109 | Yarr.API.getFeedsPks([feed.pk], state, order, successFn, failFn); 110 | }, 111 | getFeedsPks: function (feed_pks, state, order, successFn, failFn) { 112 | request( 113 | 'feed/pks', { 114 | 'feed_pks': feed_pks.join(','), 115 | 'state': state, 116 | 'order': order 117 | }, 118 | function (json) { 119 | if (successFn) { 120 | successFn(json.pks, json.feed_unread); 121 | } 122 | }, failFn 123 | ); 124 | }, 125 | 126 | getEntry: function (entry, successFn, failFn) { 127 | Yarr.API.getEntries( 128 | [entry.pk], constants.ORDER_DESC, successFn, failFn 129 | ); 130 | }, 131 | getEntries: function (entry_pks, order, successFn, failFn) { 132 | request( 133 | 'entry/get', { 134 | 'entry_pks': entry_pks.join(','), 135 | 'order': order 136 | }, 137 | function (json) { 138 | // Load data into Entry instances 139 | var pk, entry, entries = [], key, data; 140 | for (pk in json.entries) { 141 | data = json.entries[pk]; 142 | entry = Yarr.Entry.get(pk); 143 | entry.feed = Yarr.Feed.get(data.feed_pk); 144 | delete data.feed_pk; 145 | for (key in data) { 146 | entry[key] = data[key]; 147 | } 148 | entry.loaded = true; 149 | entries.push(entry); 150 | } 151 | if (successFn) { 152 | successFn(entries); 153 | } 154 | }, failFn 155 | ); 156 | }, 157 | 158 | unreadEntry: function (entry, successFn, failFn) { 159 | Yarr.API.unreadEntries([entry.pk], successFn, failFn); 160 | }, 161 | readEntry: function (entry, successFn, failFn) { 162 | Yarr.API.readEntries([entry.pk], successFn, failFn); 163 | }, 164 | saveEntry: function (entry, successFn, failFn) { 165 | Yarr.API.saveEntries([entry.pk], successFn, failFn); 166 | }, 167 | 168 | unreadEntries: function (entry_pks, successFn, failFn) { 169 | Yarr.API.setEntries( 170 | entry_pks, ENTRY_UNREAD, null, successFn, failFn 171 | ); 172 | }, 173 | readEntries: function (entry_pks, successFn, failFn) { 174 | Yarr.API.setEntries( 175 | entry_pks, ENTRY_READ, null, successFn, failFn 176 | ); 177 | }, 178 | saveEntries: function (entry_pks, successFn, failFn) { 179 | Yarr.API.setEntries( 180 | entry_pks, ENTRY_SAVED, null, successFn, failFn 181 | ); 182 | }, 183 | 184 | setEntries: function (entry_pks, state, if_state, successFn, failFn) { 185 | request('entry/set', { 186 | 'entry_pks': entry_pks.join(','), 187 | 'state': state, 188 | 'if_state': if_state 189 | }, successFn, failFn); 190 | } 191 | }; 192 | })(); 193 | 194 | 195 | /** Feed object */ 196 | Yarr.Feed = multiton(function (pk) { 197 | this.pk = pk; 198 | this.loaded = false; 199 | }); 200 | Yarr.Feed.prototype = $.extend(Yarr.Feed.prototype, { 201 | load: function (successFn, failFn) { 202 | /** Load feed data */ 203 | if (this.loaded) { 204 | return successFn(); 205 | } 206 | Yarr.API.getFeed(this, successFn, failFn); 207 | }, 208 | toString: function () { 209 | return this.text || this.title || 'untitled'; 210 | } 211 | }); 212 | 213 | /** Entry object */ 214 | Yarr.Entry = multiton(function (pk) { 215 | this.pk = pk; 216 | this.loaded = false; 217 | }); 218 | Yarr.Entry.prototype = $.extend(Yarr.Entry.prototype, { 219 | load: function (successFn, failFn) { 220 | /** Load entry data */ 221 | if (this.loaded) { 222 | return successFn(); 223 | } 224 | Yarr.API.getEntry(this, successFn, failFn); 225 | } 226 | }); 227 | 228 | 229 | /** Cookie management */ 230 | Yarr.Cookie = { 231 | set: function (key, value) { 232 | /** Set the cookie */ 233 | var expires = new Date(); 234 | expires.setDate(expires.getDate() + 3650); 235 | document.cookie = [ 236 | encodeURIComponent(key), '=', value, 237 | '; expires=' + expires.toUTCString(), 238 | '; path=/', 239 | (window.location.protocol == 'https:') ? '; secure' : '' 240 | ].join(''); 241 | }, 242 | get: function (key, defaultValue) { 243 | /** Get all cookies */ 244 | var pairs = document.cookie.split('; '); 245 | for (var i = 0, pair; pair = pairs[i] && pairs[i].split('='); i++) { 246 | if (decodeURIComponent(pair[0]) === key) return pair[1]; 247 | } 248 | return defaultValue; 249 | } 250 | }; 251 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yarr", 3 | "version": "0.5.0", 4 | "description": "django-yarr", 5 | "main": "index.js", 6 | "author": "Richard Terry", 7 | "license": "BSD", 8 | "private": true, 9 | "devDependencies": { 10 | "@babel/cli": "^7.2.0", 11 | "@babel/core": "^7.2.0", 12 | "@babel/preset-env": "^7.2.0", 13 | "@rushstack/set-webpack-public-path-plugin": "^2.3.4", 14 | "babel-loader": "^8.0.4", 15 | "clean-webpack-plugin": "^3.0.0", 16 | "css-loader": "^4.3.0", 17 | "image-webpack-loader": "^7.0.0", 18 | "mini-css-extract-plugin": "^0.11.2", 19 | "node-sass": "^4.11.0", 20 | "optimize-css-assets-webpack-plugin": "^5.0.1", 21 | "resolve-url-loader": "^3.1.0", 22 | "sass-loader": "^10.0.2", 23 | "source-map-loader": "^1.1.0", 24 | "style-loader": "^1.1.3", 25 | "svg-url-loader": "^6.0.0", 26 | "url-loader": "^4.1.0", 27 | "webpack": "^4.27.1", 28 | "webpack-cli": "^3.1.2", 29 | "webpack-dev-server": "^3.1.10" 30 | }, 31 | "scripts": { 32 | "watch": "webpack-dev-server --host 0 --mode development", 33 | "dev": "webpack --mode development", 34 | "build": "webpack --mode production", 35 | "test": "jest" 36 | }, 37 | "dependencies": { 38 | "include-media": "^1.4.9", 39 | "jquery": "^3.5.1" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /requirements.in: -------------------------------------------------------------------------------- 1 | # 2 | # Developer requirements 3 | # 4 | # After compiling, remove django from requirements.txt to avoid a clash with tox 5 | # 6 | 7 | # Dev tools 8 | black 9 | flake8 10 | isort 11 | pip-tools 12 | 13 | # Testing 14 | model_bakery 15 | pytest 16 | pytest-black 17 | pytest-cov 18 | pytest-django 19 | pytest-flake8 20 | pytest-isort 21 | pytest-mypy 22 | tox 23 | 24 | # Docs 25 | sphinx 26 | sphinx-autobuild 27 | 28 | # Project (setup.cfg) 29 | django~=3.2.0 30 | django-yaa-settings 31 | bleach[css]>=5.0.0 32 | feedparser>=6.0.0 33 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with python 3.8 3 | # To update, run: 4 | # 5 | # pip-compile 6 | # 7 | alabaster==0.7.12 8 | # via sphinx 9 | asgiref==3.5.2 10 | # via django 11 | attrs==21.4.0 12 | # via 13 | # pytest 14 | # pytest-mypy 15 | babel==2.10.3 16 | # via sphinx 17 | black==22.6.0 18 | # via 19 | # -r requirements.in 20 | # pytest-black 21 | bleach[css]==5.0.1 22 | # via -r requirements.in 23 | build==0.8.0 24 | # via pip-tools 25 | certifi==2022.6.15 26 | # via requests 27 | charset-normalizer==2.1.0 28 | # via requests 29 | click==8.1.3 30 | # via 31 | # black 32 | # pip-tools 33 | colorama==0.4.5 34 | # via sphinx-autobuild 35 | coverage[toml]==6.4.1 36 | # via pytest-cov 37 | distlib==0.3.4 38 | # via virtualenv 39 | django==3.2.14 40 | # via 41 | # -r requirements.in 42 | # model-bakery 43 | django-yaa-settings==1.1.0 44 | # via -r requirements.in 45 | docutils==0.18.1 46 | # via sphinx 47 | feedparser==6.0.10 48 | # via -r requirements.in 49 | filelock==3.7.1 50 | # via 51 | # pytest-mypy 52 | # tox 53 | # virtualenv 54 | flake8==4.0.1 55 | # via 56 | # -r requirements.in 57 | # pytest-flake8 58 | idna==3.3 59 | # via requests 60 | imagesize==1.4.1 61 | # via sphinx 62 | importlib-metadata==4.12.0 63 | # via sphinx 64 | iniconfig==1.1.1 65 | # via pytest 66 | isort==5.10.1 67 | # via 68 | # -r requirements.in 69 | # pytest-isort 70 | jinja2==3.1.2 71 | # via sphinx 72 | livereload==2.6.3 73 | # via sphinx-autobuild 74 | markupsafe==2.1.1 75 | # via jinja2 76 | mccabe==0.6.1 77 | # via flake8 78 | model-bakery==1.6.0 79 | # via -r requirements.in 80 | mypy==0.961 81 | # via pytest-mypy 82 | mypy-extensions==0.4.3 83 | # via 84 | # black 85 | # mypy 86 | packaging==21.3 87 | # via 88 | # build 89 | # pytest 90 | # sphinx 91 | # tox 92 | pathspec==0.9.0 93 | # via black 94 | pep517==0.12.0 95 | # via build 96 | pip-tools==6.8.0 97 | # via -r requirements.in 98 | platformdirs==2.5.2 99 | # via 100 | # black 101 | # virtualenv 102 | pluggy==1.0.0 103 | # via 104 | # pytest 105 | # tox 106 | py==1.11.0 107 | # via 108 | # pytest 109 | # tox 110 | pycodestyle==2.8.0 111 | # via flake8 112 | pyflakes==2.4.0 113 | # via flake8 114 | pygments==2.12.0 115 | # via sphinx 116 | pyparsing==3.0.9 117 | # via packaging 118 | pytest==7.1.2 119 | # via 120 | # -r requirements.in 121 | # pytest-black 122 | # pytest-cov 123 | # pytest-django 124 | # pytest-flake8 125 | # pytest-isort 126 | # pytest-mypy 127 | pytest-black==0.3.12 128 | # via -r requirements.in 129 | pytest-cov==3.0.0 130 | # via -r requirements.in 131 | pytest-django==4.5.2 132 | # via -r requirements.in 133 | pytest-flake8==1.1.1 134 | # via -r requirements.in 135 | pytest-isort==3.0.0 136 | # via -r requirements.in 137 | pytest-mypy==0.9.1 138 | # via -r requirements.in 139 | pytz==2022.1 140 | # via 141 | # babel 142 | # django 143 | requests==2.28.1 144 | # via sphinx 145 | sgmllib3k==1.0.0 146 | # via feedparser 147 | six==1.16.0 148 | # via 149 | # bleach 150 | # livereload 151 | # tox 152 | # virtualenv 153 | snowballstemmer==2.2.0 154 | # via sphinx 155 | sphinx==5.0.2 156 | # via 157 | # -r requirements.in 158 | # sphinx-autobuild 159 | sphinx-autobuild==2021.3.14 160 | # via -r requirements.in 161 | sphinxcontrib-applehelp==1.0.2 162 | # via sphinx 163 | sphinxcontrib-devhelp==1.0.2 164 | # via sphinx 165 | sphinxcontrib-htmlhelp==2.0.0 166 | # via sphinx 167 | sphinxcontrib-jsmath==1.0.1 168 | # via sphinx 169 | sphinxcontrib-qthelp==1.0.3 170 | # via sphinx 171 | sphinxcontrib-serializinghtml==1.1.5 172 | # via sphinx 173 | sqlparse==0.4.2 174 | # via django 175 | tinycss2==1.1.1 176 | # via bleach 177 | toml==0.10.2 178 | # via 179 | # pytest-black 180 | # tox 181 | tomli==2.0.1 182 | # via 183 | # black 184 | # build 185 | # coverage 186 | # mypy 187 | # pytest 188 | tornado==6.2 189 | # via livereload 190 | tox==3.25.1 191 | # via -r requirements.in 192 | typing-extensions==4.3.0 193 | # via 194 | # black 195 | # mypy 196 | urllib3==1.26.9 197 | # via requests 198 | virtualenv==20.15.1 199 | # via tox 200 | webencodings==0.5.1 201 | # via 202 | # bleach 203 | # tinycss2 204 | wheel==0.37.1 205 | # via pip-tools 206 | zipp==3.8.0 207 | # via importlib-metadata 208 | 209 | # The following packages are considered to be unsafe in a requirements file: 210 | # pip 211 | # setuptools 212 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = django-yarr 3 | description = A lightweight customisable RSS reader for Django 4 | long_description = file: README.rst 5 | keywords = django rss 6 | author = Richard Terry 7 | author_email = code@radiac.net 8 | license = BSD 9 | classifiers = 10 | Development Status :: 4 - Beta 11 | Environment :: Web Environment 12 | Framework :: Django 13 | Framework :: Django :: 2.2 14 | Framework :: Django :: 3.0 15 | Framework :: Django :: 3.1 16 | Intended Audience :: Developers 17 | License :: OSI Approved :: BSD License 18 | Operating System :: OS Independent 19 | Programming Language :: Python 20 | Programming Language :: Python :: 3 21 | Programming Language :: Python :: 3 :: Only 22 | Programming Language :: Python :: 3.7 23 | Programming Language :: Python :: 3.8 24 | url = https://radiac.net/projects/django-yarr/ 25 | project_urls = 26 | Documentation = https://radiac.net/projects/django-yarr/ 27 | Source = https://github.com/radiac/django-yarr 28 | Tracker = https://github.com/radiac/django-yarr/issues 29 | 30 | [options] 31 | python_requires = >=3.7 32 | packages = find: 33 | install_requires = 34 | Django>=2.2 35 | django-yaa-settings 36 | bleach[css]>=5.0.0 37 | feedparser>=6.0.0 38 | include_package_data = true 39 | zip_safe = false 40 | 41 | [options.packages.find] 42 | exclude = tests* 43 | 44 | [tool:pytest] 45 | addopts = --black --flake8 --isort --cov=yarr --cov-report=term --cov-report=html 46 | testpaths = 47 | tests 48 | yarr 49 | example 50 | DJANGO_SETTINGS_MODULE = tests.settings 51 | 52 | [flake8] 53 | max-line-length = 88 54 | ignore = E123,E128,E203,E231,E266,E501,W503 55 | exclude = .tox,.git,*/static/CACHE/*,docs,node_modules,static_root,tmp 56 | 57 | [isort] 58 | multi_line_output = 3 59 | line_length = 88 60 | known_django = django 61 | sections = FUTURE,STDLIB,DJANGO,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 62 | include_trailing_comma = True 63 | lines_after_imports = 2 64 | skip = .git,node_modules,.tox 65 | 66 | [coverage:report] 67 | omit=example 68 | 69 | [doc8] 70 | max-line-length = 88 71 | ignore-path = *.txt,.tox,node_modules 72 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import re 2 | from pathlib import Path 3 | 4 | from setuptools import setup 5 | 6 | 7 | def find_version(*paths): 8 | path = Path(*paths) 9 | content = path.read_text() 10 | match = re.search(r"^__version__\s*=\s*['\"]([^'\"]*)['\"]", content, re.M) 11 | if match: 12 | return match.group(1) 13 | raise RuntimeError("Unable to find version string.") 14 | 15 | 16 | setup(version=find_version("yarr", "__init__.py")) 17 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radiac/django-yarr/0b087298311a0e7b3901f59ba74e6bd27278a5e9/tests/__init__.py -------------------------------------------------------------------------------- /tests/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class YarrConfig(AppConfig): 5 | name = "yarr" 6 | verbose_name = "Yarr" 7 | -------------------------------------------------------------------------------- /tests/feed1-wellformed.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Well-formed 4 | http://example.com/wellformed 5 | Tue, 02 Jul 2013 02:02:02 GMT 6 | 7 | Item 1 8 | Content 1 9 | http://example.com/?item=1 10 | Mon, 01 Jul 2013 01:01:01 GMT 11 | 12 | 13 | Item 2 14 | Content 2 15 | http://example.com/?item=2 16 | Tue, 02 Jul 2013 02:02:02 GMT 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /tests/feed2-malformed.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | crashes here 22 | 23 | 24 | 25 | 26 | 27 | 2 | 3 | Feed with img in item 4 | http://example.com/with_img 5 | Tue, 02 Jul 2013 02:02:02 GMT 6 | 7 | Item 1 8 | <img src="http://example.com/webcomic.png" alt="alt text" title="annoying in-joke" width="100" height="200"> 9 | http://example.com/?item=1 10 | Mon, 01 Jul 2013 01:01:01 GMT 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for testapp project. 3 | 4 | Generated by 'django-admin startproject' using Django 2.2.15. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/2.2/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | 16 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 17 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 18 | 19 | 20 | # Quick-start development settings - unsuitable for production 21 | # See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/ 22 | 23 | # SECURITY WARNING: keep the secret key used in production secret! 24 | SECRET_KEY = "secret" 25 | 26 | # SECURITY WARNING: don't run with debug turned on in production! 27 | DEBUG = True 28 | 29 | ALLOWED_HOSTS = [] 30 | 31 | 32 | # Application definition 33 | 34 | INSTALLED_APPS = [ 35 | "django.contrib.admin", 36 | "django.contrib.auth", 37 | "django.contrib.contenttypes", 38 | "django.contrib.sessions", 39 | "django.contrib.messages", 40 | "django.contrib.staticfiles", 41 | "yarr", 42 | ] 43 | 44 | MIDDLEWARE = [ 45 | "django.middleware.security.SecurityMiddleware", 46 | "django.contrib.sessions.middleware.SessionMiddleware", 47 | "django.middleware.common.CommonMiddleware", 48 | "django.middleware.csrf.CsrfViewMiddleware", 49 | "django.contrib.auth.middleware.AuthenticationMiddleware", 50 | "django.contrib.messages.middleware.MessageMiddleware", 51 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 52 | ] 53 | 54 | ROOT_URLCONF = "tests.urls" 55 | 56 | TEMPLATES = [ 57 | { 58 | "BACKEND": "django.template.backends.django.DjangoTemplates", 59 | "DIRS": [], 60 | "APP_DIRS": True, 61 | "OPTIONS": { 62 | "context_processors": [ 63 | "django.template.context_processors.debug", 64 | "django.template.context_processors.request", 65 | "django.contrib.auth.context_processors.auth", 66 | "django.contrib.messages.context_processors.messages", 67 | ] 68 | }, 69 | } 70 | ] 71 | 72 | 73 | # Database 74 | # https://docs.djangoproject.com/en/2.2/ref/settings/#databases 75 | 76 | DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}} 77 | 78 | 79 | # Password validation 80 | # https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators 81 | 82 | AUTH_PASSWORD_VALIDATORS = [ 83 | { 84 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator" 85 | }, 86 | {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, 87 | {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, 88 | {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, 89 | ] 90 | 91 | 92 | # Internationalization 93 | # https://docs.djangoproject.com/en/2.2/topics/i18n/ 94 | 95 | LANGUAGE_CODE = "en-us" 96 | 97 | TIME_ZONE = "UTC" 98 | 99 | USE_I18N = True 100 | 101 | USE_L10N = True 102 | 103 | USE_TZ = True 104 | 105 | MEDIA_ROOT = os.path.join(BASE_DIR, "test_media") 106 | MEDIA_URL = "/media/" 107 | -------------------------------------------------------------------------------- /tests/test_opml.py: -------------------------------------------------------------------------------- 1 | import difflib 2 | from xml.dom import minidom 3 | from xml.etree import ElementTree 4 | 5 | from django.contrib.auth.models import User 6 | from django.test import TestCase 7 | 8 | from yarr.utils import export_opml 9 | 10 | 11 | class ExportTests(TestCase): 12 | def setUp(self): 13 | self.user = User.objects.create_user("test", "test@example.com", "test") 14 | 15 | def test_empty(self): 16 | """ 17 | An empty OPML document is generated for a user with no feeds. 18 | """ 19 | expected = ( 20 | '' 21 | "" 22 | "test subscriptions" 23 | "" 24 | "" 25 | "" 26 | ) 27 | self.assert_equal_xml(expected, export_opml(self.user)) 28 | 29 | def test_single_feed(self): 30 | self.user.feed_set.create( 31 | title="Feed 1", feed_url="http://example.com/feed.xml" 32 | ) 33 | expected = ( 34 | '' 35 | "" 36 | "test subscriptions" 37 | "" 38 | "" 39 | '' 41 | "" 42 | "" 43 | ) 44 | self.assert_equal_xml(expected, export_opml(self.user)) 45 | 46 | def test_unicode_title(self): 47 | self.user.feed_set.create( 48 | title="\u2042", feed_url="http://example.com/feed.xml" 49 | ) 50 | expected = ( 51 | '' 52 | "" 53 | "test subscriptions" 54 | "" 55 | "" 56 | '' 58 | "" 59 | "" 60 | ).encode("utf-8") 61 | self.assert_equal_xml(expected, export_opml(self.user)) 62 | 63 | def test_site_url(self): 64 | self.user.feed_set.create( 65 | title="Example", 66 | feed_url="http://example.com/feed.xml", 67 | site_url="http://example.com/", 68 | ) 69 | expected = ( 70 | '' 71 | "" 72 | "test subscriptions" 73 | "" 74 | "" 75 | '' 78 | "" 79 | "" 80 | ) 81 | self.assert_equal_xml(expected, export_opml(self.user)) 82 | 83 | def assert_equal_xml(self, a, b): 84 | """ 85 | Poor man's XML differ. 86 | """ 87 | a_el = ElementTree.fromstring(a) 88 | b_el = ElementTree.fromstring(b) 89 | if not etree_equal(a_el, b_el): 90 | a_str = pretty_etree(a_el).splitlines() 91 | b_str = pretty_etree(b_el).splitlines() 92 | diff = difflib.unified_diff(a_str, b_str, fromfile="a", tofile="b") 93 | full_diff = "\n".join(diff).encode("utf-8") 94 | self.fail("XML not equivalent:\n\n{}".format(full_diff)) 95 | 96 | 97 | def pretty_etree(e): 98 | s = ElementTree.tostring(e, "utf-8") 99 | return minidom.parseString(s).toprettyxml(indent=" ") 100 | 101 | 102 | def etree_equal(a, b): 103 | """ 104 | Determine whether two :class:`xml.etree.ElementTree.Element` trees are 105 | equivalent. 106 | 107 | >>> from xml.etree.ElementTree import Element, SubElement as SE, fromstring 108 | >>> a = fromstring('') 109 | >>> b = fromstring('') 110 | >>> etree_equal(a, a), etree_equal(a, b) 111 | (True, True) 112 | >>> c = fromstring('') 113 | >>> d = fromstring('') 114 | >>> etree_equal(a, c), etree_equal(c, d) 115 | (False, True) 116 | """ 117 | return ( 118 | a.tag == b.tag 119 | and a.text == b.text 120 | and a.tail == b.tail 121 | and a.attrib == b.attrib 122 | and len(a) == len(b) 123 | and all(etree_equal(x, y) for (x, y) in zip(a, b)) 124 | ) 125 | -------------------------------------------------------------------------------- /tests/test_yarr.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.contrib.auth.models import User 4 | from django.test import TestCase 5 | 6 | import six 7 | 8 | from yarr.decorators import with_socket_timeout 9 | from yarr.models import Feed 10 | 11 | 12 | class FeedTest(TestCase): 13 | def setUp(self): 14 | test_path = os.path.dirname(__file__) 15 | user = User.objects.create_user("test", "test@example.com", "test") 16 | self.feed_wellformed = Feed.objects.create( 17 | title="Feed: well-formed", 18 | user=user, 19 | feed_url=os.path.join(test_path, "feed1-wellformed.xml"), 20 | ) 21 | self.feed_malformed = Feed.objects.create( 22 | title="Feed: malformed", 23 | user=user, 24 | feed_url=os.path.join(test_path, "feed2-malformed.xml"), 25 | ) 26 | 27 | self.feed_missing_server = Feed.objects.create( 28 | title="Feed: missing server", 29 | user=user, 30 | feed_url="http://missing.example.com/", 31 | ) 32 | 33 | self.feed_with_img = Feed.objects.create( 34 | title="Feed: has ", 35 | user=user, 36 | feed_url=os.path.join(test_path, "feed4-with-img.xml"), 37 | ) 38 | 39 | def test_feed_wellformed(self): 40 | """ 41 | Test wellformed feed 42 | """ 43 | # Update the feed 44 | self.feed_wellformed.check_feed() 45 | 46 | # Check the feed data 47 | self.assertEqual(self.feed_wellformed.site_url, "http://example.com/wellformed") 48 | 49 | # Check the entries (newest first) 50 | entries = self.feed_wellformed.entries.all()[0:] 51 | self.assertEqual(len(entries), 2) 52 | self.assertEqual(entries[0].feed, self.feed_wellformed) 53 | self.assertEqual(entries[0].title, "Item 2") 54 | self.assertEqual(entries[0].content, "Content 2") 55 | self.assertEqual(entries[0].url, "http://example.com/?item=2") 56 | # ++ Cannot assert date without knowing server timezone 57 | self.assertEqual(entries[1].title, "Item 1") 58 | self.assertEqual(entries[1].content, "Content 1") 59 | self.assertEqual(entries[1].url, "http://example.com/?item=1") 60 | 61 | def test_feed_malformed(self): 62 | """ 63 | Test malformed feed 64 | """ 65 | # Update the feed 66 | self.feed_malformed.check_feed() 67 | 68 | # Check the feed data 69 | self.assertEqual(self.feed_malformed.site_url, "") 70 | self.assertEqual(self.feed_malformed.is_active, True) 71 | six.assertRegex( 72 | self, self.feed_malformed.error, r"^Feed error: SAXParseException - " 73 | ) 74 | 75 | @with_socket_timeout 76 | def test_http_error(self): 77 | """ 78 | Test HTTP errors 79 | """ 80 | # Update the feed 81 | self.feed_missing_server.check_feed() 82 | 83 | # Check the feed object 84 | self.assertEqual(self.feed_missing_server.is_active, True) 85 | six.assertRegex( 86 | self, 87 | self.feed_missing_server.error, 88 | r"^Feed error: .+?Name or service not known", 89 | ) 90 | 91 | def test_feed_with_img(self): 92 | """ 93 | With the default settings, an ```` tag should be permitted 94 | ``src``, ``alt``, ``title``, ``width``, and ``height`` attributes. 95 | """ 96 | # Update the feed. 97 | self.feed_with_img.check_feed() 98 | (entry,) = self.feed_with_img.entries.all() 99 | 100 | self.assertHTMLEqual( 101 | entry.content, 102 | 'alt text', 104 | ) 105 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | """testapp URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/2.2/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.urls import include, path 17 | 18 | 19 | urlpatterns = [ 20 | path("yarr/", include("yarr.urls", namespace="yarr")), 21 | ] 22 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | clean 4 | py{37,38}-django{2.2,3.0,3.1} 5 | report 6 | 7 | [testenv] 8 | skipsdist=True 9 | usedevelop=True 10 | passenv = TRAVIS TRAVIS_JOB_ID TRAVIS_BRANCH 11 | setenv = 12 | PYTHONWARNINGS=default 13 | TOXENV={envname} 14 | depends = 15 | py{37,38}-django{2.2}: clean 16 | report: py{37,38}-django{2.2} 17 | deps = 18 | -rrequirements.txt 19 | coveralls 20 | django2.2: Django==2.2.* 21 | django3.0: Django==3.0.* 22 | django3.1: Django==3.1.* 23 | commands = 24 | pytest --cov-append {posargs} 25 | -coveralls 26 | 27 | [testenv:clean] 28 | deps = coverage 29 | skip_install = true 30 | commands = 31 | -coverage erase 32 | 33 | [testenv:report] 34 | deps = coverage 35 | skip_install = true 36 | commands = 37 | -coverage report 38 | -coverage html 39 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 2 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 3 | const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin'); 4 | const path = require('path'); 5 | const setPublicPath = require('@rushstack/set-webpack-public-path-plugin'); 6 | const webpack = require('webpack'); 7 | 8 | // Only embed assets under 10 KB 9 | const embedLimit = 10240; 10 | const devServerPort = 8080; 11 | const pathRoot = 'frontend'; 12 | const pathDist = `yarr/static/yarr`; 13 | 14 | /* 15 | * Rules which change depending on mode 16 | */ 17 | 18 | // Image optimisation rule 19 | const moduleRuleOptimiseImages = { 20 | test: /\.(gif|png|jpe?g|svg)$/iu, 21 | loader: 'image-webpack-loader', 22 | options: { 23 | // Apply the loader before url-loader and svg-url-loader 24 | enforce: 'pre', 25 | // Disabled in dev, enable in production 26 | disable: true, 27 | }, 28 | }; 29 | 30 | // Style building rule 31 | const moduleRuleScss = { 32 | test: /\.scss$/u, 33 | use: [ 34 | // MiniCssExtractPlugin doesn't support HMR, so will replace style-loader 35 | // in production 36 | 'style-loader', 37 | 'css-loader', 38 | { 39 | loader: 'resolve-url-loader', 40 | options: { 41 | 'sourceMap': true, 42 | }, 43 | }, 44 | { 45 | loader: 'sass-loader', 46 | options: { 47 | sassOptions: { 48 | includePaths: ['./node_modules'], 49 | sourceMap: true, 50 | sourceMapContents: false 51 | }, 52 | }, 53 | }, 54 | ], 55 | }; 56 | 57 | 58 | /* 59 | ** Main config 60 | */ 61 | const config = { 62 | entry: { 63 | index: [ 64 | `./${pathRoot}/index.js`, 65 | `./${pathRoot}/index.scss`, 66 | ], 67 | }, 68 | output: { 69 | filename: '[name].js', 70 | path: path.resolve( 71 | __dirname, 72 | pathDist, 73 | ), 74 | publicPath: '/static/yarr/', 75 | }, 76 | 77 | // Enable sourcemaps for debugging webpack's output. 78 | devtool: 'source-map', 79 | devServer: { 80 | contentBase: path.resolve(__dirname, pathDist), 81 | contentBasePublicPath: '/static/yarr/', 82 | publicPath: '/static/yarr/', 83 | hot: true, 84 | disableHostCheck: true, 85 | port: devServerPort, 86 | headers: { 87 | 'Access-Control-Allow-Origin': '*' 88 | } 89 | }, 90 | 91 | resolve: { 92 | alias: { 93 | '~': `${__dirname}/${pathRoot}`, 94 | }, 95 | extensions: ['.js', '.json'], 96 | }, 97 | 98 | plugins: [ 99 | new CleanWebpackPlugin({ 100 | // Remove built js and css from the dist folder before building 101 | cleanOnceBeforeBuildPatterns: ['**/*', '!.gitkeep'], 102 | }), 103 | new MiniCssExtractPlugin({ 104 | // Options similar to the same options in webpackOptions.output 105 | // both options are optional 106 | filename: '[name].css', 107 | }), 108 | ], 109 | 110 | module: { 111 | rules: [ 112 | // All output '.js' files will have any sourcemaps re-processed by 'source-map-loader'. 113 | { 114 | test: /\.js$/u, 115 | use: { 116 | loader: 'babel-loader', 117 | options: { 118 | presets: ['@babel/preset-env'], 119 | }, 120 | }, 121 | }, 122 | 123 | // Optimise images 124 | moduleRuleOptimiseImages, 125 | 126 | // Embed small images and fonts 127 | { 128 | test: /\.(png|jpg|gif|eot|ttf|woff|woff2)$/u, 129 | loader: 'url-loader', 130 | options: { 131 | limit: embedLimit, 132 | }, 133 | }, 134 | { 135 | test: /\.svg$/u, 136 | loader: 'svg-url-loader', 137 | options: { 138 | limit: embedLimit, 139 | noquotes: true, 140 | } 141 | }, 142 | 143 | // SCSS 144 | moduleRuleScss, 145 | ], 146 | }, 147 | }; 148 | 149 | 150 | module.exports = (env, argv) => { 151 | if (argv.mode === 'production') { 152 | console.log(`Running in production mode: 153 | * optimising images 154 | * extracting and minifying CSS 155 | `); 156 | 157 | // Switch from style-loader to generate separate css files 158 | moduleRuleScss.use[0] = MiniCssExtractPlugin.loader; 159 | 160 | // Activate image optimisation 161 | moduleRuleOptimiseImages.options.disable = false; 162 | 163 | // Optimise CSS assets 164 | config.plugins.push(new OptimizeCssAssetsPlugin()); 165 | } else if (process.argv[1].indexOf('webpack-dev-server') > -1) { 166 | console.log('Running in development mode with HMR'); 167 | 168 | // Add HMR and change the public path to pick up the correct hostname 169 | config.plugins.push( 170 | new webpack.HotModuleReplacementPlugin(), 171 | new setPublicPath.SetPublicPathPlugin({ 172 | scriptName: { 173 | name: '[name].js', 174 | isTokenized: true, 175 | }, 176 | }), 177 | ); 178 | } else { 179 | console.log(`Running in development mode: 180 | * no optimisation or minification 181 | * single bundle.js 182 | `); 183 | } 184 | return config; 185 | }; 186 | -------------------------------------------------------------------------------- /yarr/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django Yarr - Yet Another RSS Reader 3 | """ 4 | 5 | __version__ = "0.7.0" 6 | __license__ = "BSD" 7 | __author__ = "Richard Terry" 8 | -------------------------------------------------------------------------------- /yarr/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from yarr import models 4 | 5 | 6 | class FeedAdmin(admin.ModelAdmin): 7 | list_display = ["title", "is_active", "user", "next_check", "error"] 8 | list_filter = ["is_active", "user"] 9 | search_fields = ["title", "feed_url", "site_url"] 10 | actions = ["deactivate", "clear_error"] 11 | 12 | def deactivate(self, request, queryset): 13 | queryset.update(is_active=False) 14 | 15 | deactivate.short_description = "Deactivate feed" 16 | 17 | def clear_error(self, request, queryset): 18 | queryset.update(is_active=True, error="") 19 | 20 | clear_error.short_description = "Clear error and reactivate feed" 21 | 22 | 23 | admin.site.register(models.Feed, FeedAdmin) 24 | 25 | 26 | class EntryAdmin(admin.ModelAdmin): 27 | list_display = ["title", "date", "state", "feed"] 28 | list_select_related = True 29 | search_fields = ["title", "content"] 30 | 31 | 32 | admin.site.register(models.Entry, EntryAdmin) 33 | -------------------------------------------------------------------------------- /yarr/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class YarrConfig(AppConfig): 5 | default_auto_field = "django.db.models.AutoField" 6 | name = "yarr" 7 | -------------------------------------------------------------------------------- /yarr/constants.py: -------------------------------------------------------------------------------- 1 | """ 2 | Yarr constants 3 | 4 | Also defined in JavaScript in utils/constants.js 5 | """ 6 | 7 | ENTRY_UNREAD = 0 8 | ENTRY_READ = 1 9 | ENTRY_SAVED = 2 10 | 11 | ORDER_DESC = "" 12 | ORDER_ASC = "asc" 13 | 14 | SIDEBAR_DEFAULT = "" 15 | SIDEBAR_OVERRIDE = "override" 16 | 17 | LAYOUT_ARTICLE = "" 18 | LAYOUT_LIST = "list" 19 | -------------------------------------------------------------------------------- /yarr/decorators.py: -------------------------------------------------------------------------------- 1 | import socket 2 | 3 | from . import settings 4 | 5 | 6 | def with_socket_timeout(fn): 7 | """ 8 | Call a function while the global socket timeout is ``YARR_SOCKET_TIMEOUT`` 9 | 10 | The socket timeout value is set before calling the function, then reset to 11 | the original timeout value afterwards 12 | 13 | Note: This is not thread-safe. 14 | """ 15 | 16 | def wrap(*args, **kwargs): 17 | # Set global socket 18 | old_timeout = socket.getdefaulttimeout() 19 | socket.setdefaulttimeout(settings.SOCKET_TIMEOUT) 20 | 21 | # Call fn 22 | r = fn(*args, **kwargs) 23 | 24 | # Reset global socket 25 | socket.setdefaulttimeout(old_timeout) 26 | 27 | return r 28 | 29 | return wrap 30 | -------------------------------------------------------------------------------- /yarr/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | from . import models, settings 4 | 5 | 6 | class AddFeedForm(forms.ModelForm): 7 | required_css_class = "required" 8 | 9 | class Meta: 10 | model = models.Feed 11 | fields = ["feed_url"] 12 | widgets = {"feed_url": forms.TextInput()} 13 | 14 | 15 | def _build_frequency_choices(): 16 | """ 17 | Build a choices list of frequencies 18 | This will be removed when Yarr moves to automated frequencies 19 | """ 20 | choices = [] 21 | current = settings.MAXIMUM_INTERVAL 22 | HOUR = 60 23 | DAY = 60 * 24 24 | MIN = settings.MINIMUM_INTERVAL 25 | while current >= MIN: 26 | # Create humanised relative time 27 | # There are many ways to do this, but to avoid introducing a dependency 28 | # only to remove it again a few releases later, we'll do this by hand 29 | dd = 0 30 | hh = 0 31 | mm = current 32 | parts = [] 33 | 34 | if mm > DAY: 35 | dd = mm // DAY 36 | mm = mm % DAY 37 | parts.append("%s day%s" % (dd, "s" if dd > 1 else "")) 38 | 39 | if mm > HOUR: 40 | hh = mm // HOUR 41 | mm = mm % HOUR 42 | parts.append("%s hour%s" % (hh, "s" if hh > 1 else "")) 43 | 44 | if mm > 0: 45 | parts.append("%s minute%s" % (mm, "s" if mm > 1 else "")) 46 | 47 | if len(parts) == 3: 48 | human = "%s, %s and %s" % tuple(parts) 49 | elif len(parts) == 2: 50 | human = "%s and %s" % tuple(parts) 51 | else: 52 | human = parts[0] 53 | 54 | choices.append((current, human)) 55 | 56 | old = current 57 | current = int(current / 2) 58 | if old > MIN and current < MIN: 59 | current = MIN 60 | 61 | return choices 62 | 63 | 64 | class EditFeedForm(forms.ModelForm): 65 | required_css_class = "required" 66 | check_frequency = forms.ChoiceField( 67 | widget=forms.Select, 68 | choices=_build_frequency_choices(), 69 | label="Frequency", 70 | help_text="How often to check the feed for changes", 71 | ) 72 | 73 | class Meta: 74 | model = models.Feed 75 | fields = ["text", "feed_url", "is_active", "check_frequency"] 76 | widgets = { 77 | "text": forms.TextInput(), 78 | "feed_url": forms.TextInput(), 79 | "title": forms.TextInput(), 80 | } 81 | -------------------------------------------------------------------------------- /yarr/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radiac/django-yarr/0b087298311a0e7b3901f59ba74e6bd27278a5e9/yarr/management/__init__.py -------------------------------------------------------------------------------- /yarr/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radiac/django-yarr/0b087298311a0e7b3901f59ba74e6bd27278a5e9/yarr/management/commands/__init__.py -------------------------------------------------------------------------------- /yarr/management/commands/check_feeds.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.management.base import BaseCommand 3 | 4 | from yarr import models 5 | from yarr.decorators import with_socket_timeout 6 | 7 | 8 | # Supress feedparser's DeprecationWarning in production environments - we don't 9 | # care about the changes to updated and published, we're already doing it right 10 | if not settings.DEBUG: 11 | import warnings 12 | 13 | warnings.filterwarnings("ignore", category=DeprecationWarning) 14 | 15 | 16 | class Command(BaseCommand): 17 | help = "Check feeds for updates" 18 | 19 | def add_arguments(self, parser): 20 | parser.add_argument( 21 | "--force", 22 | action="store_true", 23 | dest="force", 24 | default=False, 25 | help="Force all feeds to update", 26 | ) 27 | parser.add_argument( 28 | "--read", 29 | action="store_true", 30 | dest="read", 31 | default=False, 32 | help="Any new items will be marked as read; useful when importing", 33 | ) 34 | parser.add_argument( 35 | "--purge", 36 | action="store_true", 37 | dest="purge", 38 | default=False, 39 | help="Purge current entries and reset feed counters", 40 | ) 41 | parser.add_argument( 42 | "--verbose", 43 | action="store_true", 44 | dest="verbose", 45 | default=False, 46 | help="Print information to the console", 47 | ) 48 | parser.add_argument("--url", dest="url", help="Specify the URL to update") 49 | 50 | @with_socket_timeout 51 | def handle(self, *args, **options): 52 | # Apply url filter 53 | entries = models.Entry.objects.all() 54 | feeds = models.Feed.objects.all() 55 | if options["url"]: 56 | feeds = feeds.filter(feed_url=options["url"]) 57 | if feeds.count() == 0: 58 | raise ValueError("Specified URL must be a known feed") 59 | entries = entries.filter(feed__in=feeds) 60 | 61 | # Purge current entries 62 | if options["purge"]: 63 | entries.delete() 64 | feeds.update(last_updated=None, last_checked=None, next_check=None) 65 | 66 | # Check feeds for updates 67 | feeds.check_feed( 68 | force=options["force"], 69 | read=options["read"], 70 | logfile=self.stdout if options["verbose"] else None, 71 | ) 72 | -------------------------------------------------------------------------------- /yarr/management/commands/import_opml.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.contrib.auth.models import User 4 | from django.core.management.base import BaseCommand, CommandError 5 | 6 | from yarr import models 7 | from yarr.utils import import_opml 8 | 9 | 10 | class Command(BaseCommand): 11 | help = "Import subscriptions from an OPML file" 12 | 13 | def add_arguments(self, parser): 14 | parser.add_argument( 15 | "--purge", 16 | action="store_true", 17 | dest="purge", 18 | default=False, 19 | help="Purge current feeds for this user", 20 | ) 21 | 22 | def handle(self, subscription_file, username, *args, **options): 23 | # Get subscriptions 24 | if not os.path.exists(subscription_file): 25 | raise CommandError( 26 | 'Subscription file "%s" does not exist' % subscription_file 27 | ) 28 | 29 | # Look up user 30 | try: 31 | user = User.objects.get(username=username) 32 | except User.DoesNotExist: 33 | raise CommandError('User "%s" does not exist' % username) 34 | 35 | # Purge current entries 36 | if options["purge"]: 37 | print(("Purging feeds for %s..." % user)) 38 | models.Feed.objects.filter(user=user).delete() 39 | 40 | # Parse subscription 41 | print("Importing feeds...") 42 | new_count, old_count = import_opml(subscription_file, user, options["purge"]) 43 | 44 | print( 45 | ( 46 | "Imported %s new feeds and %s already existed for %s" 47 | % (new_count, old_count, user) 48 | ) 49 | ) 50 | -------------------------------------------------------------------------------- /yarr/management/commands/yarr_clean.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | 3 | from yarr import models 4 | from yarr.decorators import with_socket_timeout 5 | 6 | 7 | class Command(BaseCommand): 8 | help = "Yarr cleaning tool" 9 | 10 | def add_arguments(self, parser): 11 | parser.add_argument( 12 | "--delete_read", 13 | action="store_true", 14 | dest="delete_read", 15 | default=False, 16 | help="Delete all read (unsaved) entries", 17 | ) 18 | parser.add_argument( 19 | "--update_cache", 20 | action="store_true", 21 | dest="update_cache", 22 | default=False, 23 | help="Update cache values", 24 | ) 25 | 26 | @with_socket_timeout 27 | def handle(self, *args, **options): 28 | # Delete all read entries - useful for upgrades to 0.3.12 29 | if options["delete_read"]: 30 | feeds = models.Feed.objects.filter(is_active=False) 31 | for feed in feeds: 32 | feed.entries.read().delete() 33 | feed.save() 34 | 35 | # Update feed unread and total counts 36 | if options["update_cache"]: 37 | models.Feed.objects.update_count_unread().update_count_total() 38 | -------------------------------------------------------------------------------- /yarr/managers.py: -------------------------------------------------------------------------------- 1 | """ 2 | Yarr model managers 3 | """ 4 | import datetime 5 | import html 6 | import time 7 | 8 | from django.apps import apps 9 | from django.db import connection, models 10 | from django.utils import timezone 11 | 12 | import bleach 13 | from bleach.css_sanitizer import CSSSanitizer 14 | 15 | from . import settings 16 | from .constants import ENTRY_READ, ENTRY_SAVED, ENTRY_UNREAD 17 | 18 | 19 | ############################################################################### 20 | # Feed model 21 | 22 | 23 | class FeedQuerySet(models.query.QuerySet): 24 | def active(self): 25 | "Filter to active feeds" 26 | return self.filter(is_active=True) 27 | 28 | def check_feed(self, force=False, read=False, logfile=None): 29 | "Check active feeds for updates" 30 | for feed in self.active(): 31 | feed.check_feed(force, read, logfile) 32 | 33 | # Update the total and unread counts 34 | self.update_count_unread() 35 | self.update_count_total() 36 | 37 | return self 38 | 39 | def _do_update(self, extra): 40 | "Perform the update for update_count_total and update_count_unread" 41 | # Get IDs for current queries 42 | ids = self.values_list("id", flat=True) 43 | 44 | # If no IDs, no sense trying to do anything 45 | if not ids: 46 | return self 47 | 48 | # Prepare query options 49 | # IDs and states should only ever be ints, but force them to 50 | # ints to be sure we don't introduce injection vulns 51 | opts = { 52 | "feed": apps.get_model("yarr", "Feed")._meta.db_table, 53 | "entry": apps.get_model("yarr", "Entry")._meta.db_table, 54 | "ids": ",".join([str(int(id)) for id in ids]), 55 | # Fields which should be set in extra 56 | "field": "", 57 | "where": "", 58 | } 59 | opts.update(extra) 60 | 61 | # Uses raw query so we can update in a single call to avoid race condition 62 | cursor = connection.cursor() 63 | cursor.execute( 64 | """UPDATE %(feed)s 65 | SET %(field)s=COALESCE( 66 | ( 67 | SELECT COUNT(1) 68 | FROM %(entry)s 69 | WHERE %(feed)s.id=feed_id%(where)s 70 | GROUP BY feed_id 71 | ), 0 72 | ) 73 | WHERE id IN (%(ids)s) 74 | """ 75 | % opts 76 | ) 77 | 78 | return self 79 | 80 | def update_count_total(self): 81 | "Update the cached total counts" 82 | return self._do_update({"field": "count_total"}) 83 | 84 | def update_count_unread(self): 85 | "Update the cached unread counts" 86 | return self._do_update( 87 | {"field": "count_unread", "where": " AND state=%s" % ENTRY_UNREAD} 88 | ) 89 | 90 | def count_unread(self): 91 | "Get a dict of unread counts, with feed pks as keys" 92 | return dict(self.values_list("pk", "count_unread")) 93 | 94 | 95 | class FeedManager(models.Manager): 96 | def active(self): 97 | "Active feeds" 98 | return self.get_queryset().active() 99 | 100 | def check_feed(self, force=False, read=False, logfile=None): 101 | "Check all active feeds for updates" 102 | return self.get_queryset().check_feed(force, read, logfile) 103 | 104 | def update_count_total(self): 105 | "Update the cached total counts" 106 | return self.get_queryset().update_count_total() 107 | 108 | def update_count_unread(self): 109 | "Update the cached unread counts" 110 | return self.get_queryset().update_count_unread() 111 | 112 | def count_unread(self): 113 | "Get a dict of unread counts, with feed pks as keys" 114 | return self.get_queryset().count_unread() 115 | 116 | def get_queryset(self): 117 | "Return a FeedQuerySet" 118 | return FeedQuerySet(self.model) 119 | 120 | 121 | ############################################################################### 122 | # Entry model 123 | 124 | 125 | class EntryQuerySet(models.query.QuerySet): 126 | def user(self, user): 127 | "Filter by user" 128 | return self.filter(feed__user=user) 129 | 130 | def read(self): 131 | "Filter to read entries" 132 | return self.filter(state=ENTRY_READ) 133 | 134 | def unread(self): 135 | "Filter to unread entries" 136 | return self.filter(state=ENTRY_UNREAD) 137 | 138 | def saved(self): 139 | "Filter to saved entries" 140 | return self.filter(state=ENTRY_SAVED) 141 | 142 | def set_state(self, state, count_unread=False): 143 | """ 144 | Set a new state for these entries 145 | If count_unread=True, returns a dict of the new unread count for the 146 | affected feeds, {feed_pk: unread_count, ...}; if False, returns nothing 147 | """ 148 | # Get list of feed pks before the update changes this queryset 149 | feed_pks = list(self.feeds().values_list("pk", flat=True)) 150 | 151 | # Update the state 152 | self.update(state=state) 153 | 154 | # Look up affected feeds 155 | feeds = apps.get_model("yarr", "Feed").objects.filter(pk__in=feed_pks) 156 | 157 | # Update the unread counts for affected feeds 158 | feeds.update_count_unread() 159 | if count_unread: 160 | return feeds.count_unread() 161 | 162 | def feeds(self): 163 | "Get feeds associated with entries" 164 | return ( 165 | apps.get_model("yarr", "Feed").objects.filter(entries__in=self).distinct() 166 | ) 167 | 168 | def set_expiry(self): 169 | "Ensure selected entries are set to expire" 170 | return self.filter(expires__isnull=True).update( 171 | expires=timezone.now() + datetime.timedelta(days=settings.ITEM_EXPIRY) 172 | ) 173 | 174 | def clear_expiry(self): 175 | "Ensure selected entries will not expire" 176 | return self.exclude(expires__isnull=True).update(expires=None) 177 | 178 | def update_feed_unread(self): 179 | "Update feed read count cache" 180 | return self.feeds().update_count_unread() 181 | 182 | 183 | class EntryManager(models.Manager): 184 | def user(self, user): 185 | "Filter by user" 186 | return self.get_queryset().user(user) 187 | 188 | def read(self): 189 | "Get read entries" 190 | return self.get_queryset().read() 191 | 192 | def unread(self): 193 | "Get unread entries" 194 | return self.get_queryset().unread() 195 | 196 | def saved(self): 197 | "Get saved entries" 198 | return self.get_queryset().saved() 199 | 200 | def set_state(self, state): 201 | "Set a new state for these entries, and update unread count" 202 | return self.get_queryset().set_state(state) 203 | 204 | def update_feed_unread(self): 205 | "Update feed read count cache" 206 | return self.get_queryset().update_feed_unread() 207 | 208 | def from_feedparser(self, raw): 209 | """ 210 | Create an Entry object from a raw feedparser entry 211 | 212 | Arguments: 213 | raw The raw feedparser entry 214 | 215 | Returns: 216 | entry An Entry instance (not saved) 217 | 218 | # ++ TODO: tags 219 | Any tags will be stored on _tags, to be moved to tags field after save 220 | 221 | The content field must be sanitised HTML of the entry's content, or 222 | failing that its sanitised summary or description. 223 | 224 | The date field should use the entry's updated date, then its published 225 | date, then its created date. If none of those are present, it will fall 226 | back to the current datetime when it is first saved. 227 | 228 | The guid is either the guid according to the feed, or the entry link. 229 | 230 | Currently ignoring the following feedparser attributes: 231 | author_detail 232 | contributors 233 | created 234 | enclosures 235 | expired 236 | license 237 | links 238 | publisher 239 | source 240 | summary_detail 241 | title_detail 242 | vcard 243 | xfn 244 | """ 245 | # Create a new entry 246 | entry = self.model() 247 | 248 | # Get the title and sanitise completely 249 | title = raw.get("title", "") 250 | title = bleach.clean(title, tags=[], strip=True) 251 | title = html.unescape(title) 252 | entry.title = title 253 | 254 | # Get the content and sanitise according to settings 255 | content = raw.get("content", [{"value": ""}])[0]["value"] 256 | if not content: 257 | content = raw.get("description", "") 258 | css_sanitizer = CSSSanitizer(settings.ALLOWED_STYLES) 259 | content = bleach.clean( 260 | content, 261 | tags=settings.ALLOWED_TAGS, 262 | attributes=settings.ALLOWED_ATTRIBUTES, 263 | css_sanitizer=css_sanitizer, 264 | strip=True, 265 | ) 266 | entry.content = content 267 | 268 | # Order: updated, published, created 269 | # If not provided, needs to be None for update comparison 270 | # Will default to current time when saved 271 | date = raw.get( 272 | "updated_parsed", 273 | raw.get("published_parsed", raw.get("created_parsed", None)), 274 | ) 275 | if date is not None: 276 | entry.date = timezone.make_aware( 277 | datetime.datetime.fromtimestamp(time.mktime(date)) 278 | ) 279 | 280 | entry.url = raw.get("link", "") 281 | entry.guid = raw.get("guid", entry.url) 282 | 283 | entry.author = raw.get("author", "") 284 | entry.comments_url = raw.get("comments", "") 285 | 286 | # ++ TODO: tags 287 | """ 288 | tags = raw.get('tags', None) 289 | if tags is not None: 290 | entry._tags = tags 291 | """ 292 | 293 | return entry 294 | 295 | def get_queryset(self): 296 | """ 297 | Return an EntryQuerySet 298 | """ 299 | return EntryQuerySet(self.model) 300 | -------------------------------------------------------------------------------- /yarr/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.16 on 2018-11-20 06:56 3 | from __future__ import unicode_literals 4 | 5 | import django.core.validators 6 | import django.db.models.deletion 7 | from django.conf import settings 8 | from django.db import migrations, models 9 | 10 | 11 | class Migration(migrations.Migration): 12 | 13 | initial = True 14 | 15 | dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name="Entry", 20 | fields=[ 21 | ( 22 | "id", 23 | models.AutoField( 24 | auto_created=True, 25 | primary_key=True, 26 | serialize=False, 27 | verbose_name="ID", 28 | ), 29 | ), 30 | ( 31 | "state", 32 | models.IntegerField( 33 | choices=[(0, "Unread"), (1, "Read"), (2, "Saved")], default=0 34 | ), 35 | ), 36 | ( 37 | "expires", 38 | models.DateTimeField( 39 | blank=True, help_text="When the entry should expire", null=True 40 | ), 41 | ), 42 | ("title", models.TextField(blank=True)), 43 | ("content", models.TextField(blank=True)), 44 | ( 45 | "date", 46 | models.DateTimeField( 47 | help_text="When this entry says it was published" 48 | ), 49 | ), 50 | ("author", models.TextField(blank=True)), 51 | ( 52 | "url", 53 | models.TextField( 54 | blank=True, 55 | help_text="URL for the HTML for this entry", 56 | validators=[django.core.validators.URLValidator()], 57 | ), 58 | ), 59 | ( 60 | "comments_url", 61 | models.TextField( 62 | blank=True, 63 | help_text="URL for HTML comment submission page", 64 | validators=[django.core.validators.URLValidator()], 65 | ), 66 | ), 67 | ( 68 | "guid", 69 | models.TextField( 70 | blank=True, 71 | help_text="GUID for the entry, according to the feed", 72 | ), 73 | ), 74 | ], 75 | options={"verbose_name_plural": "entries", "ordering": ("-date",)}, 76 | ), 77 | migrations.CreateModel( 78 | name="Feed", 79 | fields=[ 80 | ( 81 | "id", 82 | models.AutoField( 83 | auto_created=True, 84 | primary_key=True, 85 | serialize=False, 86 | verbose_name="ID", 87 | ), 88 | ), 89 | ("title", models.TextField(help_text="Published title of the feed")), 90 | ( 91 | "feed_url", 92 | models.TextField( 93 | help_text="URL of the RSS feed", 94 | validators=[django.core.validators.URLValidator()], 95 | verbose_name="Feed URL", 96 | ), 97 | ), 98 | ( 99 | "text", 100 | models.TextField( 101 | blank=True, 102 | help_text="Custom title for the feed - defaults to feed title above", 103 | verbose_name="Custom title", 104 | ), 105 | ), 106 | ( 107 | "site_url", 108 | models.TextField( 109 | help_text="URL of the HTML site", 110 | validators=[django.core.validators.URLValidator()], 111 | verbose_name="Site URL", 112 | ), 113 | ), 114 | ( 115 | "added", 116 | models.DateTimeField( 117 | auto_now_add=True, help_text="Date this feed was added" 118 | ), 119 | ), 120 | ( 121 | "is_active", 122 | models.BooleanField( 123 | default=True, 124 | help_text="A feed will become inactive when a permanent error occurs", 125 | ), 126 | ), 127 | ( 128 | "check_frequency", 129 | models.IntegerField( 130 | blank=True, 131 | help_text="How often to check the feed for changes, in minutes", 132 | null=True, 133 | ), 134 | ), 135 | ( 136 | "last_updated", 137 | models.DateTimeField( 138 | blank=True, 139 | help_text="Last time the feed says it changed", 140 | null=True, 141 | ), 142 | ), 143 | ( 144 | "last_checked", 145 | models.DateTimeField( 146 | blank=True, 147 | help_text="Last time the feed was checked", 148 | null=True, 149 | ), 150 | ), 151 | ( 152 | "next_check", 153 | models.DateTimeField( 154 | blank=True, 155 | help_text="When the next feed check is due", 156 | null=True, 157 | ), 158 | ), 159 | ( 160 | "error", 161 | models.CharField( 162 | blank=True, help_text="When a problem occurs", max_length=255 163 | ), 164 | ), 165 | ( 166 | "count_unread", 167 | models.IntegerField( 168 | default=0, help_text="Cache of number of unread items" 169 | ), 170 | ), 171 | ( 172 | "count_total", 173 | models.IntegerField( 174 | default=0, help_text="Cache of total number of items" 175 | ), 176 | ), 177 | ( 178 | "user", 179 | models.ForeignKey( 180 | on_delete=django.db.models.deletion.CASCADE, 181 | to=settings.AUTH_USER_MODEL, 182 | ), 183 | ), 184 | ], 185 | options={"ordering": ("title", "added")}, 186 | ), 187 | migrations.AddField( 188 | model_name="entry", 189 | name="feed", 190 | field=models.ForeignKey( 191 | on_delete=django.db.models.deletion.CASCADE, 192 | related_name="entries", 193 | to="yarr.Feed", 194 | ), 195 | ), 196 | ] 197 | -------------------------------------------------------------------------------- /yarr/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radiac/django-yarr/0b087298311a0e7b3901f59ba74e6bd27278a5e9/yarr/migrations/__init__.py -------------------------------------------------------------------------------- /yarr/models.py: -------------------------------------------------------------------------------- 1 | """ 2 | Yarr models 3 | """ 4 | 5 | import datetime 6 | import time 7 | from urllib.error import URLError 8 | 9 | from django.conf import settings as django_settings 10 | from django.core.validators import URLValidator 11 | from django.db import models 12 | from django.utils import timezone 13 | 14 | import feedparser 15 | 16 | from yarr import managers, settings 17 | from yarr.constants import ENTRY_READ, ENTRY_SAVED, ENTRY_UNREAD 18 | 19 | 20 | # ++ TODO: tags 21 | 22 | 23 | ############################################################################### 24 | # Setup 25 | 26 | # Disable feedparser's sanitizer - FeedManager will be using bleach instead 27 | feedparser.SANITIZE_HTML = 0 28 | 29 | 30 | class NullFile(object): 31 | """Fake file object for disabling logging in Feed.check""" 32 | 33 | def write(self, str): 34 | pass 35 | 36 | 37 | nullfile = NullFile() 38 | 39 | 40 | ############################################################################### 41 | # Exceptions 42 | 43 | 44 | class FeedError(Exception): 45 | """ 46 | An error occurred when fetching the feed 47 | 48 | If it was parsed despite the error, the feed and entries will be available: 49 | e.feed None if not parsed 50 | e.entries Empty list if not parsed 51 | """ 52 | 53 | def __init__(self, *args, **kwargs): 54 | self.feed = kwargs.pop("feed", None) 55 | self.entries = kwargs.pop("entries", []) 56 | super(FeedError, self).__init__(*args, **kwargs) 57 | 58 | 59 | class InactiveFeedError(FeedError): 60 | pass 61 | 62 | 63 | class EntryError(Exception): 64 | """ 65 | An error occurred when processing an entry 66 | """ 67 | 68 | pass 69 | 70 | 71 | ############################################################################### 72 | # Feed model 73 | 74 | 75 | class Feed(models.Model): 76 | """ 77 | A feed definition 78 | 79 | The last_updated field is either the updated or published date of the feed, 80 | or if neither are set, the feed parser's best guess. 81 | 82 | Currently ignoring the following feedparser attributes: 83 | author 84 | author_detail 85 | cloud 86 | contributors 87 | docs 88 | errorreportsto 89 | generator 90 | generator_detail 91 | icon 92 | id 93 | image 94 | info 95 | info_detail 96 | language 97 | license 98 | links 99 | logo 100 | publisher 101 | rights 102 | subtitle 103 | tags 104 | textinput 105 | title 106 | ttl 107 | """ 108 | 109 | # Compulsory data fields 110 | title = models.TextField(help_text="Published title of the feed") 111 | feed_url = models.TextField( 112 | "Feed URL", validators=[URLValidator()], help_text="URL of the RSS feed" 113 | ) 114 | text = models.TextField( 115 | "Custom title", 116 | blank=True, 117 | help_text="Custom title for the feed - defaults to feed title above", 118 | ) 119 | 120 | # Optional data fields 121 | site_url = models.TextField( 122 | "Site URL", validators=[URLValidator()], help_text="URL of the HTML site" 123 | ) 124 | 125 | # Internal fields 126 | user = models.ForeignKey(django_settings.AUTH_USER_MODEL, on_delete=models.CASCADE) 127 | added = models.DateTimeField( 128 | auto_now_add=True, help_text="Date this feed was added" 129 | ) 130 | is_active = models.BooleanField( 131 | default=True, 132 | help_text="A feed will become inactive when a permanent error occurs", 133 | ) 134 | check_frequency = models.IntegerField( 135 | blank=True, 136 | null=True, 137 | help_text="How often to check the feed for changes, in minutes", 138 | ) 139 | last_updated = models.DateTimeField( 140 | blank=True, null=True, help_text="Last time the feed says it changed" 141 | ) 142 | last_checked = models.DateTimeField( 143 | blank=True, null=True, help_text="Last time the feed was checked" 144 | ) 145 | next_check = models.DateTimeField( 146 | blank=True, null=True, help_text="When the next feed check is due" 147 | ) 148 | error = models.CharField( 149 | blank=True, max_length=255, help_text="When a problem occurs" 150 | ) 151 | 152 | # Cached data 153 | count_unread = models.IntegerField( 154 | default=0, help_text="Cache of number of unread items" 155 | ) 156 | count_total = models.IntegerField( 157 | default=0, help_text="Cache of total number of items" 158 | ) 159 | 160 | objects = managers.FeedManager() 161 | 162 | def __str__(self): 163 | return str(self.text or self.title) 164 | 165 | def update_count_unread(self): 166 | """Update the cached unread count""" 167 | self.count_unread = self.entries.unread().count() 168 | 169 | def update_count_total(self): 170 | """Update the cached total item count""" 171 | self.count_total = self.entries.count() 172 | 173 | def _fetch_feed(self, url_history=None): 174 | """ 175 | Internal method to get the feed from the specified URL 176 | Follows good practice 177 | Returns: 178 | feed Feed data, or None if there was a temporary error 179 | entries List of entries 180 | Raises: 181 | FetchError Feed fetch suffered permanent failure 182 | """ 183 | # Request and parse the feed 184 | try: 185 | d = feedparser.parse(self.feed_url) 186 | except URLError as e: 187 | raise FeedError(f"URL error: {e.reason}") 188 | except Exception as e: 189 | # Unrecognised exception 190 | raise FeedError(f"Feed error: {e.__class__.__name__} - {e}") 191 | 192 | status = d.get("status", 200) 193 | feed = d.get("feed", None) 194 | entries = d.get("entries", []) 195 | 196 | # Handle feedparser exceptions (bozo): 197 | # 198 | # Raise a FeedError, but the feed may have been parsed anyway, so feed and 199 | # entries will be available on the exception. 200 | # 201 | # Most of these will be SAXParseException, which doesn't convert to a string 202 | # cleanly, so explicitly mention the exception class 203 | if d.get("bozo") == 1: 204 | bozo = d["bozo_exception"] 205 | raise FeedError( 206 | "Feed error: %s - %s" % (bozo.__class__.__name__, bozo), 207 | feed=feed, 208 | entries=entries, 209 | ) 210 | 211 | # Accepted status: 212 | # 200 OK 213 | # 302 Temporary redirect 214 | # 304 Not Modified 215 | # 307 Temporary redirect 216 | if status in (200, 302, 304, 307): 217 | # Check for valid feed 218 | if feed is None or "title" not in feed or "link" not in feed: 219 | raise FeedError("Feed parsed but with invalid contents") 220 | 221 | # OK 222 | return feed, entries 223 | 224 | # Temporary errors: 225 | # 404 Not Found 226 | # 500 Internal Server Error 227 | # 502 Bad Gateway 228 | # 503 Service Unavailable 229 | # 504 Gateway Timeout 230 | if status in (404, 500, 502, 503, 504): 231 | raise FeedError("Temporary error %s" % status) 232 | 233 | # Follow permanent redirection 234 | if status == 301: 235 | # Log url 236 | if url_history is None: 237 | url_history = [] 238 | url_history.append(self.feed_url) 239 | 240 | # Avoid circular redirection 241 | self.feed_url = d.get("href", self.feed_url) 242 | if self.feed_url in url_history: 243 | raise InactiveFeedError("Circular redirection found") 244 | 245 | # Update feed and try again 246 | self.save() 247 | return self._fetch_feed(url_history) 248 | 249 | # Feed gone 250 | if status == 410: 251 | raise InactiveFeedError("Feed has gone") 252 | 253 | # Unknown status 254 | raise FeedError("Unrecognised HTTP status %s" % status) 255 | 256 | def check_feed(self, force=False, read=False, logfile=None): 257 | """ 258 | Check the feed for updates 259 | 260 | Optional arguments: 261 | force Force an update 262 | read Mark new entries as read 263 | logfile Logfile to print report data 264 | 265 | It will update if: 266 | * ``force==True`` 267 | * it has never been updated 268 | * it was due for an update in the past 269 | * it is due for an update in the next ``MINIMUM_INTERVAL`` minutes 270 | 271 | Note: because feedparser refuses to support timeouts, this method could 272 | block on an unresponsive connection. 273 | 274 | The official feedparser solution is to set the global socket timeout, 275 | but that is not thread safe, so has not been done here in case it 276 | affects the use of sockets in other installed django applications. 277 | 278 | New code which calls this method directly must use the decorator 279 | ``yarr.decorators.with_socket_timeout`` to avoid blocking requests. 280 | 281 | For this reason, and the fact that it could take a relatively long time 282 | to parse a feed, this method should never be called as a direct result 283 | of a web request. 284 | 285 | Note: after this is called, feed unread and total count caches will be 286 | incorrect, and must be recalculated with the appropriate management 287 | commands. 288 | """ 289 | # Call _do_check and save if anything has changed 290 | changed = self._do_check(force, read, logfile) 291 | if changed: 292 | self.save() 293 | 294 | # Remove expired entries 295 | self.entries.filter(expires__lte=timezone.now()).delete() 296 | 297 | def _do_check(self, force, read, logfile): 298 | """ 299 | Perform the actual check from ``check`` 300 | 301 | Takes the same arguments as ``check``, but returns True if something 302 | in the Feed object has changed, and False if it has not. 303 | """ 304 | # Ensure logfile is valid 305 | if logfile is None: 306 | logfile = nullfile 307 | 308 | # Report 309 | logfile.write("[%s] %s" % (self.pk, self.feed_url)) 310 | 311 | # Check it's due for a check before the next poll 312 | now = timezone.now() 313 | next_poll = now + datetime.timedelta(minutes=settings.MINIMUM_INTERVAL) 314 | if not force and self.next_check is not None and self.next_check >= next_poll: 315 | logfile.write("Not due yet") 316 | # Return False, because nothing has changed yet 317 | return False 318 | 319 | # We're about to check, update the counters 320 | self.last_checked = now 321 | self.next_check = now + datetime.timedelta( 322 | minutes=self.check_frequency or settings.FREQUENCY 323 | ) 324 | # Note: from now on always return True, because something has changed 325 | 326 | # Fetch feed 327 | logfile.write("Fetching...") 328 | try: 329 | feed, entries = self._fetch_feed() 330 | except FeedError as e: 331 | logfile.write("Error: %s" % e) 332 | 333 | # Update model to reflect the error 334 | if isinstance(e, InactiveFeedError): 335 | logfile.write("Deactivating feed") 336 | self.is_active = False 337 | self.error = str(e) 338 | 339 | # Check for a valid feed despite error 340 | if e.feed is None or len(e.entries) == 0: 341 | logfile.write("No valid feed") 342 | return True 343 | logfile.write("Valid feed found") 344 | feed = e.feed 345 | entries = e.entries 346 | 347 | else: 348 | # Success 349 | logfile.write("Feed fetched") 350 | 351 | # Clear error if necessary 352 | if self.error != "": 353 | self.error = "" 354 | 355 | # Try to find the updated time 356 | updated = feed.get("updated_parsed", feed.get("published_parsed", None)) 357 | if updated: 358 | updated = timezone.make_aware( 359 | datetime.datetime.fromtimestamp(time.mktime(updated)) 360 | ) 361 | 362 | # Stop if we now know it hasn't updated recently 363 | if not force and updated and self.last_updated and updated <= self.last_updated: 364 | logfile.write("Has not updated") 365 | return True 366 | 367 | # Add or update any entries, and get latest timestamp 368 | try: 369 | latest = self._update_entries(entries, read) 370 | except EntryError as e: 371 | if self.error: 372 | self.error += ". " 373 | self.error += "Entry error: %s" % e 374 | return True 375 | 376 | # Update last_updated 377 | if not updated: 378 | # If no feed pub date found, use latest entry 379 | updated = latest 380 | self.last_updated = updated 381 | 382 | # Update feed fields 383 | title = feed.get("title", None) 384 | site_url = feed.get("link", None) 385 | if title: 386 | self.title = title 387 | if site_url: 388 | self.site_url = site_url 389 | 390 | logfile.write("Feed updated") 391 | 392 | return True 393 | 394 | def _update_entries(self, entries, read): 395 | """ 396 | Add or update feedparser entries, and return latest timestamp 397 | """ 398 | latest = None 399 | found = [] 400 | for raw_entry in entries: 401 | # Create Entry and set feed 402 | entry = Entry.objects.from_feedparser(raw_entry) 403 | 404 | entry.feed = self 405 | entry.state = ENTRY_READ if read else ENTRY_UNREAD 406 | 407 | # Try to match by guid, then link, then title and date 408 | if entry.guid: 409 | query = {"guid": entry.guid} 410 | elif entry.url: 411 | query = {"url": entry.url} 412 | elif entry.title and entry.date: 413 | # If title and date provided, this will match 414 | query = {"title": entry.title, "date": entry.date} 415 | else: 416 | # No guid, no link, no title and date - no way to match 417 | # Can never de-dupe this entry, so to avoid the risk of adding 418 | # it more than once, declare this feed invalid 419 | raise EntryError("No guid, link, and title or date; cannot import") 420 | 421 | # Update existing, or delete old 422 | try: 423 | existing = self.entries.get(**query) 424 | except self.entries.model.DoesNotExist: 425 | # New entry, save 426 | entry.save() 427 | else: 428 | # Existing entry 429 | if entry.date is not None and entry.date > existing.date: 430 | # Changes - update entry 431 | existing.update(entry) 432 | 433 | # Note that we found this 434 | found.append(entry.pk) 435 | 436 | # Update latest tracker 437 | if latest is None or (entry.date is not None and entry.date > latest): 438 | latest = entry.date 439 | 440 | # Mark entries for expiry if: 441 | # ITEM_EXPIRY is set to expire entries 442 | # they weren't found in the feed 443 | # they have been read (excludes those saved) 444 | if settings.ITEM_EXPIRY >= 0: 445 | self.entries.exclude(pk__in=found).read().set_expiry() 446 | 447 | return latest 448 | 449 | class Meta: 450 | ordering = ("title", "added") 451 | 452 | 453 | ############################################################################### 454 | # Entry model 455 | 456 | 457 | class Entry(models.Model): 458 | """ 459 | A cached entry 460 | 461 | If creating from a feedparser entry, use Entry.objects.from_feedparser() 462 | 463 | # ++ TODO: tags 464 | To add tags for an entry before saving, add them to _tags, and they will be 465 | set by save(). 466 | """ 467 | 468 | # Internal fields 469 | feed = models.ForeignKey(Feed, related_name="entries", on_delete=models.CASCADE) 470 | state = models.IntegerField( 471 | default=ENTRY_UNREAD, 472 | choices=( 473 | (ENTRY_UNREAD, "Unread"), 474 | (ENTRY_READ, "Read"), 475 | (ENTRY_SAVED, "Saved"), 476 | ), 477 | ) 478 | expires = models.DateTimeField( 479 | blank=True, null=True, help_text="When the entry should expire" 480 | ) 481 | 482 | # Compulsory data fields 483 | title = models.TextField(blank=True) 484 | content = models.TextField(blank=True) 485 | date = models.DateTimeField(help_text="When this entry says it was published") 486 | 487 | # Optional data fields 488 | author = models.TextField(blank=True) 489 | url = models.TextField( 490 | blank=True, 491 | validators=[URLValidator()], 492 | help_text="URL for the HTML for this entry", 493 | ) 494 | 495 | comments_url = models.TextField( 496 | blank=True, 497 | validators=[URLValidator()], 498 | help_text="URL for HTML comment submission page", 499 | ) 500 | guid = models.TextField( 501 | blank=True, help_text="GUID for the entry, according to the feed" 502 | ) 503 | 504 | # ++ TODO: tags 505 | 506 | objects = managers.EntryManager() 507 | 508 | def __str__(self): 509 | return str(self.title) 510 | 511 | def update(self, entry): 512 | """ 513 | An old entry has been re-published; update with new data 514 | """ 515 | fields = ["title", "content", "date", "author", "url", "comments_url", "guid"] 516 | for field in fields: 517 | setattr(self, field, getattr(entry, field)) 518 | # ++ Should we mark as unread? Leaving it as is for now. 519 | self.save() 520 | 521 | def save(self, *args, **kwargs): 522 | # Default the date 523 | if self.date is None: 524 | self.date = timezone.now() 525 | 526 | # Save 527 | super(Entry, self).save(*args, **kwargs) 528 | 529 | # ++ TODO: tags 530 | """ 531 | # Add any tags 532 | if hasattr(self, '_tags'): 533 | self.tags = self._tags 534 | delattr(self, '_tags') 535 | """ 536 | 537 | class Meta: 538 | ordering = ("-date",) 539 | verbose_name_plural = "entries" 540 | -------------------------------------------------------------------------------- /yarr/settings.py: -------------------------------------------------------------------------------- 1 | from bleach.css_sanitizer import ALLOWED_CSS_PROPERTIES 2 | from yaa_settings import AppSettings 3 | 4 | 5 | class Settings(AppSettings): 6 | prefix = "YARR" 7 | 8 | # 9 | # To manage the web interface 10 | # 11 | 12 | # Use webpack dev server instead of static files 13 | DEV_MODE = False 14 | 15 | # Page to open at Yarr root url (resolved using reverse) 16 | INDEX_URL = "yarr:list_unread" 17 | 18 | # Pagination limits 19 | PAGE_LENGTH = 25 20 | API_PAGE_LENGTH = 5 21 | 22 | # If true, fix the layout elements at the top of the screen when scrolling down 23 | # Disable if using a custom layout 24 | LAYOUT_FIXED = True 25 | 26 | # Template string for document title (shown on the browser window and tabs). 27 | # If set, used to update the title when changing feeds in list view. 28 | # Use ``%(feed)s`` as a placeholder for the feed title (case sensitive) 29 | TITLE_TEMPLATE = "%(feed)s" 30 | 31 | # jQuery Selector for page title (an element in your page template) 32 | # If set, this element's content will be replaced with the feed title when 33 | # changing feeds in list view. 34 | TITLE_SELECTOR = "" 35 | 36 | # 37 | # To control feed updates 38 | # 39 | 40 | # Socket timeout, in seconds 41 | # Highly recommended that this is **not** set to ``None``, which would block 42 | # Note: this sets the global socket timeout, which is not thread-safe; it is 43 | # therefore set explicitly when checking feeds, and reset after feeds have been 44 | # updated (see ``yarr.decorators.with_socket_timeout`` for more details). 45 | SOCKET_TIMEOUT = 15 46 | 47 | # Minimum and maximum interval for checking a feed, in minutes 48 | # The minimum interval must match the interval that the cron job runs at, 49 | # otherwise some feeds may not get checked on time 50 | MINIMUM_INTERVAL = 60 51 | MAXIMUM_INTERVAL = 24 * 60 52 | 53 | # Default frequency to check a feed, in minutes 54 | # Defaults to just under 24 hours (23:45) to avoid issues with slow responses 55 | # Note: this will be removed in a future version 56 | FREQUENCY = 24 * 60 57 | 58 | # Number of days to keep a read item which is no longer in the feed 59 | # Set this to 0 to expire immediately, -1 to never expire 60 | ITEM_EXPIRY = 1 61 | 62 | # 63 | # Bleach settings for Yarr 64 | # 65 | 66 | # HTML whitelist for bleach 67 | # This default list is roughly the same as the WHATWG sanitization rules 68 | # , but without form elements. 69 | # A few common HTML 5 elements have been added as well. 70 | ALLOWED_TAGS = [ 71 | "a", 72 | "abbr", 73 | "acronym", 74 | "aside", 75 | "b", 76 | "bdi", 77 | "bdo", 78 | "blockquote", 79 | "br", 80 | "code", 81 | "data", 82 | "dd", 83 | "del", 84 | "dfn", 85 | "div", # Why not? 86 | "dl", 87 | "dt", 88 | "em", 89 | "h1", 90 | "h2", 91 | "h3", 92 | "h4", 93 | "h5", 94 | "h6", 95 | "hr", 96 | "i", 97 | "img", 98 | "ins", 99 | "kbd", 100 | "li", 101 | "ol", 102 | "p", 103 | "pre", 104 | "q", 105 | "s", 106 | "samp", 107 | "small", 108 | "span", 109 | "strike", 110 | "strong", 111 | "sub", 112 | "sup", 113 | "table", 114 | "tbody", 115 | "td", 116 | "tfoot", 117 | "th", 118 | "thead", 119 | "tr", 120 | "time", 121 | "tt", # Obsolete, but docutils likes to generate these. 122 | "u", 123 | "var", 124 | "wbr", 125 | "ul", 126 | ] 127 | 128 | ALLOWED_ATTRIBUTES = { 129 | "*": ["lang", "dir"], # lang is necessary for hyphentation. 130 | "a": ["href", "title"], 131 | "abbr": ["title"], 132 | "acronym": ["title"], 133 | "data": ["value"], 134 | "dfn": ["title"], 135 | "img": ["src", "alt", "width", "height", "title"], 136 | "li": ["value"], 137 | "ol": ["reversed", "start", "type"], 138 | "td": ["align", "valign", "width", "colspan", "rowspan"], 139 | "th": ["align", "valign", "width", "colspan", "rowspan"], 140 | "time": ["datetime"], 141 | } 142 | 143 | ALLOWED_STYLES = ALLOWED_CSS_PROPERTIES 144 | -------------------------------------------------------------------------------- /yarr/static/yarr/index.css: -------------------------------------------------------------------------------- 1 | .yarr{display:flex;flex-flow:column nowrap;height:100%}.yarr>.body{flex:1 1 auto;order:2;position:relative}.yarr>.body .status{position:absolute;top:0;left:50%;width:16rem;margin-left:-8rem;z-index:2}.yarr>.body.with-sidebar{display:flex;flex-flow:row nowrap;height:0}.yarr>.body.with-sidebar .sidebar{flex:0 0 18em;order:1;z-index:1}.yarr>.body.with-sidebar .content{flex:1 1 auto;order:2;overflow-y:auto;z-index:1}.yarr>.body .content{padding:1rem 0;margin:0}.yarr>.body>.content{padding:1rem;order:2}.yarr table{width:100%}.yarr table td,.yarr table th{text-align:left;vertical-align:top}.yarr table th{font-weight:700}.yarr .sidebar{display:flex;flex-flow:row nowrap}.yarr .sidebar-body{flex:1 1 auto;margin:1rem 0 1rem 1rem;overflow-y:auto;border:1px solid #ccc}.yarr .sidebar-body ul.feed_menu{border-bottom:1px solid #ccc}.yarr .sidebar-body ul{list-style:none;margin:0;padding:1rem}.yarr .sidebar-body .count_unread{vertical-align:bottom;display:inline-block;font-size:.8em;font-weight:400;color:#666;margin-left:.4rem}.yarr .sidebar-body .count_unread:before{content:"("}.yarr .sidebar-body .count_unread:after{content:")"}.yarr__conf-sidebar_override .sidebar{display:none}.yarr .feed-list .selected .name{font-weight:700}.yarr .feed-list .unread{margin-left:.5rem;font-size:.9rem;color:#666}.yarr .entry{margin:1rem 0;border:1px solid #ccc;border-radius:0}.yarr .entry:first-child{margin-top:0}.yarr .entry:last-child{margin-bottom:0}.yarr .entry .content,.yarr .entry .header{padding:1rem}.yarr .entry .header h2{margin:0;padding:0 0 .3rem;font-size:1.8rem;line-height:1.8rem}.yarr .entry .header a{text-decoration:none}.yarr .entry .header a:hover{text-decoration:underline}@media (min-width:768px){.yarr .entry .header .meta{display:flex;flex-direction:row;width:100%}.yarr .entry .header .meta p.date{order:2;flex:0 0 10rem;text-align:right}.yarr .entry .header .meta p.feed{order:1;flex:1 0 auto;width:0}}.yarr .entry .control{border-top:1px solid #ccc;background:#f8f8f8;padding:.5rem 1rem}.yarr .entry .control ul{display:flex;flex-direction:row;flex-wrap:wrap;list-style:none;margin:0;padding:0}.yarr .entry .control ul li{flex:0 0 auto}.yarr .entry .control ul li a,.yarr .entry .control ul li span{margin-right:1rem;text-decoration:none;font-size:.9rem;color:#666}.yarr .entry .control ul li span{font-weight:700}.yarr .entry .control ul li a:hover{text-decoration:underline}.yarr .entry{border-left:3px solid #46a}.yarr .entry .header{border-bottom:1px solid #ced8ec}.yarr .entry .header .meta a,.yarr .entry .header h2 a{color:#46a}.yarr .entry.active{border-left-color:#a54}.yarr .entry.active .header{background:#f3f5fa}.yarr .entry.read{border-left-color:#ccc}.yarr .entry.read .header{border-bottom-color:#fff}.yarr .entry.read .header .meta a,.yarr .entry.read .header h2 a{color:#ccc}.yarr .entry.read.active{border-left-color:#a54}.yarr .entry.read.active .header{background:#fff}.yarr .entry.read.active .header .meta a,.yarr .entry.read.active .header h2 a{color:#666}.yarr .entry.saved{border-left-color:#6a4}.yarr .entry.saved .header{border-bottom-color:#d8ecce}.yarr .entry.saved .header .meta a,.yarr .entry.saved .header h2 a{color:#6a4}.yarr .entry.saved.active{border-left-color:#a54}.yarr .entry.saved.active .header{background:#f5faf3}.yarr .entry .content h1,.yarr .entry .content h2,.yarr .entry .content h3,.yarr .entry .content h4,.yarr .entry .content h5,.yarr .entry .content h6{font-weight:700;margin-top:1.5rem}.yarr .entry .content h1:first-child,.yarr .entry .content h2:first-child,.yarr .entry .content h3:first-child,.yarr .entry .content h4:first-child,.yarr .entry .content h5:first-child,.yarr .entry .content h6:first-child{margin-top:0}.yarr .entry .content h1{font-size:1.71rem}.yarr .entry .content h2{font-size:1.62rem}.yarr .entry .content h3{font-size:1.53rem}.yarr .entry .content p{margin:1rem 0}.yarr .entry .content p:first-child{margin-top:0}.yarr .entry .content p:last-child{margin-bottom:0}.yarr .entry .content img{max-width:100%}.yarr .entry .content ul{list-style:initial;margin:1rem 2rem}.yarr .entry .content ul li{display:list-item}.yarr .entry .content a{color:#46a}.yarr .entry .content a:hover{color:#738fc7}.yarr__conf-layout_list input[name=layout_list]{display:none}.yarr__conf-layout_list .entry{margin:0}.yarr__conf-layout_list .entry:not(:first-child){border-top:0}.yarr__conf-layout_list .entry input[name=layout_list]~.article{display:none}.yarr__conf-layout_list .entry input[name=layout_list]:checked~.article{position:relative;display:block;border-top:1px solid #ccc}.yarr__conf-layout_list .entry input[name=layout_list]:checked~.article .summary--close{position:absolute;top:-1.4rem;height:1.4rem;width:100%}.yarr__conf-layout_list .entry .summary{height:1.4rem;line-height:1.4rem;display:flex;padding:0 .5rem}.yarr__conf-layout_list .entry .summary .feed{flex:0 0 12rem}.yarr__conf-layout_list .entry .summary .title{flex:1 1 auto;padding:0 .5rem}.yarr__conf-layout_list .entry .summary .feed,.yarr__conf-layout_list .entry .summary .title{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.yarr__conf-layout_list .entry .summary .date{flex:0 0 10rem;text-align:right}@media (max-width:767px){.yarr__conf-layout_list .entry .summary .date{display:none}.yarr__conf-layout_list .entry .summary .feed{flex-basis:8rem}}.yarr__conf-layout_list .entry:not(.read) .title{font-weight:700}.yarr>.control{flex:0 0 auto;order:1;display:flex}.yarr>.control .feednav,.yarr>.control .menu{flex:1 1 auto;margin:.5rem 0}@media (max-width:767px){.yarr>.control .feednav,.yarr>.control .menu{flex:1 0 auto}}.yarr>.control .feednav{text-align:right}.yarr>.control ul{list-style:none;margin:0;padding:0}.yarr>.control{padding:0 1rem;border-bottom:1px solid #ccc}.yarr>.control a,.yarr>.control span{display:block;box-sizing:content-box;height:1.5rem;line-height:1.5rem;font-size:.8em;font-weight:700;text-decoration:none}.yarr>.control span{padding:.5rem}.yarr>.control a{padding:.5rem 1rem .5rem 2.5rem}.yarr>.control a:empty{padding-left:1.5rem}.yarr>.control .menu_ctl,.yarr>.control .menu_layout,.yarr>.control .menu_manage,.yarr>.control .menu_op,.yarr>.control .menu_sort,.yarr>.control .menu_state,.yarr>.control .stepper{display:inline-block;vertical-align:top;position:relative}.yarr>.control .menu_ctl a:before,.yarr>.control .menu_ctl span:before,.yarr>.control .menu_layout a:before,.yarr>.control .menu_layout span:before,.yarr>.control .menu_manage a:before,.yarr>.control .menu_manage span:before,.yarr>.control .menu_op a:before,.yarr>.control .menu_op span:before,.yarr>.control .menu_sort a:before,.yarr>.control .menu_sort span:before,.yarr>.control .menu_state a:before,.yarr>.control .menu_state span:before,.yarr>.control .stepper a:before,.yarr>.control .stepper span:before{content:" ";position:absolute;width:1.5rem;height:1.5rem;background-color:#666;mask-size:cover}.yarr>.control .menu_ctl span,.yarr>.control .menu_layout span,.yarr>.control .menu_manage span,.yarr>.control .menu_op span,.yarr>.control .menu_sort span,.yarr>.control .menu_state span,.yarr>.control .stepper span{width:1.5rem}.yarr>.control .menu_ctl a:before,.yarr>.control .menu_ctl span:before,.yarr>.control .menu_layout a:before,.yarr>.control .menu_layout span:before,.yarr>.control .menu_manage a:before,.yarr>.control .menu_manage span:before,.yarr>.control .menu_op a:before,.yarr>.control .menu_op span:before,.yarr>.control .menu_sort a:before,.yarr>.control .menu_sort span:before,.yarr>.control .menu_state a:before,.yarr>.control .menu_state span:before,.yarr>.control .stepper a:before,.yarr>.control .stepper span:before{top:.5rem;left:.5rem}.yarr>.control .menu_ctl a,.yarr>.control .menu_ctl span,.yarr>.control .menu_layout a,.yarr>.control .menu_layout span,.yarr>.control .menu_manage a,.yarr>.control .menu_manage span,.yarr>.control .menu_op a,.yarr>.control .menu_op span,.yarr>.control .menu_sort a,.yarr>.control .menu_sort span,.yarr>.control .menu_state a,.yarr>.control .menu_state span,.yarr>.control .stepper a,.yarr>.control .stepper span{cursor:pointer}.yarr>.control .menu_ctl a.selected,.yarr>.control .menu_ctl a:focus,.yarr>.control .menu_ctl a:hover,.yarr>.control .menu_ctl span:focus,.yarr>.control .menu_ctl span:hover,.yarr>.control .menu_layout a.selected,.yarr>.control .menu_layout a:focus,.yarr>.control .menu_layout a:hover,.yarr>.control .menu_layout span:focus,.yarr>.control .menu_layout span:hover,.yarr>.control .menu_manage a.selected,.yarr>.control .menu_manage a:focus,.yarr>.control .menu_manage a:hover,.yarr>.control .menu_manage span:focus,.yarr>.control .menu_manage span:hover,.yarr>.control .menu_op a.selected,.yarr>.control .menu_op a:focus,.yarr>.control .menu_op a:hover,.yarr>.control .menu_op span:focus,.yarr>.control .menu_op span:hover,.yarr>.control .menu_sort a.selected,.yarr>.control .menu_sort a:focus,.yarr>.control .menu_sort a:hover,.yarr>.control .menu_sort span:focus,.yarr>.control .menu_sort span:hover,.yarr>.control .menu_state a.selected,.yarr>.control .menu_state a:focus,.yarr>.control .menu_state a:hover,.yarr>.control .menu_state span:focus,.yarr>.control .menu_state span:hover,.yarr>.control .stepper a.selected,.yarr>.control .stepper a:focus,.yarr>.control .stepper a:hover,.yarr>.control .stepper span:focus,.yarr>.control .stepper span:hover{background-color:#eee}.yarr>.control .menu_layout,.yarr>.control .menu_op,.yarr>.control .menu_sort,.yarr>.control .menu_state{position:relative}.yarr>.control .menu_layout ul,.yarr>.control .menu_op ul,.yarr>.control .menu_sort ul,.yarr>.control .menu_state ul{display:none;background-color:#f8f8f8;width:10rem}.yarr>.control .menu_layout ul li,.yarr>.control .menu_op ul li,.yarr>.control .menu_sort ul li,.yarr>.control .menu_state ul li{display:block;position:relative}.yarr>.control .menu_layout:focus ul,.yarr>.control .menu_layout:hover ul,.yarr>.control .menu_op:focus ul,.yarr>.control .menu_op:hover ul,.yarr>.control .menu_sort:focus ul,.yarr>.control .menu_sort:hover ul,.yarr>.control .menu_state:focus ul,.yarr>.control .menu_state:hover ul{z-index:1000;position:absolute;display:block;border:1px solid #ccc}.yarr>.control .menu_ctl li,.yarr>.control .menu_manage li,.yarr>.control .stepper li{position:relative;display:inline-block}.yarr>.control .menu_ctl-sidebar_toggle:before{mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath d='M3 17a4 4 0 014 4H3v-4zm0-7c6.075 0 11 4.925 11 11h-2a9 9 0 00-9-9v-2zm0-7c9.941 0 18 8.059 18 18h-2c0-8.837-7.163-16-16-16V3z'/%3E%3C/svg%3E")}.yarr>.control .menu_state-all:before{mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath d='M3 3h18a1 1 0 011 1v16a1 1 0 01-1 1H3a1 1 0 01-1-1V4a1 1 0 011-1zm17 4.238l-7.928 7.1L4 7.216V19h16V7.238zM4.511 5l7.55 6.662L19.502 5H4.511z'/%3E%3C/svg%3E")}.yarr>.control .menu_state-unread:before{mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath d='M16.1 3a5.023 5.023 0 000 2H4.511l7.55 6.662 5.049-4.52c.426.527.958.966 1.563 1.285l-6.601 5.911L4 7.216V19h16V8.9a5.023 5.023 0 002 0V20a1 1 0 01-1 1H3a1 1 0 01-1-1V4a1 1 0 011-1h13.1zM21 7a3 3 0 110-6 3 3 0 010 6z'/%3E%3C/svg%3E")}.yarr>.control .menu_state-saved:before{mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath d='M22 13h-2V7.238l-7.928 7.1L4 7.216V19h10v2H3a1 1 0 01-1-1V4a1 1 0 011-1h18a1 1 0 011 1v9zM4.511 5l7.55 6.662L19.502 5H4.511zM19.5 21.75l-2.645 1.39.505-2.945-2.14-2.086 2.957-.43L19.5 15l1.323 2.68 2.957.43-2.14 2.085.505 2.946L19.5 21.75z'/%3E%3C/svg%3E")}.yarr>.control .menu_sort-asc:before{mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath d='M19 3l4 5h-3v12h-2V8h-3l4-5zm-5 15v2H3v-2h11zm0-7v2H3v-2h11zm-2-7v2H3V4h9z'/%3E%3C/svg%3E")}.yarr>.control .menu_sort-desc:before{mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath d='M20 4v12h3l-4 5-4-5h3V4h2zm-8 14v2H3v-2h9zm2-7v2H3v-2h11zm0-7v2H3V4h11z'/%3E%3C/svg%3E")}.yarr>.control .menu_layout-article:before{mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath d='M20 22H4a1 1 0 01-1-1V3a1 1 0 011-1h16a1 1 0 011 1v18a1 1 0 01-1 1zm-1-2V4H5v16h14zM7 6h4v4H7V6zm0 6h10v2H7v-2zm0 4h10v2H7v-2zm6-9h4v2h-4V7z'/%3E%3C/svg%3E")}.yarr>.control .menu_layout-list:before{mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath d='M11 4h10v2H11V4zm0 4h6v2h-6V8zm0 6h10v2H11v-2zm0 4h6v2h-6v-2zM3 4h6v6H3V4zm2 2v2h2V6H5zm-2 8h6v6H3v-6zm2 2v2h2v-2H5z'/%3E%3C/svg%3E")}.yarr>.control .menu_op-mark_read:before{mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath d='M12 3c5.392 0 9.878 3.88 10.819 9-.94 5.12-5.427 9-10.819 9-5.392 0-9.878-3.88-10.819-9C2.121 6.88 6.608 3 12 3zm0 16a9.005 9.005 0 008.777-7 9.005 9.005 0 00-17.554 0A9.005 9.005 0 0012 19zm0-2.5a4.5 4.5 0 110-9 4.5 4.5 0 010 9zm0-2a2.5 2.5 0 100-5 2.5 2.5 0 000 5z'/%3E%3C/svg%3E")}.yarr>.control .menu_manage-read_feeds:before{mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath d='M20 22H4a1 1 0 01-1-1V3a1 1 0 011-1h16a1 1 0 011 1v18a1 1 0 01-1 1zm-1-2V4H5v16h14zM7 6h4v4H7V6zm0 6h10v2H7v-2zm0 4h10v2H7v-2zm6-9h4v2h-4V7z'/%3E%3C/svg%3E")}.yarr>.control .menu_manage-manage_feeds:before{mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath d='M3.34 17a10.018 10.018 0 01-.978-2.326 3 3 0 00.002-5.347A9.99 9.99 0 014.865 4.99a3 3 0 004.631-2.674 9.99 9.99 0 015.007.002 3 3 0 004.632 2.672A9.99 9.99 0 0120.66 7c.433.749.757 1.53.978 2.326a3 3 0 00-.002 5.347 9.99 9.99 0 01-2.501 4.337 3 3 0 00-4.631 2.674 9.99 9.99 0 01-5.007-.002 3 3 0 00-4.632-2.672A10.018 10.018 0 013.34 17zm5.66.196a4.993 4.993 0 012.25 2.77c.499.047 1 .048 1.499.001A4.993 4.993 0 0115 17.197a4.993 4.993 0 013.525-.565c.29-.408.54-.843.748-1.298A4.993 4.993 0 0118 12c0-1.26.47-2.437 1.273-3.334a8.126 8.126 0 00-.75-1.298A4.993 4.993 0 0115 6.804a4.993 4.993 0 01-2.25-2.77c-.499-.047-1-.048-1.499-.001A4.993 4.993 0 019 6.803a4.993 4.993 0 01-3.525.565 7.99 7.99 0 00-.748 1.298A4.993 4.993 0 016 12a4.99 4.99 0 01-1.273 3.334 8.126 8.126 0 00.75 1.298A4.993 4.993 0 019 17.196zM12 15a3 3 0 110-6 3 3 0 010 6zm0-2a1 1 0 100-2 1 1 0 000 2z' fill='rgba(0,0,0,1)'/%3E%3C/svg%3E")}.yarr>.control .stepper a{padding:.5rem 1rem .5rem 1.5rem}.yarr>.control .stepper-previous:before{mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath d='M12 10.828l-4.95 4.95-1.414-1.414L12 8l6.364 6.364-1.414 1.414z'/%3E%3C/svg%3E")}.yarr>.control .stepper-next:before{mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath d='M12 13.172l4.95-4.95 1.414 1.414L12 16 5.636 9.636 7.05 8.222z'/%3E%3C/svg%3E")}.yarr>.control .feednav li{display:inline-block}.yarr>.control .feednav ul.paginated span{padding:.5rem 1rem;background-color:#f8f8f8}.yarr>.control .feednav ul.paginated a{padding:.5rem 1rem}.yarr>.control .feednav ul.paginated a:hover{background-color:#f8f8f8}.yarr .status{padding:.6rem 1rem;font-weight:700;color:#fff;background-color:#6a4;box-shadow:0 0 5px 0 rgba(0,0,0,.7)}.yarr .status.error{background-color:#a54}@media (max-width:768px){.yarr .status{top:auto;bottom:0}}.yarr form.feed_form table{width:100%}.yarr form.feed_form table tr>:first-child{width:10rem;text-align:right;padding-right:.5rem}.yarr form.feed_form table input[type=text]{width:100%}.yarr form.feed_form table .helptext{font-size:.8rem;color:#666}.yarr table.feed_manage{width:100%}.yarr table.feed_manage tr th{background-color:#666;color:#fff;padding:.2rem}.yarr table.feed_manage tr td{padding:.2rem}.yarr table.feed_manage tr:nth-child(2n){background-color:#eee}.yarr table.feed_manage tr td:nth-child(2),.yarr table.feed_manage tr td:nth-child(3){width:4rem}.yarr table.feed_manage tr td:nth-child(4){width:12rem} -------------------------------------------------------------------------------- /yarr/templates/yarr/base.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | -------------------------------------------------------------------------------- /yarr/templates/yarr/base_all.html: -------------------------------------------------------------------------------- 1 | {% extends "yarr/base.html" %} 2 | {% load static %} 3 | 4 | {% block css %} 5 | 6 | {{ block.super }} 7 | {% endblock %} 8 | 9 | {% block title %}{{ title }}{% endblock %} 10 | 11 | {% block content %} 12 |
13 |
14 | {% block yarr_control %}{% endblock %} 15 |
16 | 17 | {% block yarr_body %} 18 |
19 | 22 | 23 |
24 | {% block yarr_content %}{% endblock %} 25 |
26 |
27 |
28 | {% endblock %} 29 |
30 | {% endblock %} 31 | 32 | {% block js %} 33 | 34 | 35 | {{ block.super }} 36 | {% endblock %} 37 | -------------------------------------------------------------------------------- /yarr/templates/yarr/base_manage.html: -------------------------------------------------------------------------------- 1 | {% extends "yarr/base_all.html" %} 2 | 3 | {% block yarr_control %} 4 | 20 | {% endblock %} 21 | 22 | {% block yarr_body %} 23 |
24 |
25 | {% block yarr_content %}{% endblock %} 26 |
27 |
28 | {% endblock %} 29 | -------------------------------------------------------------------------------- /yarr/templates/yarr/confirm.html: -------------------------------------------------------------------------------- 1 | {% extends "yarr/base_manage.html" %} 2 | 3 | {% block yarr_content %} 4 | 5 |
6 | 7 | {% if entry %} 8 |

{{ entry.title }}

9 | {% endif %} 10 | 11 |

{{ message }}

12 | 13 |
14 | {% csrf_token %} 15 | 16 |
17 | 18 |
19 | 20 | {% endblock %} 21 | -------------------------------------------------------------------------------- /yarr/templates/yarr/feed_add.html: -------------------------------------------------------------------------------- 1 | {% extends "yarr/base_manage.html" %} 2 | 3 | {% block yarr_content %} 4 | 5 |
6 | {{ block.super }} 7 | 8 | {% include "yarr/include/form_feed_add.html" %} 9 |
10 | 11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /yarr/templates/yarr/feed_edit.html: -------------------------------------------------------------------------------- 1 | {% extends "yarr/base_manage.html" %} 2 | 3 | {% block yarr_content %} 4 | 5 |
6 | {{ block.super }} 7 | 8 | {% include "yarr/include/form_feed_edit.html" %} 9 |
10 | 11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /yarr/templates/yarr/feeds.html: -------------------------------------------------------------------------------- 1 | {% extends "yarr/base_manage.html" %} 2 | {% load static %} 3 | 4 | {% block yarr_content %} 5 |
6 | 7 |

Add feed

8 | {% include "yarr/include/form_feed_add.html" %} 9 | 10 | 11 |

Manage feeds

12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | {% for feed in feeds %} 33 | 39 | 40 | 41 | 42 | 43 | {% if feed.is_active %} 44 | {% if feed.error %} 45 | 46 | {% else %} 47 | 48 | {% endif %} 49 | {% else %} 50 | 51 | {% endif %} 52 | 53 | {% if feed.is_active %} 54 | 55 | {% else %} 56 | 57 | {% endif %} 58 | 59 | {% endfor %} 60 | 61 |
FeedUnreadStatusNext check
{{ feed }}{{ feed.count_unread }}/{{ feed.count_total }}ProblemActiveInactive{{ feed.next_check|default:"As soon as possible" }}Never
62 | 63 |

Export feeds

64 | 65 |

Export feeds as OPML 66 | 67 |

68 | 69 | {% endblock %} 70 | -------------------------------------------------------------------------------- /yarr/templates/yarr/include/entry.html: -------------------------------------------------------------------------------- 1 | 2 |
7 | {% if not layout_article %} 8 | 9 | 14 |
15 | 18 | {% endif %} 19 | 20 |
21 |

22 | {% if entry.url %}{% endif %} 23 | {{ entry.title|default:"Untitled" }} 24 | {% if entry.url %}{% endif %} 25 |

26 |
27 |

{{ entry.date|date:"M d Y, H:i" }}

28 |

from 29 | {% if entry.feed.site_url %}{% endif %} 30 | {{ entry.feed.title }} 31 | {% if entry.feed.site_url %}{% endif %} 32 | {% if entry.author %}by {{ entry.author }}{% endif %} 33 |

34 |
35 |
36 | 37 |
38 | {{ entry.content|safe }} 39 | 40 | {% if entry.tags %}

{{ entry.tags }}

{% endif %} 41 | {% if entry.comments_url %}

Comments

{% endif %} 42 |
43 | 44 |
45 |
    46 | {% if entry.state == constants.ENTRY_READ %} 47 |
  • Mark as unread
  • 48 |
  • Save
  • 49 | {% else %} 50 | {% if entry.state == constants.ENTRY_SAVED %} 51 |
  • Discard as read
  • 52 |
  • Saved
  • 53 | {% else %} 54 |
  • Mark as read
  • 55 |
  • Save
  • 56 | {% endif %} 57 | {% endif %} 58 |
59 |
60 | 61 | {% if not layout_article %} 62 |
63 | {% endif %} 64 | 65 |
66 | -------------------------------------------------------------------------------- /yarr/templates/yarr/include/form_feed_add.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | {% csrf_token %} 4 | 5 | 6 | {{ feed_form.as_table }} 7 | 8 |
9 | 10 |
11 | -------------------------------------------------------------------------------- /yarr/templates/yarr/include/form_feed_edit.html: -------------------------------------------------------------------------------- 1 | 2 | {% if feed.error %} 3 |

Problem with feed

4 |

Last time it was checked, the feed had the following error:

5 |
{{ feed.error }}
6 | 7 | {% if feed.is_active %} 8 |

The feed is still being checked.

9 | {% else %} 10 |

The feed is no longer being checked.

11 | {% endif %} 12 | 13 |

Edit feed

14 | {% endif %} 15 | 16 |
17 | {% csrf_token %} 18 | 19 | 20 | 21 | 22 | 23 | 24 | {{ feed_form.as_table }} 25 | 26 | 27 | 31 | 32 |
Title:{{ feed.title }}
Last check:{{ feed.last_checked|default:"Not checked yet" }}
Next check:{{ feed.next_check|default:"As soon as possible" }}
Last updated:{{ feed.last_updated|default:"Not checked yet" }}
28 | 29 | Delete this feed 30 |
33 | 34 |
35 | -------------------------------------------------------------------------------- /yarr/templates/yarr/list_entries.html: -------------------------------------------------------------------------------- 1 | {% extends "yarr/base_all.html" %} 2 | {% load static %} 3 | 4 | {% block page_class %}yarr-list-entries{% if not sidebar_default %} yarr__conf-sidebar_override{% endif %}{% if not layout_article %} yarr__conf-layout_list{% endif %}{% endblock %} 5 | 6 | {% block yarr_control %} 7 | {% spaceless %} 8 | 109 | 110 | {% endspaceless %} 111 |
112 | {% if entries.paginator.num_pages > 1 %} 113 |
    114 | {% if pagination.has_previous %} 115 |
  • «
  • 116 | {% endif %} 117 | 118 | {% if pagination.first %} 119 |
  • {{ pagination.first.number }}
  • 120 |
  • 121 | {% endif %} 122 | 123 | {% for linkpage in pagination.pages %} 124 | {% if linkpage.current %} 125 |
  • {{ linkpage.number }}
  • 126 | {% else %} 127 |
  • {{ linkpage.number }}
  • 128 | {% endif %} 129 | {% endfor %} 130 | 131 | {% if pagination.show_last %} 132 |
  • 133 |
  • {{ pagination.last.number }}
  • 134 | {% endif %} 135 | 136 | {% if pagination.has_next %} 137 |
  • »
  • 138 | {% endif %} 139 |
140 | {% endif %} 141 |
142 | 143 | {# List mode needs a detached radio to close items #} 144 | {% if not layout_article %} 145 | 146 | {% endif %} 147 | 148 | {% endblock %} 149 | 150 | 151 | {% block yarr_sidebar %} 152 | 165 | {% endblock %} 166 | 167 | {% block yarr_content %} 168 | {% if entries.object_list|length == 0 %} 169 |

No {% if state == constants.ENTRY_UNREAD %}unread {% endif %}{% if state == constants.ENTRY_SAVED %}saved {% endif %}items

170 | 171 | {% else %} 172 | 173 | {% for entry in entries.object_list %} 174 | {% include "yarr/include/entry.html" %} 175 | {% endfor %} 176 | 177 | {% endif %} 178 | {% endblock %} 179 | -------------------------------------------------------------------------------- /yarr/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | from . import views 4 | from .constants import ENTRY_READ, ENTRY_SAVED, ENTRY_UNREAD 5 | 6 | 7 | app_name = "yarr" 8 | 9 | urlpatterns = [ 10 | url(r"^$", views.index, name="index"), 11 | url(r"^all/$", views.list_entries, name="list_all"), 12 | url(r"^unread/$", views.list_entries, {"state": ENTRY_UNREAD}, name="list_unread"), 13 | url(r"^saved/$", views.list_entries, {"state": ENTRY_SAVED}, name="list_saved"), 14 | # Feed views 15 | url(r"^all/(?P\d+)/$", views.list_entries, name="list_all"), 16 | url( 17 | r"^unread/(?P\d+)/$", 18 | views.list_entries, 19 | {"state": ENTRY_UNREAD}, 20 | name="list_unread", 21 | ), 22 | url( 23 | r"^saved/(?P\d+)/$", 24 | views.list_entries, 25 | {"state": ENTRY_SAVED}, 26 | name="list_saved", 27 | ), 28 | # Feed management 29 | url(r"^feeds/$", views.feeds, name="feeds"), 30 | url(r"^feeds/add/$", views.feed_form, name="feed_add"), 31 | url(r"^feeds/(?P\d+)/$", views.feed_form, name="feed_edit"), 32 | url(r"^feeds/(?P\d+)/delete/$", views.feed_delete, name="feed_delete"), 33 | url(r"^feeds/export/$", views.feeds_export, name="feeds_export"), 34 | # Flag management without javascript 35 | url( 36 | r"^state/read/all/$", 37 | views.entry_state, 38 | {"state": ENTRY_READ, "if_state": ENTRY_UNREAD}, 39 | name="mark_all_read", 40 | ), 41 | url( 42 | r"^state/read/feed/(?P\d+)/$", 43 | views.entry_state, 44 | {"state": ENTRY_READ}, 45 | name="mark_feed_read", 46 | ), 47 | url( 48 | r"^state/read/entry/(?P\d+)/$", 49 | views.entry_state, 50 | {"state": ENTRY_READ}, 51 | name="mark_read", 52 | ), 53 | url( 54 | r"^state/unread/entry/(?P\d+)/$", 55 | views.entry_state, 56 | {"state": ENTRY_UNREAD}, 57 | name="mark_unread", 58 | ), 59 | url( 60 | r"^state/save/entry/(?P\d+)/$", 61 | views.entry_state, 62 | {"state": ENTRY_SAVED}, 63 | name="mark_saved", 64 | ), 65 | # 66 | # JSON API 67 | # 68 | url(r"^api/$", views.api_base, name="api_base"), 69 | url(r"^api/feed/get/$", views.api_feed_get, name="api_feed_get"), 70 | url(r"^api/feed/pks/$", views.api_feed_pks_get, name="api_feed_pks_get"), 71 | url(r"^api/entry/get/$", views.api_entry_get, name="api_entry_get"), 72 | url(r"^api/entry/set/$", views.api_entry_set, name="api_entry_set"), 73 | ] 74 | -------------------------------------------------------------------------------- /yarr/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utils for yarr 3 | """ 4 | import json 5 | from io import BytesIO 6 | from xml.dom import minidom 7 | from xml.etree.ElementTree import Element, ElementTree, SubElement 8 | 9 | from django.core.exceptions import ObjectDoesNotExist 10 | from django.core.paginator import EmptyPage, InvalidPage, Paginator 11 | from django.core.serializers.json import DjangoJSONEncoder 12 | from django.http import HttpResponse 13 | 14 | from . import models, settings 15 | 16 | 17 | def paginate(request, qs, adjacent_pages=3): 18 | """ 19 | Paginate a querystring and prepare an object for building links in template 20 | Returns: 21 | paginated Paginated items 22 | pagination Info for template 23 | """ 24 | paginator = Paginator(qs, settings.PAGE_LENGTH) 25 | try: 26 | page = int(request.GET.get("p", "1")) 27 | except ValueError: 28 | page = 1 29 | try: 30 | paginated = paginator.page(page) 31 | except (EmptyPage, InvalidPage): 32 | paginated = paginator.page(paginator.num_pages) 33 | 34 | # Prep pagination vars 35 | total_pages = paginator.num_pages 36 | start_page = max(paginated.number - adjacent_pages, 1) 37 | if start_page <= 3: 38 | start_page = 1 39 | 40 | end_page = paginated.number + adjacent_pages + 1 41 | if end_page >= total_pages - 1: 42 | end_page = total_pages + 1 43 | 44 | def page_dict(number): 45 | """ 46 | A dictionary which describes a page of the given number. Includes 47 | a version of the current querystring, replacing only the "p" parameter 48 | so nothing else is clobbered. 49 | """ 50 | query = request.GET.copy() 51 | query["p"] = str(number) 52 | return { 53 | "number": number, 54 | "query": query.urlencode(), 55 | "current": number == paginated.number, 56 | } 57 | 58 | page_numbers = [ 59 | n for n in range(start_page, end_page) if n > 0 and n <= total_pages 60 | ] 61 | 62 | if 1 not in page_numbers: 63 | first = page_dict(1) 64 | else: 65 | first = None 66 | 67 | if total_pages not in page_numbers: 68 | last = page_dict(total_pages) 69 | else: 70 | last = None 71 | 72 | pagination = { 73 | "has_next": paginated.has_next(), 74 | "next": page_dict(paginated.next_page_number()) 75 | if paginated.has_next() 76 | else None, 77 | "has_previous": paginated.has_previous(), 78 | "previous": page_dict(paginated.previous_page_number()) 79 | if paginated.has_previous() 80 | else None, 81 | "show_first": first is not None, 82 | "first": first, 83 | "pages": [page_dict(n) for n in page_numbers], 84 | "show_last": last is not None, 85 | "last": last, 86 | } 87 | 88 | return paginated, pagination 89 | 90 | 91 | def jsonEncode(data): 92 | return json.dumps(data, cls=DjangoJSONEncoder) 93 | 94 | 95 | def jsonResponse(data): 96 | """ 97 | Return a JSON HttpResponse 98 | """ 99 | return HttpResponse(jsonEncode(data), content_type="application/json") 100 | 101 | 102 | def import_opml(file_path, user, purge=False): 103 | if purge: 104 | models.Feed.objects.filter(user=user).delete() 105 | 106 | xmldoc = minidom.parse(file_path) 107 | 108 | new = [] 109 | existing = [] 110 | for node in xmldoc.getElementsByTagName("outline"): 111 | url_node = node.attributes.get("xmlUrl", None) 112 | if url_node is None: 113 | continue 114 | url = url_node.value 115 | 116 | title_node = node.attributes.get("title", None) 117 | title = title_node.value if title_node else url 118 | site_node = node.attributes.get("htmlUrl", None) 119 | site_url = site_node.value if site_node else "" 120 | 121 | try: 122 | feed = models.Feed.objects.get( 123 | title=title, feed_url=url, site_url=site_url, user=user 124 | ) 125 | existing.append(feed) 126 | except ObjectDoesNotExist: 127 | feed = models.Feed(title=title, feed_url=url, site_url=site_url, user=user) 128 | new.append(feed) 129 | 130 | models.Feed.objects.bulk_create(new) 131 | return len(new), len(existing) 132 | 133 | 134 | def export_opml(user): 135 | """ 136 | Generate a minimal OPML export of the user's feeds. 137 | 138 | :param user: Django User object 139 | :param stream: writable file-like object to which the XML is written 140 | """ 141 | root = Element("opml", {"version": "1.0"}) 142 | 143 | head = SubElement(root, "head") 144 | title = SubElement(head, "title") 145 | title.text = "{0} subscriptions".format(user.username) 146 | 147 | body = SubElement(root, "body") 148 | 149 | for feed in user.feed_set.all(): 150 | item = SubElement( 151 | body, 152 | "outline", 153 | { 154 | "type": "rss", 155 | "text": feed.title, 156 | "title": feed.title, 157 | "xmlUrl": feed.feed_url, 158 | }, 159 | ) 160 | if feed.site_url: 161 | item.set("htmlUrl", feed.site_url) 162 | 163 | buf = BytesIO() 164 | ElementTree(root).write(buf, encoding="UTF-8") 165 | return buf.getvalue() 166 | --------------------------------------------------------------------------------