"""Portable import/export of feeds, accounts, and echoes. The export is a versioned JSON document, not a SQL dump: a single-mode SQLite instance and the hosted Postgres service speak the same format, so migration runs in both directions. Credentials are included on purpose — an export that omitted access tokens would be useless for moving an account — so the routes that produce/consume this document must gate on authentication and warn the operator that the file contains secrets. Record identity on import is by natural key (feed url; account email / handle / uid / homeserver+room / webhook url / instance+username), so re-importing the same document is idempotent rather than duplicating. ``echoes`` reference feeds and accounts by their source ``id``; import rebuilds ``{old_id: new_id}`` maps and rewrites those references so an imported echo points at the freshly inserted feed/account, never at a stale id. Echoes are themselves deduplicated on ``(feed_id, destination_type, destination_id)``. Quotas are enforced on import in multi mode only (``settings.MULTI``): the number of *new* rows an import would add — after dedup against what already exists — is checked against the plan's ``max_feeds`` / ``max_destinations`` caps, and ``poll_interval`` / ``drip_limit`` are clamped to the plan's floor and ceiling. Single mode never consults any of this. """ from __future__ import annotations from datetime import datetime, timezone import plans import settings from _version import __version__ as APP_VERSION from security import decrypt_secret, encrypt_secret, hash_secret from webhook import dump_headers, normalize_body_template as webhook_normalize_body_template from github import ( SAMPLE_ITEM, normalize_token as github_normalize_token, parse_repo as github_parse_repo, render_path as github_render_path, ) FORMAT = "feedecho-export" VERSION = 1 # (export section key, table, insert columns, natural-key columns for dedup). # Column order is the insert order; `id` and `user_id` are appended by the # importer, and `created_at` is deliberately dropped so the target instance # stamps its own. _ACCOUNTS = [ ("mastodon", "accounts", ["name", "username", "instance", "access_token"], ("instance", "username")), ("email", "email_accounts", ["name", "email"], ("email",)), ("bluesky", "bluesky_accounts", [ "name", "handle", "app_password", "did", "pds", "access_jwt", "refresh_jwt", "session_expires_at", ], ("handle",)), ("microblog", "microblog_accounts", ["name", "uid", "token"], ("uid",)), ("matrix", "matrix_accounts", [ "name", "homeserver", "base_url", "access_token", "matrix_user_id", "room_id", "room_alias", ], ("homeserver", "room_id")), ("discord", "discord_accounts", ["name", "webhook_url", "webhook_url_hash", "channel_id"], ("webhook_url_hash",)), ("telegram", "telegram_accounts", ["name", "bot_token", "chat_id"], ("chat_id",)), ("webhook", "webhook_accounts", ["name", "url", "headers", "body_template"], ("url",)), ("github", "github_accounts", [ "name", "repo", "branch", "token", "committer_name", "committer_email", ], ("repo", "branch")), ] ACCOUNT_TYPES = tuple(section for section, *_ in _ACCOUNTS) ACCOUNT_TABLES = {section: table for section, table, _, _ in _ACCOUNTS} _KEY_COLS = {section: keys for section, _, _, keys in _ACCOUNTS} # Columns that hold third-party credentials and are encrypted at rest in multi # mode. Export decrypts them (the export document carries plaintext, matching # the "credentials included by design" contract); import re-encrypts them. _CREDENTIAL_COLS = { "mastodon": {"access_token"}, "bluesky": {"app_password", "access_jwt", "refresh_jwt"}, "microblog": {"token"}, "matrix": {"access_token"}, "discord": {"webhook_url"}, "telegram": {"bot_token"}, "github": {"token"}, } # Required columns per account section: NOT NULL in the schema (database.py) # with no usable code-level default, so a missing/blank/wrong-typed value # would otherwise bind straight into the INSERT and raise a bare # IntegrityError. (`username` for mastodon is NOT NULL-with-a-default and is # additionally back-filled from `name` in `_normalize_account`, so it's # deliberately not required here.) _REQUIRED_ACCOUNT_FIELDS = { "mastodon": ("name", "instance", "access_token"), "email": ("name", "email"), "bluesky": ("name", "handle", "app_password"), "microblog": ("name", "uid", "token"), "matrix": ("name", "homeserver", "access_token", "room_id"), "discord": ("name", "webhook_url"), "telegram": ("name", "bot_token", "chat_id"), "webhook": ("name", "url"), "github": ("name", "repo", "token"), } # The one credential a user can CLEAR without deleting the account row: the # github connect form's `{}` sentinel stores `token = ''`. Such an account can # neither deliver nor round-trip (import rejects a blank required token), so # exporting it would make the whole document unimportable. build_export omits # the account and its echoes (README documents the omission); import_data # skips such an account with a counter. Other destination types keep the # long-standing "missing required field raises ExportError" contract. _CLEARABLE_CREDENTIALS = {"github": ("token",)} _FEED_COLS = ["name", "url", "feed_type", "poll_interval", "last_item_id", "paused", "read_enabled"] _ECHO_COLS = [ "feed_id", "destination_type", "destination_id", "template", "visibility", "enabled", "filter_keywords", "filter_mode", "content_warning", "attach_image", "render_html", "use_markdown", "delivery_mode", "drip_limit", "github_path", "github_commit", ] _VALID_VISIBILITY = ("public", "unlisted", "private", "direct") _VALID_FILTER_MODES = ("exclude", "include") _VALID_DELIVERY_MODES = ("instant", "digest", "queue", "review") class ExportError(ValueError): """The import document is malformed or would exceed plan limits.""" def _now_iso() -> str: return datetime.now(timezone.utc).isoformat() def _jsonable(value): """Normalize a DB value to a JSON-serializable Python value. PG reads TIMESTAMP columns back as ``datetime``; sqlite as ``str``. ``json.dumps`` cannot serialize a ``datetime``, so normalize to the same ``YYYY-MM-DD HH:MM:SS`` UTC string the app stores everywhere else. """ if value is None: return None if isinstance(value, datetime): return value.strftime("%Y-%m-%d %H:%M:%S") return value def _rows_to_dicts(rows) -> list[dict]: return [{k: _jsonable(v) for k, v in dict(r).items()} for r in rows] def build_export(db, uid: int) -> dict: """Read one user's feeds, accounts and echoes into the export document.""" feeds = _rows_to_dicts(db.execute( f"SELECT id, {', '.join(_FEED_COLS)} FROM feeds" " WHERE user_id = ? AND deleted_at IS NULL ORDER BY id", (uid,), ).fetchall()) accounts = {} skipped_destinations: dict[str, set] = {} for section, table, cols, _ in _ACCOUNTS: rows = _rows_to_dicts(db.execute( f"SELECT id, {', '.join(cols)} FROM {table}" " WHERE user_id = ? ORDER BY id", (uid,), ).fetchall()) kept = [] for row in rows: for col in _CREDENTIAL_COLS.get(section, frozenset()): if row.get(col): row[col] = decrypt_secret(row[col]) if any(not row.get(col) for col in _CLEARABLE_CREDENTIALS.get(section, ())): # Cleared credential (the `{}` sentinel stores ''): the # importer would reject this row, so omit the account and # every echo aimed at it rather than emit a document that # cannot be imported at all. skipped_destinations.setdefault(section, set()).add(row["id"]) continue kept.append(row) accounts[section] = kept # Echoes reference feeds by feed_id; only emit echoes whose feed survives # the export (a soft-deleted feed is not exported, and an echo pointing at # it would otherwise import with a dangling feed_id). Echoes aimed at a # skipped account are dropped for the same reason. echoes = _rows_to_dicts(db.execute( f"SELECT id, {', '.join(_ECHO_COLS)} FROM echoes" " WHERE user_id = ? AND deleted_at IS NULL" " AND feed_id IN (SELECT id FROM feeds WHERE user_id = ? AND deleted_at IS NULL)" " ORDER BY id", (uid, uid), ).fetchall()) echoes = [ echo for echo in echoes if echo.get("destination_id") not in skipped_destinations.get( echo.get("destination_type") or "", () ) ] return { "format": FORMAT, "version": VERSION, "exported_at": _now_iso(), "app_version": APP_VERSION, "feeds": feeds, "accounts": accounts, "echoes": echoes, } def _validate_payload(payload) -> dict: """Validate the document envelope and normalize its sections. Returns a dict with ``feeds``/``echoes`` as lists and ``accounts`` as a dict keyed by account type whose values are always lists. Raises ``ExportError`` (never a bare KeyError/TypeError) on anything malformed, so the import route renders a clean error instead of a 500. """ if not isinstance(payload, dict): raise ExportError("Import file is not a valid FeedEcho export.") if payload.get("format") != FORMAT: raise ExportError("This file is not a FeedEcho export.") version = payload.get("version") if version != VERSION: if isinstance(version, int) and version > VERSION: raise ExportError( f"This export was created by a newer FeedEcho (version {version}). " "Upgrade FeedEcho to import it." ) raise ExportError(f"Unsupported export version: {version!r}.") feeds = payload.get("feeds") or [] accounts_raw = payload.get("accounts") or {} echoes = payload.get("echoes") or [] if ( not isinstance(feeds, list) or not isinstance(accounts_raw, dict) or not isinstance(echoes, list) ): raise ExportError("Import file has an unexpected structure.") for feed in feeds: if not isinstance(feed, dict) or "id" not in feed: raise ExportError("Feed record is malformed (missing id).") accounts: dict[str, list] = {} for section in ACCOUNT_TYPES: section_val = accounts_raw.get(section) or [] if not isinstance(section_val, list): raise ExportError("Import file has an unexpected structure.") for account in section_val: if not isinstance(account, dict) or "id" not in account: raise ExportError("Account record is malformed (missing id).") accounts[section] = section_val for echo in echoes: if not isinstance(echo, dict): raise ExportError("Import file has an unexpected structure.") return {"feeds": feeds, "accounts": accounts, "echoes": echoes} def _try_int(value): """``int(value)`` or None — never raises.""" try: return int(value) except (TypeError, ValueError): return None def _record_id(record: dict, label: str) -> int: value = _try_int(record.get("id")) if value is None: raise ExportError(f"{label} record is malformed (invalid id).") return value def _user_plan(db, uid: int) -> str: row = db.execute("SELECT plan FROM users WHERE id = ?", (uid,)).fetchone() return (row["plan"] if row else None) or "trial" def _account_key(section: str, account: dict) -> tuple: """The natural-key values for an account, for dedup lookups. Mastodon's stored ``username`` mirrors the create path (``username or name``), so an export that somehow carries an empty username still dedups by the display name. """ values = [] for col in _KEY_COLS[section]: value = account.get(col) if col == "username" and not value: value = account.get("name") values.append(str(value or "").strip()) return tuple(values) def _normalize_account(section: str, account: dict) -> None: """Normalize an imported account dict in place before dedup/insert. Keeps the dedup natural key and the stored row in agreement: string fields are stripped (so ``url`` with surrounding whitespace still matches an existing row), and Mastodon's ``username`` falls back to ``name`` the same way the create handler does. """ for key, value in list(account.items()): if isinstance(value, str): account[key] = value.strip() if section == "mastodon" and not account.get("username"): account["username"] = account.get("name") or "" if section == "discord": # The natural key is the deterministic digest of the (plaintext) URL, # which is stored encrypted at rest. Always recompute from whatever # webhook_url is present -- never trust a webhook_url_hash carried in # the import payload, forged or merely stale -- and clear it outright # when there's no URL to hash from (webhook_url is required below, # so that account is rejected rather than inserted with a hash that # doesn't correspond to any URL). webhook_url = account.get("webhook_url") account["webhook_url_hash"] = ( hash_secret(str(webhook_url)) if webhook_url else "" ) if section == "matrix" and not isinstance(account.get("base_url"), str): # matrix_accounts.base_url is NOT NULL DEFAULT ''. SQL's DEFAULT only # fires when a column is omitted from the INSERT entirely, not when # an explicit NULL is bound to it (which is what `account.get(...)` # returning None would otherwise do), so a missing/non-string value # must be coerced here. account["base_url"] = str(account.get("base_url") or "") if section == "webhook": # webhook_accounts.headers is NOT NULL DEFAULT '{}' and is stored as # serialized JSON text, not a raw dict -- same DEFAULT-vs-explicit- # NULL reasoning as base_url above, plus a type fixup so a document # carrying a raw JSON object (rather than the string the export # itself always produces) still serializes cleanly instead of # binding a dict into a TEXT column. headers = account.get("headers") if isinstance(headers, dict): account["headers"] = dump_headers(headers) elif headers is None: account["headers"] = "{}" # body_template is NOT NULL DEFAULT '' — same explicit-NULL coercion # as base_url/headers above. A legacy export without the column # simply has no key, and .get() yields None here. body_template = account.get("body_template") if not isinstance(body_template, str): account["body_template"] = "" if section == "github": # branch/committer_* are NOT NULL-with-defaults — same explicit-NULL # coercion as base_url above. branch is stripped and falls back to # the schema default's intent when blank: an imported " main " would # otherwise be stored verbatim and every delivery would 404. branch = account.get("branch") account["branch"] = ( branch.strip() if isinstance(branch, str) else "" ) or "main" if not isinstance(account.get("committer_name"), str): account["committer_name"] = "" if not isinstance(account.get("committer_email"), str): account["committer_email"] = "" # Normalize the repo to lowercase owner/repo (accepts a github.com # URL) so the dedup natural key agrees with the create route. try: account["repo"] = github_parse_repo(account.get("repo") or "") except ValueError as e: raise ExportError(f"github account has an invalid 'repo': {e}") from e def _validate_account(section: str, account: dict, record_id) -> None: """Ensure an account's required NOT-NULL columns are present and typed. Mirrors the feed-URL check in ``import_data``: raises ``ExportError`` identifying the offending account/field instead of letting a missing, blank, or wrong-typed value (e.g. a dict where a string is expected) reach the ``INSERT`` and surface as a bare IntegrityError -- the module docstring's contract is that malformed input always raises ``ExportError``, never a bare DB exception. """ label = f"{section} account (id {record_id!r})" for col in _REQUIRED_ACCOUNT_FIELDS[section]: value = account.get(col) if not isinstance(value, str) or not value: raise ExportError(f"{label} is missing a required field: {col!r}.") if section == "webhook": headers = account.get("headers") if not isinstance(headers, str): raise ExportError(f"{label} has an invalid 'headers' value.") body_template = account.get("body_template") if not isinstance(body_template, str) or not body_template: # Missing key (legacy export), None, or blank: default payload. account["body_template"] = "" else: # Store the NORMALIZED form back: the {} clear sentinel must not # persist as a literal template (it would dispatch empty objects # instead of the default payload), and this validates real # templates the same way the connect route does. try: account["body_template"] = webhook_normalize_body_template(body_template) except ValueError as e: raise ExportError(f"{label} has an invalid 'body_template': {e}") from e if section == "github": # Shape-check the token like the connect route does (length, no # whitespace) — no live call at import time. try: account["token"] = github_normalize_token(account.get("token") or "") except ValueError as e: raise ExportError(f"{label} has an invalid 'token': {e}") from e def _existing_account_id(db, uid: int, section: str, account: dict): table = ACCOUNT_TABLES[section] key_cols = _KEY_COLS[section] key_vals = _account_key(section, account) clause = " AND ".join(f"{c} = ?" for c in key_cols) return db.execute( f"SELECT id FROM {table} WHERE user_id = ? AND {clause}", (uid, *key_vals), ).fetchone() def _destination_count(db, uid: int) -> int: return sum( db.execute( f"SELECT COUNT(*) AS c FROM {table} WHERE user_id = ?", (uid,) ).fetchone()["c"] for table in ACCOUNT_TABLES.values() ) def _enforce_quotas(db, uid: int, new_feeds: int, new_destinations: int) -> None: """Reject the import up front if it would exceed the plan's caps. Counts NEW rows only: re-importing data that already exists never trips the cap (the same "cap counts new rows" rule the create handlers use). """ plan = _user_plan(db, uid) feed_cap = plans.limit_for(plan, "max_feeds") if feed_cap and new_feeds: current = db.execute( "SELECT COUNT(*) AS c FROM feeds WHERE user_id = ? AND deleted_at IS NULL", (uid,), ).fetchone()["c"] if current + new_feeds > feed_cap: raise ExportError( f"Import would add {new_feeds} feed(s), exceeding your plan's " f"{feed_cap}-feed limit ({current} already present). Upgrade or " "remove feeds to import them all." ) dest_cap = plans.limit_for(plan, "max_destinations") if dest_cap and new_destinations: current = _destination_count(db, uid) if current + new_destinations > dest_cap: raise ExportError( f"Import would add {new_destinations} connected account(s), " f"exceeding your plan's {dest_cap}-account limit ({current} already " "present). Upgrade or disconnect accounts to import them all." ) def _clamp_poll(db, uid: int, poll_interval) -> int: # `or 15` would treat an explicitly-parsed 0 as falsy and silently # substitute the default instead of clamping it to the floor below, so # the "missing/unparseable" fallback must check for None explicitly. parsed = _try_int(poll_interval) poll = max(1, min(parsed if parsed is not None else 15, 1440)) if settings.MULTI: poll = plans.clamp_poll_interval(poll, _user_plan(db, uid)) return poll def _clamp_drip(db, uid: int, drip_limit) -> int: drip = max(0, min(_try_int(drip_limit) or 0, 1000)) if settings.MULTI: drip = plans.clamp_drip_limit(drip, _user_plan(db, uid)) return drip def _existing_feed_id(db, uid: int, url: str): """The id of the user's existing active feed for ``url``, if any. Mirrors ``_existing_account_id``: shared by the dedup pre-check and the race fallback after an ``ON CONFLICT DO NOTHING`` insert. """ return db.execute( "SELECT id FROM feeds WHERE user_id = ? AND url = ? AND deleted_at IS NULL", (uid, url), ).fetchone() def import_data(db, uid: int, payload: dict) -> dict: """Import an export document for one user, returning a summary. Raises ``ExportError`` on a malformed document or a quota overrun, before any row is written, so the caller's transaction rolls back cleanly. """ doc = _validate_payload(payload) # ── Dedup + quota pre-check (read-only) ───────────────────────────────── # First occurrence of each natural key wins; later occurrences (within the # same document) map to the same new id, so a file that itself contains a # duplicate key can never split an echo across two rows. feed_map: dict[int, int] = {} first_feed: dict[str, dict] = {} feed_oldids: dict[str, list[int]] = {} new_feed_urls: list[str] = [] for feed in doc["feeds"]: old_id = _record_id(feed, "Feed") url = str(feed.get("url") or "").strip() if not url: raise ExportError("Feed is missing a URL.") existing = _existing_feed_id(db, uid, url) if existing: feed_map[old_id] = existing["id"] continue if url not in first_feed: first_feed[url] = feed feed_oldids[url] = [] new_feed_urls.append(url) feed_oldids[url].append(old_id) account_maps: dict[str, dict[int, int]] = {s: {} for s in ACCOUNT_TYPES} first_account: dict[str, dict] = {s: {} for s in ACCOUNT_TYPES} account_oldids: dict[str, dict] = {s: {} for s in ACCOUNT_TYPES} new_account_keys: dict[str, list] = {s: [] for s in ACCOUNT_TYPES} skipped_accounts_by_section: dict[str, int] = {s: 0 for s in ACCOUNT_TYPES} for section in ACCOUNT_TYPES: for account in doc["accounts"][section]: old_id = _record_id(account, "Account") if any( not str(account.get(col) or "").strip() for col in _CLEARABLE_CREDENTIALS.get(section, ()) ): # A cleared credential (the `{}` sentinel stores '') cannot # deliver and cannot round-trip; skip the account, and its # echoes below, with a counter rather than abort the import. skipped_accounts_by_section[section] += 1 continue _normalize_account(section, account) _validate_account(section, account, old_id) existing = _existing_account_id(db, uid, section, account) if existing: account_maps[section][old_id] = existing["id"] continue key = _account_key(section, account) if key not in first_account[section]: first_account[section][key] = account account_oldids[section][key] = [] new_account_keys[section].append(key) account_oldids[section][key].append(old_id) if settings.MULTI: _enforce_quotas( db, uid, len(new_feed_urls), sum(len(v) for v in new_account_keys.values()), ) # The reader is a paid capability in multi mode, so a feed's read_enabled # flag must not be imported by a plan that doesn't include it (single mode # is always allowed). Same clamp-on-import philosophy as poll/drip below. reader_allowed = not settings.MULTI or plans.reader_enabled(_user_plan(db, uid)) # ── Insert feeds ──────────────────────────────────────────────────────── for url in new_feed_urls: feed = first_feed[url] poll = _clamp_poll(db, uid, feed.get("poll_interval")) row = db.execute( "INSERT INTO feeds (name, url, feed_type, poll_interval, last_item_id," " paused, read_enabled, user_id)" " VALUES (?, ?, ?, ?, ?, ?, ?, ?)" " ON CONFLICT(user_id, url) WHERE deleted_at IS NULL DO NOTHING" " RETURNING id", ( str(feed.get("name") or "").strip(), url, str(feed.get("feed_type") or "rss"), poll, feed.get("last_item_id"), 1 if feed.get("paused") else 0, 1 if (feed.get("read_enabled") and reader_allowed) else 0, uid, ), ).fetchone() if row is None: # A concurrent request (another import, or a manual add) inserted # the same user+url between our SELECT above and this INSERT: the # unique index rejected the duplicate, so map onto the survivor # instead of raising a bare IntegrityError. row = _existing_feed_id(db, uid, url) for old_id in feed_oldids[url]: feed_map[old_id] = row["id"] # ── Insert accounts ───────────────────────────────────────────────────── for section, table, cols, _ in _ACCOUNTS: for key in new_account_keys[section]: account = first_account[section][key] creds = _CREDENTIAL_COLS.get(section, frozenset()) # Encrypt credentials at rest (no-op in single mode) without # mutating the caller's payload, so re-importing the same document # stays idempotent. values = [ encrypt_secret(account[col]) if col in creds and account.get(col) else account.get(col) for col in cols ] placeholders = ", ".join("?" for _ in cols) column_list = ", ".join(cols) key_cols = _KEY_COLS[section] conflict_target = ", ".join(key_cols) row = db.execute( f"INSERT INTO {table} ({column_list}, user_id)" f" VALUES ({placeholders}, ?)" f" ON CONFLICT(user_id, {conflict_target}) DO NOTHING" f" RETURNING id", (*values, uid), ).fetchone() if row is None: # A concurrent request inserted the same natural key between # our SELECT above and this INSERT: map onto the survivor # instead of raising a bare IntegrityError. clause = " AND ".join(f"{c} = ?" for c in key_cols) row = db.execute( f"SELECT id FROM {table} WHERE user_id = ? AND {clause}", (uid, *key), ).fetchone() for old_id in account_oldids[section][key]: account_maps[section][old_id] = row["id"] # ── Insert echoes (dedup on feed+destination) ─────────────────────────── added_echoes = 0 existing_echoes = 0 skipped_echoes = 0 for echo in doc["echoes"]: dest_type = echo.get("destination_type") if dest_type not in ACCOUNT_TYPES: skipped_echoes += 1 continue feed_old = _try_int(echo.get("feed_id")) dest_old = _try_int(echo.get("destination_id")) if ( feed_old is None or dest_old is None or feed_old not in feed_map or dest_old not in account_maps[dest_type] ): skipped_echoes += 1 continue new_feed = feed_map[feed_old] new_dest = account_maps[dest_type][dest_old] existing = db.execute( "SELECT id FROM echoes WHERE user_id = ? AND feed_id = ?" " AND destination_type = ? AND destination_id = ? AND deleted_at IS NULL", (uid, new_feed, dest_type, new_dest), ).fetchone() if existing: existing_echoes += 1 continue drip = _clamp_drip(db, uid, echo.get("drip_limit")) visibility = echo.get("visibility") or "public" if visibility not in _VALID_VISIBILITY: visibility = "public" filter_mode = echo.get("filter_mode") or "exclude" if filter_mode not in _VALID_FILTER_MODES: filter_mode = "exclude" delivery_mode = echo.get("delivery_mode") or "instant" if delivery_mode not in _VALID_DELIVERY_MODES: delivery_mode = "instant" # github_path/github_commit ride along only for github echoes; any # other destination type stores '' so a stale path can never leak # onto a different destination (same rule as the edit route). github_path = "" github_commit = "" if dest_type == "github": candidate = str(echo.get("github_path") or "").strip() if not candidate: # A github echo without a path would permanently fail on the # first item; skip it rather than import a known-bad row. skipped_echoes += 1 continue try: github_render_path(candidate, SAMPLE_ITEM) except Exception: skipped_echoes += 1 continue github_path = candidate github_commit = str(echo.get("github_commit") or "").strip() db.execute( "INSERT INTO echoes (feed_id, destination_type, destination_id, template," " visibility, enabled, filter_keywords, filter_mode, content_warning," " attach_image, render_html, use_markdown, delivery_mode, drip_limit," " github_path, github_commit, user_id)" " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ( new_feed, dest_type, new_dest, str(echo.get("template") or "{{ title }} {{ link }}"), visibility, 1 if echo.get("enabled", 1) else 0, str(echo.get("filter_keywords") or ""), filter_mode, str(echo.get("content_warning") or ""), 1 if echo.get("attach_image") else 0, # Clamp like the connect route: instant email/Matrix echoes. 1 if echo.get("render_html") and dest_type in ("email", "matrix") and delivery_mode == "instant" else 0, 1 if echo.get("use_markdown") and dest_type == "mastodon" else 0, delivery_mode, drip, github_path, github_commit, uid, ), ) added_echoes += 1 return { "added_feeds": len(new_feed_urls), "added_accounts": sum(len(v) for v in new_account_keys.values()), "added_echoes": added_echoes, "existing_feeds": len(doc["feeds"]) - len(new_feed_urls), "existing_accounts": sum( len(doc["accounts"][s]) - len(new_account_keys[s]) - skipped_accounts_by_section[s] for s in ACCOUNT_TYPES ), "existing_echoes": existing_echoes, "skipped_echoes": skipped_echoes, "skipped_accounts": sum(skipped_accounts_by_section.values()), }