Repository: lemon24/reader

Issue:
mark-as-read backfill 
It would be nice if the [mark-as-read](https://reader.readthedocs.io/en/latest/plugins.html#reader-mark-as-read) plugin would allow matching the current rules against *existing* entries in a feed, and not only new ones.

Two ways the user could do this, API-wise:

* Add a `.reader.mark-as-read.once` feed tag, similar to that used by the [entry dedupe plugin](https://reader.readthedocs.io/en/latest/plugins.html#reader-entry-dedupe). *(suggested)*
* Add a `'backfill': 'pending'` field to the existing `.reader.mark-as-read` feed tag value / config, similar to [readtime](https://reader.readthedocs.io/en/latest/plugins.html#reader-readtime).
  * Unlike readtime, this would involve updating a user-provided value, so some care would be needed to clear the `backfill` field; #308 would help here.

Discussion:
@lemon24: The `.reader.mark-as-read.once` version can be done by adding a new hook to [before_feed_update_hooks](https://reader.readthedocs.io/en/latest/api.html#reader.Reader.before_feed_update_hooks) that checks for the tag and then delegates to the existing [_mark_as_read()](https://github.com/lemon24/reader/blob/3.12/src/reader/plugins/mark_as_read.py#L78-L94).

Solution patch:
diff --git a/docs/make.bat b/docs/make.bat
index a379de64..d9ebd27e 100644
--- a/docs/make.bat
+++ b/docs/make.bat
@@ -1,36 +1,36 @@
-@ECHO OFF
-
-pushd %~dp0
-
-REM Command file for Sphinx documentation
-
-if "%SPHINXBUILD%" == "" (
-	set SPHINXBUILD=sphinx-build
-)
-set SOURCEDIR=.
-set BUILDDIR=_build
-set SPHINXPROJ=reader
-
-if "%1" == "" goto help
-
-%SPHINXBUILD% >NUL 2>NUL
-if errorlevel 9009 (
-	echo.
-	echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
-	echo.installed, then set the SPHINXBUILD environment variable to point
-	echo.to the full path of the 'sphinx-build' executable. Alternatively you
-	echo.may add the Sphinx directory to PATH.
-	echo.
-	echo.If you don't have Sphinx installed, grab it from
-	echo.http://sphinx-doc.org/
-	exit /b 1
-)
-
-%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
-goto end
-
-:help
-%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
-
-:end
-popd
+@ECHO OFF
+
+pushd %~dp0
+
+REM Command file for Sphinx documentation
+
+if "%SPHINXBUILD%" == "" (
+	set SPHINXBUILD=sphinx-build
+)
+set SOURCEDIR=.
+set BUILDDIR=_build
+set SPHINXPROJ=reader
+
+if "%1" == "" goto help
+
+%SPHINXBUILD% >NUL 2>NUL
+if errorlevel 9009 (
+	echo.
+	echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
+	echo.installed, then set the SPHINXBUILD environment variable to point
+	echo.to the full path of the 'sphinx-build' executable. Alternatively you
+	echo.may add the Sphinx directory to PATH.
+	echo.
+	echo.If you don't have Sphinx installed, grab it from
+	echo.http://sphinx-doc.org/
+	exit /b 1
+)
+
+%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
+goto end
+
+:help
+%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
+
+:end
+popd
diff --git a/src/reader/plugins/mark_as_read.py b/src/reader/plugins/mark_as_read.py
old mode 100644
new mode 100755
index 964b839a..93b28358
--- a/src/reader/plugins/mark_as_read.py
+++ b/src/reader/plugins/mark_as_read.py
@@ -50,6 +50,7 @@ import logging
 import re
 
 from reader.exceptions import EntryNotFoundError
+from reader.plugins.entry_dedupe import _normalize
 from reader.types import EntryUpdateStatus
 
 
@@ -75,6 +76,7 @@ def _get_config(reader, feed_url, key, patterns_key):
 
 
 _CONFIG_TAG = 'mark-as-read'
+_ONCE_TAG_SUFFIX = 'once'
 
 
 def _mark_as_read(reader, entry, status):
@@ -96,5 +98,25 @@ def _mark_as_read(reader, entry, status):
         log.info("entry %r was deleted, skipping", entry.resource_id)
 
 
+def _mark_as_read_backfill(reader, feed, *, dry_run=False):
+    def by_title(e):
+        return _normalize(e.title)
+
+    suffix = _ONCE_TAG_SUFFIX
+
+    # assume there will only be exactly one "once" tag
+    mark_as_read_once_tag = reader.make_reader_reserved_name(f'{_CONFIG_TAG}.{suffix}')
+    if reader.get_tag(feed, mark_as_read_once_tag, "no value") == "no value":
+        return
+
+    log.info("_mark_as_read_backfill: tag: %r for feed %r", mark_as_read_once_tag, feed)
+
+    for entry in reader.get_entries(feed=feed, read=False, important='notset'):
+        _mark_as_read(reader, entry, EntryUpdateStatus.NEW)
+
+    reader.delete_tag(feed, mark_as_read_once_tag, missing_ok=True)
+
+
 def init_reader(reader):
+    reader.before_feed_update_hooks.append(_mark_as_read_backfill)
     reader.after_entry_update_hooks.append(_mark_as_read)


Test patch:
diff --git a/tests/test_plugins_mark_as_read.py b/tests/test_plugins_mark_as_read.py
index a0f2f084..969cb28c 100644
--- a/tests/test_plugins_mark_as_read.py
+++ b/tests/test_plugins_mark_as_read.py
@@ -1,5 +1,6 @@
 import json
 import os
+from dataclasses import _MISSING_TYPE
 
 import pytest
 
@@ -7,6 +8,68 @@ from fakeparser import Parser
 from utils import utc_datetime as datetime
 
 
+def test_regex_mark_as_read_backfill(make_reader):
+    key = '.reader.mark-as-read'
+    value = {'title': ['^match']}
+
+    reader = make_reader(':memory:', plugins=['reader.mark_as_read'])
+
+    def get_entry_data(**kwargs):
+        return {
+            (e.id, e.read, e.read_modified, e.important, e.important_modified)
+            for e in reader.get_entries(**kwargs)
+        }
+
+    parser = Parser()
+    reader._parser = parser
+
+    one = parser.feed(1)
+    reader.add_feed(one)
+
+    match_1 = parser.entry(1, 1, title='match 1')
+    match_2 = parser.entry(1, 2, title='match 2')
+    match_3 = parser.entry(1, 3, title='match 3')
+    no_match_4 = parser.entry(1, 4, title='no match 4')
+    no_match_5 = parser.entry(1, 5, title='no match 5')
+
+    # an entry that has been touched by the user and marked as read and importand -- this entry will not be modified after reader.update_feeds()
+    match_existing = parser.entry(
+        1, 6, title='match: existing entry already touched by the user'
+    )
+
+    # call reader.update_feeds() once to get the feed/entries into reader
+    reader.update_feeds()
+
+    assert len(list(reader.get_entries())) == 6
+
+    reader.set_entry_read(match_existing, True, None)
+    reader.set_entry_important(match_existing, True, None)
+
+    assert len(list(reader.get_entries(read=False))) == 5
+    # existing entry already touched by the user
+    assert len(list(reader.get_entries(read=True))) == 1
+
+    reader.set_tag(one, '.reader.mark-as-read.once')
+    reader.set_tag(one, key, value)
+    # call reader.update_feeds() again to test the .reader.mark-as-read.once flag
+    reader.update_feeds()
+
+    assert len(list(reader.get_entries(read=True))) == 4
+    # assert that only matching entries that were not touched by the user are modified
+    assert len(list(reader.get_entries(read=True, important=False))) == 3
+    assert len(list(reader.get_entries(read=False))) == 2
+
+    assert get_entry_data(read=True) == {
+        (match_1.id, True, None, False, None),
+        (match_2.id, True, None, False, None),
+        (match_3.id, True, None, False, None),
+        (match_existing.id, True, None, True, None),
+    }
+
+    # assert that .reader.mark-as-read.once tag is not set anymore
+    assert reader.get_tag(one, '.reader.mark-as-read.once', "no value") == "no value"
+
+
 def test_regex_mark_as_read(make_reader):
     key = '.reader.mark-as-read'
     value = {'title': ['^match']}