mirror of
https://codeberg.org/Reuh/feather.git
synced 2025-10-27 18:19:32 +00:00
Compare commits
No commits in common. "d77a92cb829925110f7fada1da4ddc9e40f581fe" and "54fa01d4e5475c1e6d9535b0b29bd043aaf31f2e" have entirely different histories.
d77a92cb82
...
54fa01d4e5
8 changed files with 50 additions and 111 deletions
27
README.md
27
README.md
|
|
@ -43,40 +43,27 @@ Tip: if you have nested categories, search "html" to list all the articles in th
|
|||
|
||||
### Marking articles as read
|
||||
|
||||
Deleting an article file will mark them as read (will take effect on the next synchronization to the server).
|
||||
Deleting an article will toggle their read status (will take effect on the next synchronization to the server).
|
||||
|
||||

|
||||
|
||||
#### Handling read articles
|
||||
|
||||
The now read articles can (surprisingly) be found in the trash. After marking an article as read, there is a grace period (by default 3 days) during which you can mark read articles as unread again by restoring their files from the trash.
|
||||
The now read articles can (surprisingly) be found in the trash. If you're fast and restore them before the next synchronization, it's be as if nothing happened. However, restoring the file after synchronization will otherwise not work and the article won't be marked as unread.
|
||||
|
||||
#### Reading read articles
|
||||
|
||||
If you want to re-read your favorites articles directly in the Feather reader directory, you can configure Feather to write articles files for read articles too:
|
||||
Instead, if you want Feather to also track read articles, you could add to your configuration file:
|
||||
|
||||
```toml
|
||||
[html]
|
||||
# Write article HTML files for read articles
|
||||
write_read_articles = true
|
||||
# Grab both read and unread articles into the local directory
|
||||
server.only_sync_unread_articles = false
|
||||
# Add a checkmark in the article filename indicating the read status
|
||||
filename_template = "{% if unread %}☐{% else %}☑{% endif %} [{{ feed_title }}]\t{{ title }} ({{ published }}).html"
|
||||
html.filename_template = "{% if unread %}☐{% else %}☑{% endif %} [{{ feed_title }}]\t{{ title }} ({{ published }}).html"
|
||||
```
|
||||
|
||||
Now both read and unread articles will be stored in the Feather reader directory, and after marking an article file as read by deleting it, Feather will regenerate the file on the next synchronization (but marked as read this time).
|
||||
|
||||
Note that this also change the mark-as-unread behavior: since it is no longer possible to restore from the trash because the file is automatically recreated, marking an item as unread is done in the same way as mark-as-read, i.e. by deleting the file of a read article.
|
||||
Now both read and unread articles will be stored in the Feather reader directory, and if you delete a read article file, the article will be marked as unread (and the deleted file will be recreated during the next synchronization, but marked as unread).
|
||||
|
||||

|
||||
|
||||
By default, Feather will only grab unread articles from the server, so the read articles you have access to locally are only the articles kept for the 3 days grace period after marking them as read (see the [handling read articles chapter](#handling-read-articles)). If you want to have access to _all_ articles from the server, you can add to your configuration:
|
||||
|
||||
```toml
|
||||
[server]
|
||||
# Grab both read and unread articles from the server
|
||||
only_sync_unread_articles = false
|
||||
```
|
||||
|
||||
### Synchronizing with the server
|
||||
|
||||
Run `feather sync` to synchronize all local data with the server. The synchronization is done in two parts, which you can perform separately using:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "feather"
|
||||
version = "1.1.0"
|
||||
version = "1.0.0"
|
||||
authors = [ { name = 'Étienne "Reuh" Fildadut' } ]
|
||||
description = "file-based RSS reader client"
|
||||
readme = "README.md"
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ from asyncio import Event
|
|||
from typing import Iterable
|
||||
from watchfiles import awatch
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
from feather.config import Config
|
||||
from feather.client import GReaderSession, TTRSession, ClientSession, Article, ArticleId
|
||||
|
|
@ -83,16 +82,13 @@ class FeatherApp:
|
|||
to_mark_as_read = []
|
||||
to_mark_as_unread = []
|
||||
for article in self.iter_articles():
|
||||
has_html = article.has_html()
|
||||
if article.unread and not has_html:
|
||||
to_mark_as_read.append(article)
|
||||
marked_as_read += 1
|
||||
elif not article.unread and (
|
||||
(config.write_read_articles and not has_html)
|
||||
or (not config.write_read_articles and has_html)
|
||||
):
|
||||
to_mark_as_unread.append(article)
|
||||
marked_as_unread += 1
|
||||
if not article.has_html():
|
||||
if article.unread:
|
||||
to_mark_as_read.append(article)
|
||||
marked_as_read += 1
|
||||
else:
|
||||
to_mark_as_unread.append(article)
|
||||
marked_as_unread += 1
|
||||
|
||||
if len(to_mark_as_read) == len(to_mark_as_unread) == 0:
|
||||
return # nothing to do
|
||||
|
|
@ -110,10 +106,13 @@ class FeatherApp:
|
|||
to_mark_as_unread_id[i : i + config.articles_per_query], False
|
||||
)
|
||||
|
||||
# regenerate local file with new read/unread state
|
||||
# regenerate/delete local file with new read/unread state
|
||||
for article in to_mark_as_read:
|
||||
article.unread = False
|
||||
article.regenerate()
|
||||
if config.only_sync_unread_articles:
|
||||
article.delete()
|
||||
else:
|
||||
article.regenerate()
|
||||
for article in to_mark_as_unread:
|
||||
article.unread = True
|
||||
article.regenerate()
|
||||
|
|
@ -162,16 +161,8 @@ class FeatherApp:
|
|||
|
||||
# Remove articles that we didn't get from the server but are in the JSON directory
|
||||
removed_articles = 0
|
||||
article_cutoff_timestamp = (
|
||||
datetime.now().timestamp() - config.keep_read_articles_for
|
||||
)
|
||||
for article in self.iter_articles():
|
||||
if (
|
||||
# we sync all articles: remove all articles that aren't on the server
|
||||
not config.only_sync_unread_articles
|
||||
# we only sync unread: only remove articles that are too old
|
||||
or article.last_write < article_cutoff_timestamp
|
||||
) and article.id not in grabbed_article_paths:
|
||||
if article.id not in grabbed_article_paths:
|
||||
article.delete()
|
||||
removed_articles += 1
|
||||
|
||||
|
|
@ -269,7 +260,6 @@ class FeatherApp:
|
|||
"""Regenerate all local files using local data only"""
|
||||
for article in self.iter_articles():
|
||||
article.regenerate()
|
||||
self.remove_empty_categories()
|
||||
|
||||
def clear_data(self):
|
||||
"""Delete all local data"""
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from __future__ import annotations
|
|||
|
||||
import re
|
||||
from abc import ABC, abstractmethod
|
||||
from ttrss.client import TTRClient, Headline
|
||||
from ttrss.client import TTRClient
|
||||
import google_reader
|
||||
|
||||
from feather.config import Config
|
||||
|
|
@ -146,21 +146,12 @@ class GReaderArticle(Article):
|
|||
# several API references I've seen didn't mention canonical, but alternate seems to also be the article link (?) and should be an ok fallback
|
||||
self.article_url = item_content.alternate[0].href
|
||||
|
||||
self.json_path = self._get_json_path()
|
||||
self._compute_json_path()
|
||||
|
||||
|
||||
## Tiny Tiny RSS API ##
|
||||
|
||||
|
||||
# Monkey patch Headline.__init__ to skip timestamp to datetime conversion
|
||||
# Articles may have a negative timestamp and Python's datetime.fromtimestamp doesn't like that, so instead we keep the timestamp and deal with the issue in data.py/format_datetime
|
||||
def Headline_init(self, attr, client):
|
||||
super(Headline, self).__init__(attr, client)
|
||||
|
||||
|
||||
Headline.__init__ = Headline_init
|
||||
|
||||
|
||||
class TTRSession(ClientSession):
|
||||
"""Tiny Tiny RSS API client"""
|
||||
|
||||
|
|
@ -243,8 +234,8 @@ class TTRArticle(Article):
|
|||
|
||||
self.unread = article.unread
|
||||
self.title = article.title
|
||||
self.published = article.updated
|
||||
self.updated = article.updated
|
||||
self.published = article.updated.timestamp()
|
||||
self.updated = article.updated.timestamp()
|
||||
self.author = article.author
|
||||
self.summary = article.excerpt
|
||||
self.content = article.content
|
||||
|
|
@ -257,4 +248,4 @@ class TTRArticle(Article):
|
|||
self.language = article.lang
|
||||
self.image_url = article.flavor_image
|
||||
|
||||
self.json_path = self._get_json_path()
|
||||
self._compute_json_path()
|
||||
|
|
|
|||
|
|
@ -23,17 +23,9 @@ password = "password"
|
|||
# Set to 0 to let Feather choose (200 for ttrss, 1000 for googlereader).
|
||||
# Can be set through the environment variable SERVER_ARTICLES_PER_REQUEST.
|
||||
articles_per_request = 0
|
||||
# Set to true to only sync unread articles; Feather will not retrieve any read article from the server.
|
||||
# If set to false, Feather will download ALL articles from the server, read and unread, on each synchronization. This might be a lot of data depending on how many read articles your server keeps. If you only want to keep recent read articles, look at the keep_read_articles_for settings below.
|
||||
# Set to true to only sync unread articles; Feather will not retrieve or store any read article.
|
||||
# Can be set through the environment variable SERVER_ONLY_SYNC_UNREAD_ARTICLES.
|
||||
only_sync_unread_articles = true
|
||||
# How long in seconds to keep read articles in the local storage before deleting them.
|
||||
# Once an article is removed, Feather can no longer:
|
||||
# - mark it as unread when its article file is restored from the trash;
|
||||
# - generate articles files for read articles if html.write_read_articles = true.
|
||||
# If only_sync_unread_articles = false, this does nothing (since Feather always retrieve all read articles from the server).
|
||||
# Can be set through the environment variable SERVER_KEEP_READ_ARTICLES_FOR.
|
||||
keep_read_articles_for = 259200
|
||||
|
||||
[directories]
|
||||
# Data directory: path where the internal Feather data will be stored.
|
||||
|
|
@ -44,12 +36,6 @@ data = "data"
|
|||
reader = "reader"
|
||||
|
||||
[html]
|
||||
# If set to true, Feather will also generate articles files for read articles.
|
||||
# The the mark-as-unread behavior will change depending on this value:
|
||||
# - if false, marking an article as unread requires its file to be recreated/restored from the trash;
|
||||
# - if true, marking an article as unread requires deleting its article file (same as mark-as-read).
|
||||
# Can be set through the environment variable HTML_WRITE_READ_ARTICLES.
|
||||
write_read_articles = false
|
||||
# Template used for generating article HTML files. All templates are Jinja2 templates.
|
||||
# Available fields:
|
||||
# - id: article id (int | str)
|
||||
|
|
|
|||
|
|
@ -61,14 +61,10 @@ class Config:
|
|||
self.only_sync_unread_articles: bool = bool(
|
||||
get_config("server", "only_sync_unread_articles")
|
||||
)
|
||||
self.keep_read_articles_for: float = float(
|
||||
get_config("server", "keep_read_articles_for")
|
||||
)
|
||||
|
||||
self.timezone: ZoneInfo = ZoneInfo(str(get_config("datetime", "timezone")))
|
||||
self.time_format: str = str(get_config("datetime", "format"))
|
||||
|
||||
self.write_read_articles: bool = bool(get_config("html", "write_read_articles"))
|
||||
self.article_template: Template = Template(
|
||||
str(get_config("html", "article_template")), autoescape=True
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from __future__ import annotations
|
|||
import os
|
||||
import json
|
||||
from abc import ABC
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from hashlib import sha256
|
||||
from tempfile import NamedTemporaryFile
|
||||
|
|
@ -16,7 +16,7 @@ from feather.config import Config
|
|||
def sanitize_filename(
|
||||
config: Config, filename: str, insert_before_suffix: str = ""
|
||||
) -> str:
|
||||
"""Escape invalid characters and truncate the filename as per the configuration.
|
||||
"""Escape invalid caracters and truncate the filename as per the configuration.
|
||||
This operates on a single filename, not a path.
|
||||
(insert_before_suffix will be inserted between the stem and suffix, and is assumed to not need escaping)."""
|
||||
filename = filename.translate(config.filename_translation)
|
||||
|
|
@ -38,15 +38,11 @@ def sanitize_filename(
|
|||
return filename[:cutoff] + "…" + insert_before_suffix + suffix
|
||||
|
||||
|
||||
def format_datetime(config: Config, timestamp: float) -> str:
|
||||
"""Format a timestamp according to the configuration."""
|
||||
if timestamp < 0:
|
||||
date = datetime(1970, 1, 1, tzinfo=config.timezone) + timedelta(
|
||||
seconds=timestamp
|
||||
)
|
||||
else:
|
||||
date = datetime.fromtimestamp(timestamp, config.timezone)
|
||||
return date.strftime(config.time_format)
|
||||
def format_datetime(config: Config, timestamp: int) -> str:
|
||||
"""Format a timestamp according to the configuraiton."""
|
||||
return datetime.fromtimestamp(timestamp, config.timezone).strftime(
|
||||
config.time_format
|
||||
)
|
||||
|
||||
|
||||
def atomic_write(path: Path, content: str):
|
||||
|
|
@ -103,8 +99,8 @@ class Article(ABC):
|
|||
# with default value
|
||||
unread: bool = True # if the article is unread
|
||||
title: str = "" # article title
|
||||
published: float = 0.0 # article publication time (timestamp)
|
||||
updated: float = 0.0 # article update time (timestamp)
|
||||
published: int = 0 # article publication time (timestamp)
|
||||
updated: int = 0 # article update time (timestamp)
|
||||
author: str = "" # article author
|
||||
summary: str = "" # article summary (HTML)
|
||||
content: str = "" # article content (HTML)
|
||||
|
|
@ -116,7 +112,6 @@ class Article(ABC):
|
|||
comments_url: str = "" # article comments URL
|
||||
language: str = "" # article language
|
||||
image_url: str = "" # article main image
|
||||
last_write: float = 0.0 # last time this article file was written (timestamp)
|
||||
|
||||
def _hash_id(self):
|
||||
return sha256(str(self.id).encode("utf-8")).hexdigest()
|
||||
|
|
@ -173,13 +168,13 @@ class Article(ABC):
|
|||
d["category"] = self.category.asdict()
|
||||
return d
|
||||
|
||||
def _get_json_path(self) -> Path:
|
||||
return self.config.json_root / f"{self._hash_id()}.json"
|
||||
def _compute_json_path(self):
|
||||
self.json_path = self.config.json_root / f"{self._hash_id()}.json"
|
||||
|
||||
def _write_json(self, recompute_path=False):
|
||||
"""Write the JSON file associated with this article. Error if it already exists."""
|
||||
if recompute_path:
|
||||
self.json_path = self._get_json_path()
|
||||
self._compute_json_path()
|
||||
stored_fields = (
|
||||
"id",
|
||||
"unread",
|
||||
|
|
@ -198,7 +193,6 @@ class Article(ABC):
|
|||
"language",
|
||||
"image_url",
|
||||
"html_path",
|
||||
"last_write",
|
||||
)
|
||||
article_json = {field: getattr(self, field) for field in stored_fields}
|
||||
article_json["category"] = self.category.asdict()
|
||||
|
|
@ -231,16 +225,13 @@ class Article(ABC):
|
|||
html_path, config.article_template.render(self._get_template_dict())
|
||||
)
|
||||
# set accessed date to update time, modified to publication time
|
||||
os.utime(html_path, (max(int(self.published), int(self.updated)), int(self.published)))
|
||||
os.utime(html_path, (max(self.published, self.updated), self.published))
|
||||
|
||||
def _delete_html(self, missing_ok=False):
|
||||
"""Delete the HTML file associated with this article."""
|
||||
# Delete a HTML file for a JSON object
|
||||
if self.html_path is None:
|
||||
return
|
||||
else:
|
||||
html_path = self.config.html_root / self.html_path
|
||||
html_path.unlink(missing_ok=missing_ok)
|
||||
html_path = self.config.html_root / self.html_path
|
||||
html_path.unlink(missing_ok=missing_ok)
|
||||
|
||||
def has_html(self) -> bool:
|
||||
"""Check if the HTML file associated with the article exists on disk."""
|
||||
|
|
@ -256,15 +247,13 @@ class Article(ABC):
|
|||
|
||||
def write(self, recompute_paths=False):
|
||||
"""Write all the files associated with this article to disk."""
|
||||
self.last_write = datetime.now().timestamp()
|
||||
if self.unread or self.config.write_read_articles:
|
||||
try:
|
||||
self._write_html(recompute_path=recompute_paths)
|
||||
except FileExistsError:
|
||||
raise
|
||||
except:
|
||||
self._delete_html(missing_ok=True)
|
||||
raise
|
||||
try:
|
||||
self._write_html(recompute_path=recompute_paths)
|
||||
except FileExistsError:
|
||||
raise
|
||||
except:
|
||||
self._delete_html(missing_ok=True)
|
||||
raise
|
||||
try:
|
||||
self._write_json(recompute_path=recompute_paths)
|
||||
except FileExistsError:
|
||||
|
|
|
|||
2
uv.lock
generated
2
uv.lock
generated
|
|
@ -69,7 +69,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "feather"
|
||||
version = "1.1.0"
|
||||
version = "1.0.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "google-reader" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue