87 lines
3.3 KiB
Python
87 lines
3.3 KiB
Python
|
|
"""OPML import/export.
|
||
|
|
|
||
|
|
Feeds are exported as an OPML 2.0 outline grouped by ntfy topic. ntfy-specific
|
||
|
|
options are stored as custom ``ntfy*`` attributes so an export/import round-trip
|
||
|
|
preserves everything. Import also accepts plain OPML files from other readers:
|
||
|
|
feeds without an ``ntfyTopic`` attribute inherit the parent outline's text as the
|
||
|
|
topic (falling back to ``rss``).
|
||
|
|
"""
|
||
|
|
|
||
|
|
import xml.etree.ElementTree as ET
|
||
|
|
|
||
|
|
|
||
|
|
def _int(value, default):
|
||
|
|
try:
|
||
|
|
return int(value)
|
||
|
|
except (TypeError, ValueError):
|
||
|
|
return default
|
||
|
|
|
||
|
|
|
||
|
|
def feeds_to_opml(feeds):
|
||
|
|
opml = ET.Element("opml", version="2.0")
|
||
|
|
head = ET.SubElement(opml, "head")
|
||
|
|
ET.SubElement(head, "title").text = "rss-bridge-ntfy feeds"
|
||
|
|
body = ET.SubElement(opml, "body")
|
||
|
|
|
||
|
|
# Group feeds by topic, preserving insertion order.
|
||
|
|
groups = {}
|
||
|
|
for feed in feeds:
|
||
|
|
groups.setdefault(feed.get("topic", "") or "feeds", []).append(feed)
|
||
|
|
|
||
|
|
for topic, items in groups.items():
|
||
|
|
group = ET.SubElement(body, "outline", text=topic, title=topic)
|
||
|
|
for feed in items:
|
||
|
|
label = feed.get("name") or feed.get("url", "")
|
||
|
|
ET.SubElement(group, "outline", **{
|
||
|
|
"type": "rss",
|
||
|
|
"text": label,
|
||
|
|
"title": label,
|
||
|
|
"xmlUrl": feed.get("url", ""),
|
||
|
|
"htmlUrl": feed.get("url", ""),
|
||
|
|
"ntfyTopic": feed.get("topic", "") or "",
|
||
|
|
"ntfyPriority": str(feed.get("priority", 3)),
|
||
|
|
"ntfyIcon": feed.get("icon", "") or "",
|
||
|
|
"ntfyQuietHours": feed.get("quiet_hours", "") or "",
|
||
|
|
"ntfyQuietPriority": str(feed.get("quiet_priority", 1)),
|
||
|
|
"ntfyInclude": feed.get("include_regex", "") or "",
|
||
|
|
"ntfyExclude": feed.get("exclude_regex", "") or "",
|
||
|
|
"ntfyEnabled": "true" if feed.get("enabled", True) else "false",
|
||
|
|
})
|
||
|
|
|
||
|
|
xml = ET.tostring(opml, encoding="unicode")
|
||
|
|
return '<?xml version="1.0" encoding="UTF-8"?>\n' + xml
|
||
|
|
|
||
|
|
|
||
|
|
def opml_to_feeds(xml_str):
|
||
|
|
root = ET.fromstring(xml_str)
|
||
|
|
body = root.find("body")
|
||
|
|
if body is None:
|
||
|
|
return []
|
||
|
|
|
||
|
|
feeds = []
|
||
|
|
|
||
|
|
def walk(node, parent_topic):
|
||
|
|
for outline in node.findall("outline"):
|
||
|
|
xml_url = outline.get("xmlUrl") or outline.get("xmlurl")
|
||
|
|
text = outline.get("text") or outline.get("title") or ""
|
||
|
|
if xml_url:
|
||
|
|
topic = outline.get("ntfyTopic") or parent_topic or text or "rss"
|
||
|
|
feeds.append({
|
||
|
|
"name": text or xml_url,
|
||
|
|
"url": xml_url,
|
||
|
|
"topic": topic,
|
||
|
|
"priority": _int(outline.get("ntfyPriority"), 3),
|
||
|
|
"icon": outline.get("ntfyIcon", "") or "",
|
||
|
|
"quiet_hours": outline.get("ntfyQuietHours", "") or "",
|
||
|
|
"quiet_priority": _int(outline.get("ntfyQuietPriority"), 1),
|
||
|
|
"include_regex": outline.get("ntfyInclude", "") or "",
|
||
|
|
"exclude_regex": outline.get("ntfyExclude", "") or "",
|
||
|
|
"enabled": (outline.get("ntfyEnabled", "true").lower() != "false"),
|
||
|
|
})
|
||
|
|
# A container outline (no xmlUrl) defines the topic for its children.
|
||
|
|
child_topic = text if (not xml_url and text) else parent_topic
|
||
|
|
walk(outline, child_topic)
|
||
|
|
|
||
|
|
walk(body, "")
|
||
|
|
return feeds
|