feat(v5.10.0): v5.4.0 + v5.5.0 features — backlinks, page/collection duplication, trash purge, version history, markdown import, embed/bookmark/video/audio blocks, cover & icon, OG metadata
- Migration v7: page_versions table + pages.cover_url/page_icon columns - Trash service (purge_expired 30-day) + daily scheduler - Board API: duplicate page, backlinks, versions (snapshot/list/restore), cover/icon upload, markdown/file import - Collections API: duplicate collection (deep copy properties/views/pages/data sources) - Export service: render embed/bookmark/video/audio in markdown/HTML/PDF/public - Public page renderer: cover image + icon - Editor: slash commands for media blocks, lightbox, version history UI, backlinks panel, cover/icon picker, import modal - OG metadata endpoint for bookmark cards - 386 tests passing
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
"""FlowDeck — Media embeds (v5.5.0): provider detection + iframe rewriting.
|
||||
|
||||
Maps a raw http(s) URL to a provider-specific embed URL so that one generic
|
||||
``embed`` block can render YouTube, Vimeo, Figma, Google Maps, Loom,
|
||||
CodePen, Miro, Spotify, SoundCloud, Twitch, X/Twitter, Pinterest, Office
|
||||
docs… exactly like Notion's universal embed.
|
||||
|
||||
Unknown/showable URLs (PDF, images, direct video/audio files, plain http)
|
||||
fall back to a plain iframe so the link is still visible inline.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
_PROVIDERS = (
|
||||
# (host pattern, extra path check, to_embed(url, path, query) -> str | None)
|
||||
)
|
||||
|
||||
|
||||
def _q(params, key):
|
||||
vals = params.get(key)
|
||||
return vals[0] if vals else ""
|
||||
|
||||
|
||||
def _embed_youtube(url: str, path: str, params) -> str | None:
|
||||
m = re.search(r"((?:v|shorts|embed)/|be/)([A-Za-z0-9_-]{6,20})", path)
|
||||
vid = m.group(2) if m else _q(params, "v")
|
||||
if not vid:
|
||||
return None
|
||||
start = _q(params, "t") or _q(params, "start")
|
||||
frag = f"?start={start}" if start else ""
|
||||
return f"https://www.youtube.com/embed/{vid}{frag}"
|
||||
|
||||
|
||||
def _embed_vimeo(url: str, path: str, params) -> str | None:
|
||||
m = re.search(r"/(\d{6,12})", path)
|
||||
if not m:
|
||||
return None
|
||||
return f"https://player.vimeo.com/video/{m.group(1)}"
|
||||
|
||||
|
||||
def _embed_loom(url: str, path: str, params) -> str | None:
|
||||
m = re.search(r"/(embed/)?([0-9a-f]{32})", path)
|
||||
if not m:
|
||||
return None
|
||||
return f"https://www.loom.com/embed/{m.group(2)}"
|
||||
|
||||
|
||||
def _embed_figma(url: str, path: str, params) -> str | None:
|
||||
if "figma.com/file/" not in url and "figma.com/proto/" not in url:
|
||||
return None
|
||||
m = re.search(r"(/[^/]+/[^?]+)", url.split("?", 1)[0])
|
||||
if not m:
|
||||
return None
|
||||
return "https://www.figma.com/embed?embed_host=flowdeck&url=" + url.split("?", 1)[0]
|
||||
|
||||
|
||||
def _embed_map(url: str, path: str, params) -> str | None:
|
||||
if "google.com/maps" not in url and "maps.app.goo.gl" not in url:
|
||||
return None
|
||||
return f"https://www.google.com/maps?output=embed&q={url}"
|
||||
|
||||
|
||||
def _embed_codepen(url: str, path: str, params) -> str | None:
|
||||
m = re.search(r"codepen\.io/([^/]+)/pen/([^/?#]+)", url)
|
||||
if not m:
|
||||
return None
|
||||
return f"https://codepen.io/{m.group(1)}/embed/{m.group(2)}?default-tab=result"
|
||||
|
||||
|
||||
def _embed_miro(url: str, path: str, params) -> str | None:
|
||||
m = re.search(r"miro\.com/app/board/([^/?#]+)", url)
|
||||
if not m:
|
||||
return None
|
||||
return f"https://miro.com/app/live-embed/{m.group(1)}"
|
||||
|
||||
|
||||
def _embed_spotify(url: str, path: str, params) -> str | None:
|
||||
m = re.search(r"/(track|playlist|album|episode|show|artist)/([A-Za-z0-9]+)", url)
|
||||
if not m:
|
||||
return None
|
||||
return f"https://open.spotify.com/embed/{m.group(1)}/{m.group(2)}"
|
||||
|
||||
|
||||
def _embed_soundcloud(url: str, path: str, params) -> str | None:
|
||||
m = re.search(r"(?:soundcloud\.com/[^/]+/[^/?#]+)", url)
|
||||
if not m:
|
||||
return None
|
||||
return f"https://w.soundcloud.com/player/?url={url}&color=%2300aaff"
|
||||
|
||||
|
||||
def _embed_twitch(url: str, path: str, params) -> str | None:
|
||||
if "twitch.tv" not in url:
|
||||
return None
|
||||
m = re.search(r"twitch\.tv/([^/?#]+)", url)
|
||||
if not m:
|
||||
return None
|
||||
return f"https://player.twitch.tv/?channel={m.group(1)}&parent="
|
||||
|
||||
|
||||
def _embed_twitter(url: str, path: str, params) -> str | None:
|
||||
if "twitter.com" not in url and "x.com" not in url:
|
||||
return None
|
||||
m = re.search(r"/([^/?#]+)/status/(\d+)", url)
|
||||
if not m:
|
||||
return f"https://platform.twitter.com/embed/Tweet.html?url={url}"
|
||||
return f"https://platform.twitter.com/embed/Tweet.html?id={m.group(2)}"
|
||||
|
||||
|
||||
def _embed_pinterest(url: str, path: str, params) -> str | None:
|
||||
if "pinterest" not in url:
|
||||
return None
|
||||
return f"https://pinterest.com/pin/embed?url={url}"
|
||||
|
||||
|
||||
def _embed_files(url: str, path: str, params) -> str | None:
|
||||
"""Direct media: PDF/images/videos/audio can live in a plain iframe."""
|
||||
return url
|
||||
|
||||
|
||||
_HANDLERS = (
|
||||
("youtube.com", _embed_youtube),
|
||||
("youtu.be", _embed_youtube),
|
||||
("vimeo.com", _embed_vimeo),
|
||||
("loom.com", _embed_loom),
|
||||
("figma.com", _embed_figma),
|
||||
("google.com/maps", _embed_map),
|
||||
("maps.app.goo.gl", _embed_map),
|
||||
("codepen.io", _embed_codepen),
|
||||
("miro.com", _embed_miro),
|
||||
("open.spotify.com", _embed_spotify),
|
||||
("spotify.com", _embed_spotify),
|
||||
("soundcloud.com", _embed_soundcloud),
|
||||
("twitch.tv", _embed_twitch),
|
||||
("twitter.com", _embed_twitter),
|
||||
("x.com", _embed_twitter),
|
||||
("pinterest", _embed_pinterest),
|
||||
)
|
||||
|
||||
|
||||
def embed_src(url: str) -> str | None:
|
||||
"""Return the embeddable iframe src for a URL, or None if it can't embed."""
|
||||
if not url:
|
||||
return None
|
||||
url = url.strip()
|
||||
u = urlparse(url if url.startswith("http") else "https://" + url)
|
||||
if u.scheme not in ("http", "https"):
|
||||
return None
|
||||
host = (u.hostname or "").lower().replace("www.", "")
|
||||
netloc = (u.netloc or "").lower()
|
||||
params = parse_qs(u.query)
|
||||
# Google Maps share links encode the query in the path (…&q=/maps/…)
|
||||
encoded = url
|
||||
for needle, handler in _HANDLERS:
|
||||
if needle in netloc:
|
||||
return handler(encoded, u.path, params)
|
||||
return _embed_files(encoded, u.path, params)
|
||||
|
||||
|
||||
_IMAGE_EXT = re.compile(r"\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)$", re.I)
|
||||
_PDF_EXT = re.compile(r"\.pdf$", re.I)
|
||||
_VIDEO_EXT = re.compile(r"\.(mp4|webm|ogg|ogv|mov|m4v)$", re.I)
|
||||
_AUDIO_EXT = re.compile(r"\.(mp3|wav|ogg|oga|m4a|flac|aac)$", re.I)
|
||||
|
||||
|
||||
def inline_kind(url: str) -> str | None:
|
||||
"""Best inline renderer for a URL: 'iframe' | 'image' | 'pdf' | 'video' |
|
||||
| 'audio'. Returns None when the URL should open in a new tab."""
|
||||
if not url:
|
||||
return None
|
||||
url = url.strip()
|
||||
path = urlparse(url).path
|
||||
if _IMAGE_EXT.search(path):
|
||||
return "image"
|
||||
if _PDF_EXT.search(path):
|
||||
return "pdf"
|
||||
if _VIDEO_EXT.search(path):
|
||||
return "video"
|
||||
if _AUDIO_EXT.search(path):
|
||||
return "audio"
|
||||
if url.startswith(("http://", "https://")):
|
||||
return "iframe"
|
||||
return None
|
||||
|
||||
|
||||
def embed_html(src: str, *, height: int = 520) -> str:
|
||||
"""A responsive, borderless iframe for a provider embed URL."""
|
||||
return (
|
||||
f'<iframe src="{src}" loading="lazy" '
|
||||
f'style="width:100%;height:{height}px;border:none;border-radius:8px;background:#000;" '
|
||||
f'allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; '
|
||||
f'picture-in-picture" allowfullscreen></iframe>'
|
||||
)
|
||||
+67
-1
@@ -464,6 +464,11 @@ def _page_markdown_source(page: dict) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def markdown_to_blocks(md: str) -> list:
|
||||
"""Public wrapper around the GFM→blocks parser (used by page import)."""
|
||||
return _md_to_blocks(md)
|
||||
|
||||
|
||||
# ═══════════════ Markdown ═══════════════
|
||||
|
||||
def blocks_to_markdown(blocks: list) -> str:
|
||||
@@ -508,6 +513,20 @@ def blocks_to_markdown(blocks: list) -> str:
|
||||
src = b.get("src") or ""
|
||||
alt = (b.get("alt") or "").strip() or "image"
|
||||
out.append(f"")
|
||||
elif t == "video":
|
||||
out.append(f"[Video]({b.get('src') or ''})")
|
||||
elif t == "audio":
|
||||
out.append(f"[Audio]({b.get('src') or ''})")
|
||||
elif t == "bookmark":
|
||||
url = b.get("url") or b.get("src") or ""
|
||||
title = (b.get("title") or "").strip()
|
||||
out.append(f"[{title or url}]({url})" if title else url)
|
||||
elif t == "embed":
|
||||
url = b.get("src") or ""
|
||||
if b.get("embed_type") in ("pdf", "download", None, ""):
|
||||
out.append(f"[{url}]({url})" if url else "[embed]")
|
||||
else:
|
||||
out.append(f"[{url}]({url})" if url else "[embed]")
|
||||
elif t == "table":
|
||||
out.append(_table_to_markdown(b))
|
||||
else:
|
||||
@@ -609,7 +628,54 @@ def blocks_to_html(blocks: list) -> str:
|
||||
elif t == "image":
|
||||
src = b.get("src") or ""
|
||||
alt = _text(b.get("alt"))
|
||||
parts.append(f'<figure><img src="{src}" alt="{alt}"><figcaption>{alt}</figcaption></figure>')
|
||||
parts.append(f'<figure><img src="{src}" alt="{alt}" class="fd-img" data-full="{src}"><figcaption>{alt}</figcaption></figure>')
|
||||
elif t == "video":
|
||||
src = b.get("src") or ""
|
||||
if src:
|
||||
parts.append(f'<video controls preload="metadata" style="max-width:100%;border-radius:8px;"><source src="{src}"></video>')
|
||||
elif t == "audio":
|
||||
src = b.get("src") or ""
|
||||
if src:
|
||||
parts.append(f'<audio controls preload="metadata" style="width:100%;"><source src="{src}"></audio>')
|
||||
elif t == "bookmark":
|
||||
url = b.get("url") or b.get("src") or ""
|
||||
title = _text(b.get("title")) or url
|
||||
desc = _text(b.get("description"))
|
||||
img = b.get("image") or ""
|
||||
site = _text(b.get("site_name")) or ""
|
||||
img_html = f'<img src="{img}" alt="" style="width:120px;height:90px;object-fit:cover;border-radius:8px;flex-shrink:0;">' if img else ""
|
||||
desc_html = f'<div style="font-size:13px;color:#57606a;margin-top:4px;">{desc}</div>' if desc else ""
|
||||
site_html = f'<div style="font-size:11px;color:#8b949e;text-transform:uppercase;letter-spacing:.5px;margin-top:6px;">{site}</div>' if site else ""
|
||||
parts.append(
|
||||
f'<a href="{_text(url)}" target="_blank" rel="noopener noreferrer" style="text-decoration:none;color:inherit;">'
|
||||
f'<div style="display:flex;gap:14px;align-items:center;border:1px solid #d8dee4;border-radius:10px;'
|
||||
f'padding:14px 16px;margin:14px 0;background:#f9fafb;">'
|
||||
f'<div style="flex:1;min-width:0;"><div style="font-weight:600;font-size:15px;">{title}</div>'
|
||||
f'{desc_html}{site_html}</div>{img_html}</div></a>'
|
||||
)
|
||||
elif t == "embed":
|
||||
url = b.get("src") or ""
|
||||
emb = (b.get("embed_type") or "")
|
||||
if emb in ("inline_dbs", "collection"):
|
||||
parts.append(f'<div class="embed-note">[Embedded content]</div>')
|
||||
elif emb == "download":
|
||||
parts.append(f'<a href="{_text(url)}" download>⬇ {_text(b.get("file_name") or "Download")}</a>')
|
||||
elif emb == "pdf" and url:
|
||||
parts.append(f'<iframe src="{_text(url)}" style="width:100%;height:70vh;border:none;border-radius:8px;"></iframe>')
|
||||
elif url:
|
||||
from app.services.embeds import embed_src
|
||||
src = embed_src(url) or url
|
||||
height = 520
|
||||
if b.get("height"):
|
||||
try:
|
||||
height = int(b["height"])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
parts.append(
|
||||
f'<div style="position:relative;width:100%;height:{height}px;border-radius:8px;overflow:hidden;'
|
||||
f'background:#0a0a0a;"><iframe src="{src}" loading="lazy" frameborder="0" '
|
||||
f'style="position:absolute;inset:0;width:100%;height:100%;" allowfullscreen allow="autoplay; encrypted-media; picture-in-picture"></iframe></div>'
|
||||
)
|
||||
elif t == "table":
|
||||
parts.append(_table_to_html(b))
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""FlowDeck — Bookmark cards (v5.5.0): Open Graph metadata via httpx.
|
||||
|
||||
Fetches a URL server-side, extracts OG/Twitter meta tags (title, description,
|
||||
image, site name, favicon) and returns a safe, compact payload used to render
|
||||
Notion-style bookmark cards. Robust to missing tags, non-HTML bodies and
|
||||
slow/unreachable hosts.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_META_RE = re.compile(
|
||||
r'<meta\b[^>]*?(?:content=[\'"]([^\'"]*)[\'"]|property=[\'"]([^\'"]*)[\'"]|name=[\'"]([^\'"]*)[\'"]|content=[\'"]([^\'"]*)[\'"])[^>]*?>',
|
||||
re.I,
|
||||
)
|
||||
|
||||
# Property/name keys we look for, in priority order, mapped to our payload keys.
|
||||
_OG_TITLE = ("og:title", "twitter:title", "title", "og:site_name")
|
||||
_OG_DESC = ("og:description", "twitter:description", "description")
|
||||
_OG_IMG = ("og:image", "twitter:image", "twitter:image:src")
|
||||
_OG_SITE = ("og:site_name", "twitter:site", "application-name")
|
||||
|
||||
|
||||
def _extract_og(html: str) -> dict:
|
||||
text = html[:400_000] # only scan the beginning — that's where <head> lives
|
||||
props: dict[str, str] = {}
|
||||
for m in _META_RE.finditer(text):
|
||||
groups = m.groups()
|
||||
content = ""
|
||||
prop = ""
|
||||
for s in re.findall(r"(?:content|property|name)=[\"']([^\"']*)[\"']", m.group(0)):
|
||||
if prop == "":
|
||||
prop = s
|
||||
else:
|
||||
content = s
|
||||
break
|
||||
if prop:
|
||||
props[prop.lower()] = content
|
||||
return props
|
||||
|
||||
|
||||
def _pick(props: dict, keys: tuple) -> str:
|
||||
for k in keys:
|
||||
v = props.get(k)
|
||||
if v:
|
||||
return v
|
||||
return ""
|
||||
|
||||
|
||||
def _title_of(props: dict, raw_title: str) -> str:
|
||||
t = _pick(props, _OG_TITLE)
|
||||
if t:
|
||||
return t
|
||||
m = re.search(r"<title[^>]*>(.*?)</title>", raw_title[:200_000], re.I | re.S)
|
||||
return (m.group(1).strip() if m else "") or urlparse(props.get("_url", "")).netloc
|
||||
|
||||
|
||||
def _site_name(url: str) -> str:
|
||||
host = urlparse(url).netloc.replace("www.", "")
|
||||
return host.split(".")[0].capitalize() if host else ""
|
||||
|
||||
|
||||
async def fetch_og_metadata(url: str, timeout: float = 6.0) -> dict:
|
||||
"""Fetch ``url`` and return {url, title, description, image, site_name,
|
||||
favicon}. Empty strings are omitted. Never raises for network errors."""
|
||||
src = url.strip()
|
||||
if not src.startswith(("http://", "https://")):
|
||||
src = "https://" + src
|
||||
base = {"url": src, "title": "", "description": "", "image": "", "site_name": "", "favicon": ""}
|
||||
try:
|
||||
import httpx
|
||||
headers = {
|
||||
"User-Agent": "FlowDeck/5.10 bookmark-fetcher (+https://flowdeck.dracodev.net)",
|
||||
"Accept": "text/html,application/xhtml+xml",
|
||||
}
|
||||
async with httpx.AsyncClient(follow_redirects=True, timeout=timeout) as client:
|
||||
resp = await client.get(src, headers=headers)
|
||||
resp.raise_for_status()
|
||||
except Exception as exc: # noqa: BLE001 - network/parse failures are non-fatal
|
||||
logger.debug("og fetch failed for %s: %s", src, exc)
|
||||
base["title"] = urlparse(src).netloc or src
|
||||
base["site_name"] = _site_name(src)
|
||||
return base
|
||||
|
||||
ctype = (resp.headers.get("content-type") or "").lower()
|
||||
if "text/html" not in ctype and "xhtml" not in ctype:
|
||||
base["title"] = urlparse(src).netloc or src
|
||||
base["site_name"] = _site_name(src)
|
||||
return base
|
||||
|
||||
import html as htmlmod
|
||||
|
||||
body = resp.text
|
||||
props = _extract_og(body)
|
||||
props["_url"] = src
|
||||
title = htmlmod.unescape(_title_of(props, body))
|
||||
desc = htmlmod.unescape(_pick(props, _OG_DESC))
|
||||
img = _pick(props, _OG_IMG)
|
||||
site = htmlmod.unescape(_pick(props, _OG_SITE)) or _site_name(src)
|
||||
|
||||
def abs_url(u: str) -> str:
|
||||
if not u:
|
||||
return ""
|
||||
return urljoin(src, htmlmod.unescape(u))
|
||||
|
||||
favicon = ""
|
||||
fm = re.search(r"<link[^>]+rel=[\"'](?:shortcut )?icon[\"'][^>]+href=[\"']([^\"']+)[\"']", body, re.I)
|
||||
if not fm:
|
||||
fm = re.search(r"<link[^>]+href=[\"']([^\"']+)[\"'][^>]+rel=[\"'](?:shortcut )?icon[\"']", body, re.I)
|
||||
if fm:
|
||||
favicon = abs_url(fm.group(1))
|
||||
|
||||
return {
|
||||
"url": src,
|
||||
"title": title.strip()[:200] or urlparse(src).netloc or src,
|
||||
"description": desc.strip()[:400],
|
||||
"image": abs_url(img),
|
||||
"site_name": site.strip()[:100],
|
||||
"favicon": favicon,
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"""FlowDeck — Global trash (v5.4.0): automatic 30-day purge.
|
||||
|
||||
Pages soft-deleted via ``deleted_at`` stay in the trash for the retention
|
||||
window (default 30 days, matching the UI copy in ``trash.html``), then are
|
||||
permanently deleted together with their child pages and orphaned versions.
|
||||
The purge is run from a background scheduler in ``app.main`` so it is fully
|
||||
automatic (no per-request cost).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ISO_RE = re.compile(r"^\d{4}-\d{2}-\d{2}")
|
||||
|
||||
|
||||
def _parse_dt(value: str) -> datetime | None:
|
||||
"""Parse the ISO-ish ``deleted_at`` timestamps (either 'YYYY-MM-DD...' or
|
||||
SQLite 'YYYY-MM-DD HH:MM:SS'). Returns None when unparseable."""
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
s = value[:19].replace("T", " ")
|
||||
dt = datetime.strptime(s, "%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
try:
|
||||
dt = datetime.fromisoformat(value)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
if not ISO_RE.match(value):
|
||||
return None
|
||||
return dt
|
||||
|
||||
|
||||
def purge_expired(days: int = 30) -> dict:
|
||||
"""Permanently delete pages whose ``deleted_at`` is older than ``days``.
|
||||
|
||||
Children are re-parented to their grandparent before deletion in order
|
||||
to avoid silently dropping whole sub-trees; deleted pages are removed
|
||||
together with their ``page_versions`` (FK cascade).
|
||||
|
||||
Returns a summary of what was purged.
|
||||
"""
|
||||
cutoff = datetime.utcnow() - timedelta(days=days)
|
||||
purged: list[int] = []
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, deleted_at FROM pages WHERE deleted_at IS NOT NULL"
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
dt = _parse_dt(row["deleted_at"])
|
||||
if dt is None or dt >= cutoff:
|
||||
continue
|
||||
page_id = row["id"]
|
||||
# Promote direct children to the deleted page's parent so no live
|
||||
# sub-tree is orphaned (matches the permanent-delete semantics).
|
||||
conn.execute(
|
||||
"UPDATE pages SET parent_id=(SELECT parent_id FROM pages WHERE id=?) "
|
||||
"WHERE parent_id=?",
|
||||
(page_id, page_id),
|
||||
)
|
||||
conn.execute("DELETE FROM pages WHERE id=?", (page_id,))
|
||||
purged.append(page_id)
|
||||
conn.commit()
|
||||
if purged:
|
||||
logger.info("Trash purge: %d expired page(s) permanently deleted", len(purged))
|
||||
return {"purged": purged, "count": len(purged)}
|
||||
|
||||
|
||||
async def trash_purge_scheduler(interval_hours: int = 24):
|
||||
"""Background loop purging the trash once a day (idempotent, cheap when
|
||||
there is nothing expired)."""
|
||||
import asyncio
|
||||
|
||||
# Run once shortly after startup, then on the configured interval.
|
||||
await asyncio.sleep(5)
|
||||
while True:
|
||||
try:
|
||||
purge_expired(days=30)
|
||||
except Exception as exc: # pragma: no cover - defensive only
|
||||
logger.warning("Trash purge failed: %s", exc)
|
||||
await asyncio.sleep(interval_hours * 3600)
|
||||
Reference in New Issue
Block a user