Add an Item wrapper object

This commit is contained in:
Tim Van Baak 2023-06-03 20:57:37 -07:00
parent 0de40b1b5c
commit 9407b53c7a
4 changed files with 164 additions and 106 deletions

View File

@ -8,7 +8,7 @@ import time
from flask import Flask, render_template, request, jsonify, abort, redirect, url_for from flask import Flask, render_template, request, jsonify, abort, redirect, url_for
from intake.source import LocalSource, execute_action from intake.source import LocalSource, execute_action, Item
# Globals # Globals
app = Flask(__name__) app = Flask(__name__)
@ -24,18 +24,8 @@ def intake_data_dir() -> Path:
raise Exception("No intake data directory defined") raise Exception("No intake data directory defined")
def item_sort_key(item): def item_sort_key(item: Item):
item_date = item.get("time", item.get("created", 0)) return item.sort_key
return (item_date, item["id"])
def show_item(item):
"""
Whether to show an item based on active and tts.
"""
return item["active"] and (
"tts" not in item or item["created"] + item["tts"] < int(time.time())
)
@app.template_filter("datetimeformat") @app.template_filter("datetimeformat")
@ -80,9 +70,7 @@ def source_feed(name):
if not source.source_path.exists(): if not source.source_path.exists():
abort(404) abort(404)
return _sources_feed( return _sources_feed(name, [source], show_hidden=request.args.get("hidden", True))
name, [source], show_hidden=request.args.get("hidden", True)
)
@app.get("/channel/<string:name>") @app.get("/channel/<string:name>")
@ -98,9 +86,7 @@ def channel_feed(name):
abort(404) abort(404)
sources = [LocalSource(intake_data_dir(), name) for name in channels[name]] sources = [LocalSource(intake_data_dir(), name) for name in channels[name]]
return _sources_feed( return _sources_feed(name, sources, show_hidden=request.args.get("hidden", False))
name, sources, show_hidden=request.args.get("hidden", False)
)
def _sources_feed(name: str, sources: List[LocalSource], show_hidden: bool): def _sources_feed(name: str, sources: List[LocalSource], show_hidden: bool):
@ -113,7 +99,7 @@ def _sources_feed(name: str, sources: List[LocalSource], show_hidden: bool):
item item
for source in sources for source in sources
for item in source.get_all_items() for item in source.get_all_items()
if show_item(item) or show_hidden if not item.is_hidden or show_hidden
], ],
key=item_sort_key, key=item_sort_key,
) )
@ -308,21 +294,17 @@ def add_item():
source_path.mkdir() source_path.mkdir()
config_path = source_path / "intake.json" config_path = source_path / "intake.json"
if not config_path.exists(): if not config_path.exists():
config_path.write_text(json.dumps({ config_path.write_text(
"action": { json.dumps({"action": {"fetch": {"exe": "true"}}}, indent=2)
"fetch": { )
"exe": "true"
}
}
}, indent=2))
source = LocalSource(source_path.parent, source_path.name) source = LocalSource(source_path.parent, source_path.name)
# Clean up the fields # Clean up the fields
item = {key: value for key, value in request.form.items() if value} fields = {key: value for key, value in request.form.items() if value}
item["id"] = '{:x}'.format(getrandbits(16 * 4)) fields["id"] = "{:x}".format(getrandbits(16 * 4))
# TODO: this doesn't support tags or ttX fields correctly # TODO: this doesn't support tags or ttX fields correctly
item = Item.create(source, **fields)
source.new_item(item) source.save_item(item)
return redirect(url_for("source_feed", name="default")) return redirect(url_for("source_feed", name="default"))

View File

@ -224,7 +224,10 @@ def cmd_feed(cmd_args):
for name in args.sources for name in args.sources
if (data / name / "intake.json").exists() if (data / name / "intake.json").exists()
] ]
items = [item for source in sources for item in source.get_all_items()] items = sorted(
[item for source in sources for item in source.get_all_items()],
key=lambda item: item.sort_key,
)
if not items: if not items:
print("Feed is empty") print("Feed is empty")
@ -234,7 +237,7 @@ def cmd_feed(cmd_args):
width = min(80, size.columns) width = min(80, size.columns)
for item in items: for item in items:
title = item["title"] if "title" in item else "" title = item.display_title
titles = [title] titles = [title]
while len(titles[-1]) > width - 4: while len(titles[-1]) > width - 4:
i = titles[-1][: width - 4].rfind(" ") i = titles[-1][: width - 4].rfind(" ")

View File

@ -2,15 +2,137 @@ from datetime import timedelta
from pathlib import Path from pathlib import Path
from subprocess import Popen, PIPE, TimeoutExpired from subprocess import Popen, PIPE, TimeoutExpired
from threading import Thread from threading import Thread
from time import time as current_time
from typing import List from typing import List
import json import json
import os import os
import os.path import os.path
import time
from intake.types import InvalidConfigException, SourceUpdateException from intake.types import InvalidConfigException, SourceUpdateException
class Item:
"""
A wrapper for an item object.
"""
def __init__(self, source: "LocalSource", item: dict):
self.source = source
self._item = item
# Methods to allow Item as a drop-in replacement for the item dict itself
def __contains__(self, key):
return self._item.__contains__(key)
def __iter__(self):
return self._item.__iter__
def __getitem__(self, key):
return self._item.__getitem__(key)
def __setitem__(self, key, value):
return self._item.__setitem__(key, value)
def get(self, key, default=None):
return self._item.get(key, default)
@staticmethod
def create(source: "LocalSource", **fields) -> "Item":
if "id" not in fields:
raise KeyError("id")
item = {
"id": fields["id"],
"source": source.source_name,
"created": int(current_time()),
"active": True,
}
for field_name in (
"title",
"author",
"body",
"link",
"time",
"tags",
"tts",
"ttl",
"ttd",
"action",
):
if val := fields.get(field_name):
item[field_name] = val
return Item(source, item)
@property
def display_title(self):
return self._item.get("title", self._item["id"])
@property
def abs_tts(self):
if "tts" not in self._item:
return None
return self._item["created"] + self._item["tts"]
@property
def can_remove(self):
# The time-to-live fields protects an item from removal until expiry.
# This is mainly used to avoid old items resurfacing when their source
# cannot guarantee monotonocity.
if "ttl" in self._item:
ttl_date = self._item["created"] + self._item["ttl"]
if ttl_date > current_time():
return False
# The time-to-die field puts a maximum lifespan on an item, removing it
# even if it is active.
if "ttd" in self._item:
ttd_date = self._item["created"] + self._item["ttd"]
if ttd_date < current_time():
return True
return not self._item["active"]
@property
def before_tts(self):
return (
"tts" in self._item
and self._item["created"] + self._item["tts"] < current_time()
)
@property
def is_hidden(self):
return not self._item["active"] or self.before_tts
@property
def sort_key(self):
item_date = self._item.get(
"time",
self._item.get(
"created",
),
)
return (item_date, self._item["id"])
def serialize(self, indent=True):
return json.dumps(self._item, indent=2 if indent else None)
def update_from(self, updated: "Item") -> None:
for field in (
"title",
"author",
"body",
"link",
"time",
"tags",
"tts",
"ttl",
"ttd",
):
if field in updated and self[field] != updated[field]:
self[field] = updated[field]
# Actions are not updated since the available actions and associated
# content is left to the action executor to manage.
class LocalSource: class LocalSource:
""" """
An intake source backed by a filesystem directory. An intake source backed by a filesystem directory.
@ -49,38 +171,25 @@ class LocalSource:
def item_exists(self, item_id) -> bool: def item_exists(self, item_id) -> bool:
return self.get_item_path(item_id).exists() return self.get_item_path(item_id).exists()
def new_item(self, item: dict) -> dict: def get_item(self, item_id: str) -> Item:
# Ensure required fields
if "id" not in item:
raise KeyError("id")
item["source"] = self.source_name
item["active"] = True
item["created"] = int(time.time())
item["title"] = item.get("title", item["id"])
item["tags"] = item.get("tags", [self.source_name])
# All other fields are optiona
self.save_item(item)
return item
def get_item(self, item_id: str) -> dict:
with self.get_item_path(item_id).open() as f: with self.get_item_path(item_id).open() as f:
return json.load(f) return Item(self, json.load(f))
def save_item(self, item: dict) -> None: def save_item(self, item: Item) -> None:
# Write to a tempfile first to avoid losing the item on write failure # Write to a tempfile first to avoid losing the item on write failure
item_path = self.get_item_path(item["id"]) item_path = self.get_item_path(item["id"])
tmp_path = item_path.with_name(f"{item_path.name}.tmp") tmp_path = item_path.with_name(f"{item_path.name}.tmp")
with tmp_path.open("w") as f: with tmp_path.open("w") as f:
f.write(json.dumps(item, indent=2)) f.write(item.serialize())
os.rename(tmp_path, item_path) os.rename(tmp_path, item_path)
def delete_item(self, item_id) -> None: def delete_item(self, item_id) -> None:
os.remove(self.get_item_path(item_id)) os.remove(self.get_item_path(item_id))
def get_all_items(self) -> List[dict]: def get_all_items(self) -> List[Item]:
for filepath in self.source_path.iterdir(): for filepath in self.source_path.iterdir():
if filepath.name.endswith(".item"): if filepath.name.endswith(".item"):
yield json.loads(filepath.read_text(encoding="utf8")) yield Item(self, json.loads(filepath.read_text(encoding="utf8")))
def _read_stdout(process: Popen, output: list) -> None: def _read_stdout(process: Popen, output: list) -> None:
@ -188,7 +297,7 @@ def fetch_items(source: LocalSource, timeout: int = 60) -> List[dict]:
for line in output: for line in output:
try: try:
item = json.loads(line) item = Item.create(source, **json.loads(line))
items.append(item) items.append(item)
except json.JSONDecodeError: except json.JSONDecodeError:
raise SourceUpdateException("invalid json") raise SourceUpdateException("invalid json")
@ -202,23 +311,23 @@ def execute_action(
""" """
Execute the action for a feed source. Execute the action for a feed source.
""" """
item = source.get_item(item_id) item: Item = source.get_item(item_id)
output = _execute_source_action( output = _execute_source_action(
source, action, json.dumps(item), timedelta(timeout) source, action, item.serialize(indent=False), timedelta(timeout)
) )
if not output: if not output:
raise SourceUpdateException("no item") raise SourceUpdateException("no item")
try: try:
item = json.loads(output[0]) item = Item(source, json.loads(output[0]))
source.save_item(item) source.save_item(item)
return item return item
except json.JSONDecodeError: except json.JSONDecodeError:
raise SourceUpdateException("invalid json") raise SourceUpdateException("invalid json")
def update_items(source: LocalSource, fetched_items): def update_items(source: LocalSource, fetched_items: List[Item]):
""" """
Update the source with a batch of new items, doing creations, updates, and Update the source with a batch of new items, doing creations, updates, and
deletions as necessary. deletions as necessary.
@ -228,8 +337,8 @@ def update_items(source: LocalSource, fetched_items):
print(f"Found {len(prior_ids)} prior items") print(f"Found {len(prior_ids)} prior items")
# Determine which items are new and which are updates. # Determine which items are new and which are updates.
new_items = [] new_items: List[Item] = []
upd_items = [] upd_items: List[Item] = []
for item in fetched_items: for item in fetched_items:
if source.item_exists(item["id"]): if source.item_exists(item["id"]):
upd_items.append(item) upd_items.append(item)
@ -239,60 +348,24 @@ def update_items(source: LocalSource, fetched_items):
# Write all the new items to the source directory. # Write all the new items to the source directory.
for item in new_items: for item in new_items:
# TODO: support on-create trigger # TODO: support on-create trigger
source.new_item(item) source.save_item(item)
# Update the other items using the fetched items' values. # Update the other items using the fetched items' values.
for upd_item in upd_items: for upd_item in upd_items:
old_item = source.get_item(upd_item["id"]) old_item = source.get_item(upd_item["id"])
for field in ( old_item.update_from(upd_item)
"title", source.save_item(old_item)
"tags",
"link",
"time",
"author",
"body",
"ttl",
"ttd",
"tts",
):
if field in upd_item and old_item[field] != upd_item[field]:
old_item[field] = upd_item[field]
if "callback" in upd_item:
# Because of the way this update happens, any fields that are set
# in the callback when the item is new will keep their original
# values, as those values reappear in new_item on subsequent
# updates.
old_item["callback"] = {**old_item["callback"], **upd_item["callback"]}
# Items are removed when they are old (not in the latest fetch) and # Items are removed when they are old (not in the latest fetch) and
# inactive. Some item fields change this basic behavior. # inactive. Some item fields change this basic behavior.
del_count = 0 del_count = 0
now = int(time.time()) # now = int(current_time())
upd_ids = [item["id"] for item in upd_items] upd_ids = [item["id"] for item in upd_items]
old_item_ids = [item_id for item_id in prior_ids if item_id not in upd_ids] old_item_ids = [item_id for item_id in prior_ids if item_id not in upd_ids]
for item_id in old_item_ids: for item_id in old_item_ids:
item = source.get_item(item_id) if source.get_item(item_id).can_remove:
remove = not item["active"] source.delete_item(item_id)
# The time-to-live field protects an item from removal until expiry.
# This is mainly used to avoid old items resurfacing when their source
# cannot guarantee monotonicity.
if "ttl" in item:
ttl_date = item["created"] + item["ttl"]
if ttl_date > now:
continue
# The time-to-die field puts a maximum lifespan on an item, removing it
# even if it is active.
if "ttd" in item:
ttd_date = item["created"] + item["ttd"]
if ttd_date < now:
remove = True
# Items to be removed are deleted.
if remove:
source.delete_item(item["id"])
del_count += 1 del_count += 1
print(len(new_items), "new,", del_count, "deleted") print(len(new_items), "new,", del_count, "deleted")

View File

@ -127,8 +127,8 @@ var doAction = function (source, itemid, action) {
{% if items %} {% if items %}
{% for item in items %} {% for item in items %}
<div class="readable-item <div class="readable-item
{%- if not item.active %} strikethru fade{% endif %} {%- if not item.active %} strikethru{% endif %}
{%- if item.active and item.tts and item.created + item.tts > now %} fade{% endif -%} {%- if item.is_hidden %} fade{% endif -%}
" id="{{item.source}}-{{item.id}}"> " id="{{item.source}}-{{item.id}}">
{% if item.id %} {% if item.id %}
<button class="item-button" onclick="javascript:deactivate('{{item.source}}', '{{item.id}}')" title="Deactivate">&#10005;</button> <button class="item-button" onclick="javascript:deactivate('{{item.source}}', '{{item.id}}')" title="Deactivate">&#10005;</button>
@ -143,7 +143,7 @@ var doAction = function (source, itemid, action) {
{# The item title is a clickable <summary> if there is body content #} {# The item title is a clickable <summary> if there is body content #}
{% if item.body or item.action %} {% if item.body or item.action %}
<details> <details>
<summary><span class="item-title">{{item.title}}</span></summary> <summary><span class="item-title">{{item.display_title}}</span></summary>
{% if item.body %} {% if item.body %}
<p>{{item.body|safe}}</p> <p>{{item.body|safe}}</p>
{% endif %} {% endif %}
@ -152,7 +152,7 @@ var doAction = function (source, itemid, action) {
{% endfor %} {% endfor %}
</details> </details>
{% else %} {% else %}
<span class="item-title">{{item.title}}</span><br> <span class="item-title">{{item.display_title}}</span><br>
{% endif %} {% endif %}
{# author/time footer line #} {# author/time footer line #}