From 5baec38c80b8278dd49fe71cfcd42502eb9c0652 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sun, 19 Apr 2026 00:57:24 +0200 Subject: [PATCH] refinement --- .vscode/settings.json | 4 + README.md | 40 +++---- pyproject.toml | 12 +++ src/imwald/app.py | 1 - src/imwald/core/database.py | 32 ++++++ src/imwald/core/md_render.py | 110 +++++++++++++++++++ src/imwald/core/nostr_crypto.py | 34 ++++++ src/imwald/core/nostr_engine.py | 18 ++-- src/imwald/core/nostr_publish.py | 3 +- src/imwald/core/relay_list.py | 125 ++++++++++++++++++++++ src/imwald/core/relay_policy.py | 11 +- src/imwald/ui/assets/vendor/marked.min.js | 6 ++ src/imwald/ui/composer_dialog.py | 26 +++-- src/imwald/ui/feed_page.py | 23 ++-- src/imwald/ui/main_window.py | 45 ++++++-- src/imwald/ui/markdown_editor_widget.py | 57 ++++++++++ src/imwald/ui/notifications_page.py | 11 +- src/imwald/ui/search_page.py | 4 +- tests/test_event_verify.py | 25 +++++ tests/test_md_render.py | 17 +++ tests/test_relay_list.py | 57 ++++++++++ 21 files changed, 602 insertions(+), 59 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 src/imwald/core/md_render.py create mode 100644 src/imwald/core/relay_list.py create mode 100644 src/imwald/ui/assets/vendor/marked.min.js create mode 100644 src/imwald/ui/markdown_editor_widget.py create mode 100644 tests/test_event_verify.py create mode 100644 tests/test_md_render.py create mode 100644 tests/test_relay_list.py diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..c785658 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python", + "python.analysis.extraPaths": ["${workspaceFolder}/src"] +} diff --git a/README.md b/README.md index 65daf90..6e3161a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# Imwald: Linux-native Nostr client +# imwald -Qt (PySide6) + SQLite (WAL) Nostr client for Linux. Relay worker ingests into the local DB; feed, search, notifications, DB admin, and composer are wired in `src/imwald/`. +Linux-focused **Nostr** desktop client: **PySide6** UI, **SQLite** (WAL) as the local store, and a background relay worker that writes events into the same database the UI reads. ## Run (development) @@ -8,22 +8,24 @@ Qt (PySide6) + SQLite (WAL) Nostr client for Linux. Relay worker ingests into th cd imwald python3 -m venv .venv .venv/bin/pip install -e '.[dev]' -PYTHONPATH=src .venv/bin/python -m imwald +.venv/bin/python -m imwald ``` -Optional dev extras are declared in `pyproject.toml` (`pytest`). - -## Core client features - -1. Handles websocket and https relays -2. Integrates all custom relay lists and sensible defaults -3. Easy onboarding for new users -4. Algorithmic feed (no follows, no recommendation lists) -5. File-style/application handling -6. SQL Lite data storage -7. Media caching -8. Voting and reacting on all events -9. Grammar check, translations, and read-alouds for popular world languages -10. Full profile view -11. Discussion/Bulletin board -12. Notifications \ No newline at end of file +The package uses a `src/` layout; **editable install** is enough (no `PYTHONPATH` needed). For Cursor/Pylance, the repo includes `.vscode/settings.json` and `[tool.pyright]` in `pyproject.toml` so `import imwald` resolves. + +## What is implemented today + +- **Relays:** Read/write sets from your latest **kind 10002** (NIP-65); built-in defaults only when you have no usable list. Relay worker restarts when the active account changes. Optional **aggr.nostr.land** thread subscription when `nostr.land` is in your write set. Trending slice relay (nostrarchives) mixed into reads. +- **Ingest:** NIP-01 id check + **BIP-340** signature verification before SQLite upsert; kind **5** tombstones applied on valid deletion events. +- **Feed:** Ranked queue (follows, kind **30000** list authors, scores), per-viewer “seen” tracking, OP body rendered as **Markdown** (offline **marked** via QuickJS, **nh3** sanitize, Python `markdown` fallback). Thread list, search hits, notifications rows, and “your events” use the same pipeline for **plain summaries** (no raw `#` / `**` noise in one-liners). +- **Composer:** Split **Markdown source** + **live preview**; publish to resolved write relays. +- **Accounts:** Onboarding wizard, **nsec** / **ncryptsec** (NIP-49) storage, composer and NIP-09 publish. +- **Other pages:** Search (local `LIKE`), notifications list (DB table + joined event text when present), local **DB admin** (safe SELECT, purge, NIP-09 hook). + +## Dependencies (runtime) + +See `pyproject.toml`: PySide6, cryptography, bech32, coincurve, PyNaCl, websockets, **quickjs-ng**, **nh3**, **markdown** (Markdown rendering is fully local; `marked` is vendored under `src/imwald/ui/assets/vendor/`). + +## Not in scope (yet) + +Server-backed grammar (LanguageTool), translation, read-aloud, rich media gallery, full “profile app” shell, or a separate bulletin-board product surface—the README used to list aspirational items; they are **not** current features unless added in code. diff --git a/pyproject.toml b/pyproject.toml index 53246ec..1225c5d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,9 @@ dependencies = [ "coincurve>=20", "PyNaCl>=1.5", "websockets>=12", + "quickjs-ng>=0.13", + "nh3>=0.2", + "markdown>=3.5", ] [project.optional-dependencies] @@ -29,5 +32,14 @@ where = ["src"] [tool.setuptools.package-dir] "" = "src" +[tool.setuptools.package-data] +imwald = ["ui/assets/vendor/*.js"] + [tool.pytest.ini_options] pythonpath = ["src"] + +# Pylance / basedpyright: resolve ``imwald`` from the src layout (same as pytest). +[tool.pyright] +include = ["src", "tests"] +extraPaths = ["src"] +pythonVersion = "3.11" diff --git a/src/imwald/app.py b/src/imwald/app.py index 84f9423..6e44dd2 100644 --- a/src/imwald/app.py +++ b/src/imwald/app.py @@ -25,7 +25,6 @@ def main() -> None: engine = NostrEngine(db) win = MainWindow(db=db, engine=engine) win.show() - engine.start_relays(list30000_owner=win.list_owner_pubkey_for_relays()) rc = app.exec() engine.stop_relays() db.close() diff --git a/src/imwald/core/database.py b/src/imwald/core/database.py index 5143543..d122cd2 100644 --- a/src/imwald/core/database.py +++ b/src/imwald/core/database.py @@ -275,6 +275,38 @@ class Database: with self.write_lock() as c: c.execute("DELETE FROM events WHERE id=?", (event_id,)) + def get_latest_kind10002_event(self, pubkey: str) -> dict[str, Any] | None: + """Latest non-deleted kind 10002 authored by ``pubkey`` (hex), or None.""" + cur = self.conn().execute( + """ + SELECT id, pubkey, created_at, kind, content, sig, tags_json, raw_json, deleted + FROM events + WHERE deleted=0 AND kind=10002 AND lower(pubkey)=? + ORDER BY created_at DESC LIMIT 1 + """, + (pubkey.lower(),), + ) + row = cur.fetchone() + if not row: + return None + raw = row["raw_json"] + if raw: + try: + ev = json.loads(raw) + if isinstance(ev, dict): + return ev + except json.JSONDecodeError: + pass + return { + "id": row["id"], + "pubkey": row["pubkey"], + "created_at": row["created_at"], + "kind": row["kind"], + "content": row["content"] or "", + "sig": row["sig"], + "tags": json.loads(row["tags_json"] or "[]"), + } + def get_event(self, event_id: str) -> dict[str, Any] | None: cur = self.conn().execute( "SELECT id,pubkey,created_at,kind,content,sig,tags_json,deleted,source_relay FROM events WHERE id=?", diff --git a/src/imwald/core/md_render.py b/src/imwald/core/md_render.py new file mode 100644 index 0000000..c4ef9a9 --- /dev/null +++ b/src/imwald/core/md_render.py @@ -0,0 +1,110 @@ +"""Offline Markdown → HTML using vendored ``marked`` (QuickJS) with ``nh3`` sanitization.""" + +from __future__ import annotations + +import html +import json +import logging +import re +from pathlib import Path + +import nh3 + +log = logging.getLogger(__name__) + +_MARKED_PATH = Path(__file__).resolve().parents[1] / "ui" / "assets" / "vendor" / "marked.min.js" +_qjs_ctx = None +_marked_load_failed = False + + +def _marked_quickjs_ctx(): + """Singleton QuickJS context with ``marked`` loaded, or None if unavailable.""" + global _qjs_ctx, _marked_load_failed + if _marked_load_failed: + return None + if _qjs_ctx is not None: + return _qjs_ctx + if not _MARKED_PATH.is_file(): + log.warning("Vendored marked not found: %s", _MARKED_PATH) + _marked_load_failed = True + return None + try: + import quickjs + except ImportError: + log.info("quickjs-ng not installed; Markdown uses Python fallback renderer") + _marked_load_failed = True + return None + try: + ctx = quickjs.Context() + ctx.eval(_MARKED_PATH.read_text(encoding="utf-8")) + except Exception as e: # noqa: BLE001 + log.warning("Could not initialize marked in QuickJS: %s", e) + _marked_load_failed = True + return None + _qjs_ctx = ctx + return _qjs_ctx + + +def _render_marked_js(md: str) -> str | None: + ctx = _marked_quickjs_ctx() + if ctx is None: + return None + try: + expr = "marked.parse(" + json.dumps(md or "") + ", {gfm: true, breaks: true})" + return str(ctx.eval(expr)) + except Exception as e: # noqa: BLE001 + log.debug("marked.parse failed: %s", e) + return None + + +def _render_markdown_fallback(md: str) -> str: + import markdown + + return markdown.markdown( + md or "", + extensions=["fenced_code", "tables", "nl2br", "sane_lists"], + output_format="html", + ) + + +def markdown_html_fragment(md: str) -> str: + """Sanitized HTML fragment (body inner HTML) for embedding in templates.""" + raw = _render_marked_js(md) + if raw is None: + raw = _render_markdown_fallback(md) + return nh3.clean(raw) + + +def markdown_plain_summary(md: str, *, max_len: int = 100) -> str: + """ + Plain-text one-line preview for list widgets: same pipeline as ``markdown_html_fragment``, + then strip tags and collapse whitespace (no Markdown noise in the UI chrome). + """ + # Cap source length so list views (search, notifications, threads) do not parse huge notes. + src = (md or "")[:1200] + frag = markdown_html_fragment(src) + plain = html.unescape(re.sub(r"<[^>]+>", " ", frag)) + plain = re.sub(r"\s+", " ", plain).strip() + if len(plain) <= max_len: + return plain + return plain[: max_len - 1] + "…" + + +_PREVIEW_CSS = """""" + + +def markdown_html_document(md: str) -> str: + """Full HTML document for ``QTextBrowser`` preview panes.""" + inner = markdown_html_fragment(md) + return ( + "" + f"{_PREVIEW_CSS}{inner}" + ) diff --git a/src/imwald/core/nostr_crypto.py b/src/imwald/core/nostr_crypto.py index bb0f0aa..340bfb1 100644 --- a/src/imwald/core/nostr_crypto.py +++ b/src/imwald/core/nostr_crypto.py @@ -7,6 +7,7 @@ from hashlib import sha256 from typing import Any from coincurve import PrivateKey +from coincurve.keys import PublicKeyXOnly def serialize_event_for_id(pubkey: str, created_at: int, kind: int, tags: list, content: str) -> str: @@ -35,6 +36,39 @@ def sign_event_id(secret: bytes, event_id_hex_str: str) -> str: return sig.hex() +def verify_nostr_event(ev: dict[str, Any]) -> bool: + """NIP-01 id (serialized JSON) + BIP-340 Schnorr signature over the 32-byte event id.""" + required = ("id", "pubkey", "created_at", "kind", "tags", "content", "sig") + if not all(k in ev for k in required): + return False + tags = ev["tags"] + if not isinstance(tags, list): + return False + pk_hex = str(ev["pubkey"]).lower() + if len(pk_hex) != 64 or any(c not in "0123456789abcdef" for c in pk_hex): + return False + sig_hex = str(ev["sig"]).lower() + if len(sig_hex) != 128 or any(c not in "0123456789abcdef" for c in sig_hex): + return False + try: + created_at = int(ev["created_at"]) + kind = int(ev["kind"]) + except (TypeError, ValueError): + return False + content = ev.get("content") + if content is not None and not isinstance(content, str): + return False + content_s = "" if content is None else str(content) + eid = event_id_hex(pk_hex, created_at, kind, tags, content_s) + if eid != str(ev["id"]).lower(): + return False + try: + pk = PublicKeyXOnly(bytes.fromhex(pk_hex)) + return bool(pk.verify(bytes.fromhex(sig_hex), bytes.fromhex(eid))) + except ValueError: + return False + + def build_signed_event( secret: bytes, *, diff --git a/src/imwald/core/nostr_engine.py b/src/imwald/core/nostr_engine.py index 9d8d7d0..36c864e 100644 --- a/src/imwald/core/nostr_engine.py +++ b/src/imwald/core/nostr_engine.py @@ -13,12 +13,14 @@ from PySide6.QtCore import QObject, Signal from imwald.core.accounts_store import StoredAccount, unlock_secret from imwald.core.database import Database -from imwald.core.nostr_crypto import build_signed_event +from imwald.core.nostr_crypto import build_signed_event, verify_nostr_event +from imwald.core.relay_list import resolve_for_account from imwald.core.nostr_publish import publish_to_relays_sync from imwald.core.ranker import Ranker from imwald.core.relay_manager import RelayManager from imwald.core.relay_policy import ( AGGR_THREAD_RELAY, + DEFAULT_READ_RELAYS, DEFAULT_WRITE_RELAYS, WISP_TRENDING_FEED_KINDS, default_feed_read_relays, @@ -48,11 +50,13 @@ class NostrEngine(QObject): self, read_urls: list[str] | None = None, *, + user_write_urls: list[str] | None = None, list30000_owner: str | None = None, ) -> None: if self._thread and self._thread.is_alive(): - return + self.stop_relays() urls = list(read_urls or default_feed_read_relays()) + aggr_writes = set(user_write_urls or list(DEFAULT_WRITE_RELAYS)) k3000_owner = (list30000_owner or "").strip().lower() def runner() -> None: @@ -76,7 +80,7 @@ class NostrEngine(QObject): f"imwald-{abs(hash(u)) % 10**8}", [{"kinds": kinds, "limit": 220}], ) - if use_aggr_for_threads(set(DEFAULT_WRITE_RELAYS)): + if use_aggr_for_threads(aggr_writes): mgr.register(AGGR_THREAD_RELAY) mgr.request_subscribe( AGGR_THREAD_RELAY, @@ -120,6 +124,8 @@ class NostrEngine(QObject): def apply_ingest_to_db(db: Database, ev: dict[str, Any], source_relay: str | None = None) -> None: if not isinstance(ev, dict) or "id" not in ev: return + if not verify_nostr_event(ev): + return if ev.get("kind") == 5: for t in ev.get("tags") or []: if t and t[0] == "e" and len(t) > 1: @@ -147,9 +153,9 @@ class NostrEngine(QObject): ) tags_10002: list[list[str]] = [["client", "imwald"]] for r in DEFAULT_READ_RELAYS: - tags_10002.extend([["r", r], ["read", "true"]]) + tags_10002.append(["r", r, "read"]) for r in DEFAULT_WRITE_RELAYS: - tags_10002.extend([["r", r], ["write", "true"]]) + tags_10002.append(["r", r, "write"]) ev10002 = build_signed_event(sec, created_at=now, kind=10002, tags=tags_10002, content="") tags_10015 = [["client", "imwald"]] for t in interest_tags[:50]: @@ -170,5 +176,5 @@ class NostrEngine(QObject): now = int(time.time()) tags = [["e", target_event_id], ["k", "1"]] ev = build_signed_event(sec, created_at=now, kind=5, tags=tags, content="") - publish_to_relays_sync(list(DEFAULT_WRITE_RELAYS), ev) + publish_to_relays_sync(resolve_for_account(self.db, account.pubkey).write_urls, ev) self.db.upsert_event(ev) diff --git a/src/imwald/core/nostr_publish.py b/src/imwald/core/nostr_publish.py index aa4bd6a..cdd1c28 100644 --- a/src/imwald/core/nostr_publish.py +++ b/src/imwald/core/nostr_publish.py @@ -30,7 +30,8 @@ async def publish_to_relays(urls: list[str], event: dict[str, Any], timeout: flo await ws.send(json.dumps(["EVENT", event])) raw = await asyncio.wait_for(ws.recv(), timeout=timeout) msg = json.loads(raw) - ok = isinstance(msg, list) and len(msg) >= 4 and msg[0] == "OK" and bool(msg[2]) + # NIP-01: ["OK", , , ] + ok = isinstance(msg, list) and len(msg) >= 3 and msg[0] == "OK" and msg[2] is True results[url] = ok except Exception as e: # noqa: BLE001 log.info("publish fail %s: %s", url, e) diff --git a/src/imwald/core/relay_list.py b/src/imwald/core/relay_list.py new file mode 100644 index 0000000..b3cfafe --- /dev/null +++ b/src/imwald/core/relay_list.py @@ -0,0 +1,125 @@ +"""NIP-65 kind 10002 relay lists: parse tags and resolve read/write URLs for an account.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from imwald.core.database import Database +from imwald.core.relay_policy import DEFAULT_READ_RELAYS, DEFAULT_WRITE_RELAYS + +_MAX_RELAYS_PER_BUCKET = 24 + + +def _is_ws_relay_url(url: str) -> bool: + u = url.strip() + return u.startswith("wss://") or u.startswith("ws://") + + +def _dedupe_preserve(urls: list[str]) -> list[str]: + seen: set[str] = set() + out: list[str] = [] + for u in urls: + if u not in seen: + seen.add(u) + out.append(u) + return out + + +def parse_kind10002_tags(tags: Any) -> tuple[list[str], list[str]]: + """ + Parse NIP-65 `r` tags into (read_urls, write_urls). + - ``["r", url]`` or unknown third value → both read and write. + - ``["r", url, "read"|"write"]`` → that direction only. + - Legacy imwald/jumble-style pairs: ``["r", url], ["read","true"]`` / ``["write","true"]``. + """ + if not isinstance(tags, list): + return [], [] + + read: list[str] = [] + write: list[str] = [] + i = 0 + while i < len(tags): + t = tags[i] + i += 1 + if not t or not isinstance(t, list) or len(t) < 2: + continue + if str(t[0]) != "r": + continue + url = str(t[1]).strip() + if not _is_ws_relay_url(url): + continue + + mode = "both" + if len(t) >= 3: + m = str(t[2]).lower() + if m == "read": + mode = "read" + elif m == "write": + mode = "write" + else: + if i < len(tags): + nxt = tags[i] + if nxt and isinstance(nxt, list) and len(nxt) >= 2: + name = str(nxt[0]).lower() + val = str(nxt[1]).lower() + if name == "read" and val in ("true", "1", "yes"): + mode = "read" + i += 1 + elif name == "write" and val in ("true", "1", "yes"): + mode = "write" + i += 1 + + if mode in ("both", "read"): + read.append(url) + if mode in ("both", "write"): + write.append(url) + + return _dedupe_preserve(read[:_MAX_RELAYS_PER_BUCKET]), _dedupe_preserve(write[:_MAX_RELAYS_PER_BUCKET]) + + +@dataclass(frozen=True) +class ResolvedRelays: + """Read/write websocket URLs after NIP-65 + fallbacks for empty halves.""" + + read_urls: list[str] + write_urls: list[str] + had_kind10002: bool + + +def resolve_for_account(db: Database, pubkey: str | None) -> ResolvedRelays: + """ + Load the latest kind 10002 for ``pubkey``. If missing or no usable ``r`` tags, + use default read and write relays. If the list exists but one side is empty, + fill that side from defaults. + """ + if not pubkey or len(pubkey.strip()) != 64: + return ResolvedRelays( + read_urls=list(DEFAULT_READ_RELAYS), + write_urls=list(DEFAULT_WRITE_RELAYS), + had_kind10002=False, + ) + + ev = db.get_latest_kind10002_event(pubkey.strip().lower()) + if not ev: + return ResolvedRelays( + read_urls=list(DEFAULT_READ_RELAYS), + write_urls=list(DEFAULT_WRITE_RELAYS), + had_kind10002=False, + ) + + parsed_read, parsed_write = parse_kind10002_tags(ev.get("tags") or []) + if not parsed_read and not parsed_write: + return ResolvedRelays( + read_urls=list(DEFAULT_READ_RELAYS), + write_urls=list(DEFAULT_WRITE_RELAYS), + had_kind10002=True, + ) + + read_urls = parsed_read or list(DEFAULT_READ_RELAYS) + write_urls = parsed_write or list(DEFAULT_WRITE_RELAYS) + return ResolvedRelays( + read_urls=_dedupe_preserve(read_urls), + write_urls=_dedupe_preserve(write_urls), + had_kind10002=True, + ) diff --git a/src/imwald/core/relay_policy.py b/src/imwald/core/relay_policy.py index c0f3900..0b8db43 100644 --- a/src/imwald/core/relay_policy.py +++ b/src/imwald/core/relay_policy.py @@ -32,17 +32,22 @@ def use_aggr_for_threads(user_write_urls: set[str]) -> bool: return "wss://nostr.land" in user_write_urls or "ws://nostr.land" in user_write_urls -def default_feed_read_relays() -> list[str]: - """Read set for the main + trending mix (deduped).""" +def augment_feed_with_trending(base_read_relays: list[str]) -> list[str]: + """Dedupe user/default read relays and append the Wisp trending slice.""" seen: set[str] = set() out: list[str] = [] - for u in (*DEFAULT_READ_RELAYS, WISP_TRENDING_NOTES_RELAY): + for u in (*base_read_relays, WISP_TRENDING_NOTES_RELAY): if u not in seen: seen.add(u) out.append(u) return out +def default_feed_read_relays() -> list[str]: + """Read set for the main + trending mix (deduped).""" + return augment_feed_with_trending(list(DEFAULT_READ_RELAYS)) + + def is_wisp_trending_relay_url(url: str) -> bool: u = url.lower().replace("ws://", "https://").replace("wss://", "https://") return "feeds.nostrarchives.com" in u and "/notes/trending/" in u diff --git a/src/imwald/ui/assets/vendor/marked.min.js b/src/imwald/ui/assets/vendor/marked.min.js new file mode 100644 index 0000000..0b26716 --- /dev/null +++ b/src/imwald/ui/assets/vendor/marked.min.js @@ -0,0 +1,6 @@ +/** + * marked v12.0.0 - a markdown parser + * Copyright (c) 2011-2024, Christopher Jeffrey. (MIT Licensed) + * https://github.com/markedjs/marked + */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).marked={})}(this,(function(e){"use strict";function t(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}function n(t){e.defaults=t}e.defaults={async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null};const s=/[&<>"']/,r=new RegExp(s.source,"g"),i=/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,l=new RegExp(i.source,"g"),o={"&":"&","<":"<",">":">",'"':""","'":"'"},a=e=>o[e];function c(e,t){if(t){if(s.test(e))return e.replace(r,a)}else if(i.test(e))return e.replace(l,a);return e}const h=/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/gi;function p(e){return e.replace(h,((e,t)=>"colon"===(t=t.toLowerCase())?":":"#"===t.charAt(0)?"x"===t.charAt(1)?String.fromCharCode(parseInt(t.substring(2),16)):String.fromCharCode(+t.substring(1)):""))}const u=/(^|[^\[])\^/g;function k(e,t){let n="string"==typeof e?e:e.source;t=t||"";const s={replace:(e,t)=>{let r="string"==typeof t?t:t.source;return r=r.replace(u,"$1"),n=n.replace(e,r),s},getRegex:()=>new RegExp(n,t)};return s}function g(e){try{e=encodeURI(e).replace(/%25/g,"%")}catch(e){return null}return e}const f={exec:()=>null};function d(e,t){const n=e.replace(/\|/g,((e,t,n)=>{let s=!1,r=t;for(;--r>=0&&"\\"===n[r];)s=!s;return s?"|":" |"})).split(/ \|/);let s=0;if(n[0].trim()||n.shift(),n.length>0&&!n[n.length-1].trim()&&n.pop(),t)if(n.length>t)n.splice(t);else for(;n.length0)return{type:"space",raw:t[0]}}code(e){const t=this.rules.block.code.exec(e);if(t){const e=t[0].replace(/^ {1,4}/gm,"");return{type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?e:x(e,"\n")}}}fences(e){const t=this.rules.block.fences.exec(e);if(t){const e=t[0],n=function(e,t){const n=e.match(/^(\s+)(?:```)/);if(null===n)return t;const s=n[1];return t.split("\n").map((e=>{const t=e.match(/^\s+/);if(null===t)return e;const[n]=t;return n.length>=s.length?e.slice(s.length):e})).join("\n")}(e,t[3]||"");return{type:"code",raw:e,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:n}}}heading(e){const t=this.rules.block.heading.exec(e);if(t){let e=t[2].trim();if(/#$/.test(e)){const t=x(e,"#");this.options.pedantic?e=t.trim():t&&!/ $/.test(t)||(e=t.trim())}return{type:"heading",raw:t[0],depth:t[1].length,text:e,tokens:this.lexer.inline(e)}}}hr(e){const t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:t[0]}}blockquote(e){const t=this.rules.block.blockquote.exec(e);if(t){const e=x(t[0].replace(/^ *>[ \t]?/gm,""),"\n"),n=this.lexer.state.top;this.lexer.state.top=!0;const s=this.lexer.blockTokens(e);return this.lexer.state.top=n,{type:"blockquote",raw:t[0],tokens:s,text:e}}}list(e){let t=this.rules.block.list.exec(e);if(t){let n=t[1].trim();const s=n.length>1,r={type:"list",raw:"",ordered:s,start:s?+n.slice(0,-1):"",loose:!1,items:[]};n=s?`\\d{1,9}\\${n.slice(-1)}`:`\\${n}`,this.options.pedantic&&(n=s?n:"[*+-]");const i=new RegExp(`^( {0,3}${n})((?:[\t ][^\\n]*)?(?:\\n|$))`);let l="",o="",a=!1;for(;e;){let n=!1;if(!(t=i.exec(e)))break;if(this.rules.block.hr.test(e))break;l=t[0],e=e.substring(l.length);let s=t[2].split("\n",1)[0].replace(/^\t+/,(e=>" ".repeat(3*e.length))),c=e.split("\n",1)[0],h=0;this.options.pedantic?(h=2,o=s.trimStart()):(h=t[2].search(/[^ ]/),h=h>4?1:h,o=s.slice(h),h+=t[1].length);let p=!1;if(!s&&/^ *$/.test(c)&&(l+=c+"\n",e=e.substring(c.length+1),n=!0),!n){const t=new RegExp(`^ {0,${Math.min(3,h-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ \t][^\\n]*)?(?:\\n|$))`),n=new RegExp(`^ {0,${Math.min(3,h-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),r=new RegExp(`^ {0,${Math.min(3,h-1)}}(?:\`\`\`|~~~)`),i=new RegExp(`^ {0,${Math.min(3,h-1)}}#`);for(;e;){const a=e.split("\n",1)[0];if(c=a,this.options.pedantic&&(c=c.replace(/^ {1,4}(?=( {4})*[^ ])/g," ")),r.test(c))break;if(i.test(c))break;if(t.test(c))break;if(n.test(e))break;if(c.search(/[^ ]/)>=h||!c.trim())o+="\n"+c.slice(h);else{if(p)break;if(s.search(/[^ ]/)>=4)break;if(r.test(s))break;if(i.test(s))break;if(n.test(s))break;o+="\n"+c}p||c.trim()||(p=!0),l+=a+"\n",e=e.substring(a.length+1),s=c.slice(h)}}r.loose||(a?r.loose=!0:/\n *\n *$/.test(l)&&(a=!0));let u,k=null;this.options.gfm&&(k=/^\[[ xX]\] /.exec(o),k&&(u="[ ] "!==k[0],o=o.replace(/^\[[ xX]\] +/,""))),r.items.push({type:"list_item",raw:l,task:!!k,checked:u,loose:!1,text:o,tokens:[]}),r.raw+=l}r.items[r.items.length-1].raw=l.trimEnd(),r.items[r.items.length-1].text=o.trimEnd(),r.raw=r.raw.trimEnd();for(let e=0;e"space"===e.type)),n=t.length>0&&t.some((e=>/\n.*\n/.test(e.raw)));r.loose=n}if(r.loose)for(let e=0;e$/,"$1").replace(this.rules.inline.anyPunctuation,"$1"):"",s=t[3]?t[3].substring(1,t[3].length-1).replace(this.rules.inline.anyPunctuation,"$1"):t[3];return{type:"def",tag:e,raw:t[0],href:n,title:s}}}table(e){const t=this.rules.block.table.exec(e);if(!t)return;if(!/[:|]/.test(t[2]))return;const n=d(t[1]),s=t[2].replace(/^\||\| *$/g,"").split("|"),r=t[3]&&t[3].trim()?t[3].replace(/\n[ \t]*$/,"").split("\n"):[],i={type:"table",raw:t[0],header:[],align:[],rows:[]};if(n.length===s.length){for(const e of s)/^ *-+: *$/.test(e)?i.align.push("right"):/^ *:-+: *$/.test(e)?i.align.push("center"):/^ *:-+ *$/.test(e)?i.align.push("left"):i.align.push(null);for(const e of n)i.header.push({text:e,tokens:this.lexer.inline(e)});for(const e of r)i.rows.push(d(e,i.header.length).map((e=>({text:e,tokens:this.lexer.inline(e)}))));return i}}lheading(e){const t=this.rules.block.lheading.exec(e);if(t)return{type:"heading",raw:t[0],depth:"="===t[2].charAt(0)?1:2,text:t[1],tokens:this.lexer.inline(t[1])}}paragraph(e){const t=this.rules.block.paragraph.exec(e);if(t){const e="\n"===t[1].charAt(t[1].length-1)?t[1].slice(0,-1):t[1];return{type:"paragraph",raw:t[0],text:e,tokens:this.lexer.inline(e)}}}text(e){const t=this.rules.block.text.exec(e);if(t)return{type:"text",raw:t[0],text:t[0],tokens:this.lexer.inline(t[0])}}escape(e){const t=this.rules.inline.escape.exec(e);if(t)return{type:"escape",raw:t[0],text:c(t[1])}}tag(e){const t=this.rules.inline.tag.exec(e);if(t)return!this.lexer.state.inLink&&/^/i.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&/^<(pre|code|kbd|script)(\s|>)/i.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&/^<\/(pre|code|kbd|script)(\s|>)/i.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){const t=this.rules.inline.link.exec(e);if(t){const e=t[2].trim();if(!this.options.pedantic&&/^$/.test(e))return;const t=x(e.slice(0,-1),"\\");if((e.length-t.length)%2==0)return}else{const e=function(e,t){if(-1===e.indexOf(t[1]))return-1;let n=0;for(let s=0;s-1){const n=(0===t[0].indexOf("!")?5:4)+t[1].length+e;t[2]=t[2].substring(0,e),t[0]=t[0].substring(0,n).trim(),t[3]=""}}let n=t[2],s="";if(this.options.pedantic){const e=/^([^'"]*[^\s])\s+(['"])(.*)\2/.exec(n);e&&(n=e[1],s=e[3])}else s=t[3]?t[3].slice(1,-1):"";return n=n.trim(),/^$/.test(e)?n.slice(1):n.slice(1,-1)),b(t,{href:n?n.replace(this.rules.inline.anyPunctuation,"$1"):n,title:s?s.replace(this.rules.inline.anyPunctuation,"$1"):s},t[0],this.lexer)}}reflink(e,t){let n;if((n=this.rules.inline.reflink.exec(e))||(n=this.rules.inline.nolink.exec(e))){const e=t[(n[2]||n[1]).replace(/\s+/g," ").toLowerCase()];if(!e){const e=n[0].charAt(0);return{type:"text",raw:e,text:e}}return b(n,e,n[0],this.lexer)}}emStrong(e,t,n=""){let s=this.rules.inline.emStrongLDelim.exec(e);if(!s)return;if(s[3]&&n.match(/[\p{L}\p{N}]/u))return;if(!(s[1]||s[2]||"")||!n||this.rules.inline.punctuation.exec(n)){const n=[...s[0]].length-1;let r,i,l=n,o=0;const a="*"===s[0][0]?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(a.lastIndex=0,t=t.slice(-1*e.length+n);null!=(s=a.exec(t));){if(r=s[1]||s[2]||s[3]||s[4]||s[5]||s[6],!r)continue;if(i=[...r].length,s[3]||s[4]){l+=i;continue}if((s[5]||s[6])&&n%3&&!((n+i)%3)){o+=i;continue}if(l-=i,l>0)continue;i=Math.min(i,i+l+o);const t=[...s[0]][0].length,a=e.slice(0,n+s.index+t+i);if(Math.min(n,i)%2){const e=a.slice(1,-1);return{type:"em",raw:a,text:e,tokens:this.lexer.inlineTokens(e)}}const c=a.slice(2,-2);return{type:"strong",raw:a,text:c,tokens:this.lexer.inlineTokens(c)}}}}codespan(e){const t=this.rules.inline.code.exec(e);if(t){let e=t[2].replace(/\n/g," ");const n=/[^ ]/.test(e),s=/^ /.test(e)&&/ $/.test(e);return n&&s&&(e=e.substring(1,e.length-1)),e=c(e,!0),{type:"codespan",raw:t[0],text:e}}}br(e){const t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e){const t=this.rules.inline.del.exec(e);if(t)return{type:"del",raw:t[0],text:t[2],tokens:this.lexer.inlineTokens(t[2])}}autolink(e){const t=this.rules.inline.autolink.exec(e);if(t){let e,n;return"@"===t[2]?(e=c(t[1]),n="mailto:"+e):(e=c(t[1]),n=e),{type:"link",raw:t[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}url(e){let t;if(t=this.rules.inline.url.exec(e)){let e,n;if("@"===t[2])e=c(t[0]),n="mailto:"+e;else{let s;do{s=t[0],t[0]=this.rules.inline._backpedal.exec(t[0])?.[0]??""}while(s!==t[0]);e=c(t[0]),n="www."===t[1]?"http://"+t[0]:t[0]}return{type:"link",raw:t[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}inlineText(e){const t=this.rules.inline.text.exec(e);if(t){let e;return e=this.lexer.state.inRawBlock?t[0]:c(t[0]),{type:"text",raw:t[0],text:e}}}}const m=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,y=/(?:[*+-]|\d{1,9}[.)])/,$=k(/^(?!bull )((?:.|\n(?!\s*?\n|bull ))+?)\n {0,3}(=+|-+) *(?:\n+|$)/).replace(/bull/g,y).getRegex(),z=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,T=/(?!\s*\])(?:\\.|[^\[\]\\])+/,R=k(/^ {0,3}\[(label)\]: *(?:\n *)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n *)?| *\n *)(title))? *(?:\n+|$)/).replace("label",T).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),_=k(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,y).getRegex(),A="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",S=/|$))/,I=k("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n *)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$))","i").replace("comment",S).replace("tag",A).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),E=k(z).replace("hr",m).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",A).getRegex(),Z={blockquote:k(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",E).getRegex(),code:/^( {4}[^\n]+(?:\n(?: *(?:\n|$))*)?)+/,def:R,fences:/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,heading:/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,hr:m,html:I,lheading:$,list:_,newline:/^(?: *(?:\n|$))+/,paragraph:E,table:f,text:/^[^\n]+/},q=k("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",m).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code"," {4}[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",A).getRegex(),L={...Z,table:q,paragraph:k(z).replace("hr",m).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",q).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",A).getRegex()},P={...Z,html:k("^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))").replace("comment",S).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:f,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:k(z).replace("hr",m).replace("heading"," *#{1,6} *[^\n]").replace("lheading",$).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},Q=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,v=/^( {2,}|\\)\n(?!\s*$)/,B="\\p{P}\\p{S}",M=k(/^((?![*_])[\spunctuation])/,"u").replace(/punctuation/g,B).getRegex(),O=k(/^(?:\*+(?:((?!\*)[punct])|[^\s*]))|^_+(?:((?!_)[punct])|([^\s_]))/,"u").replace(/punct/g,B).getRegex(),C=k("^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)[punct](\\*+)(?=[\\s]|$)|[^punct\\s](\\*+)(?!\\*)(?=[punct\\s]|$)|(?!\\*)[punct\\s](\\*+)(?=[^punct\\s])|[\\s](\\*+)(?!\\*)(?=[punct])|(?!\\*)[punct](\\*+)(?!\\*)(?=[punct])|[^punct\\s](\\*+)(?=[^punct\\s])","gu").replace(/punct/g,B).getRegex(),D=k("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)[punct](_+)(?=[\\s]|$)|[^punct\\s](_+)(?!_)(?=[punct\\s]|$)|(?!_)[punct\\s](_+)(?=[^punct\\s])|[\\s](_+)(?!_)(?=[punct])|(?!_)[punct](_+)(?!_)(?=[punct])","gu").replace(/punct/g,B).getRegex(),j=k(/\\([punct])/,"gu").replace(/punct/g,B).getRegex(),H=k(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),U=k(S).replace("(?:--\x3e|$)","--\x3e").getRegex(),X=k("^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",U).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),F=/(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,N=k(/^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/).replace("label",F).replace("href",/<(?:\\.|[^\n<>\\])+>|[^\s\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),G=k(/^!?\[(label)\]\[(ref)\]/).replace("label",F).replace("ref",T).getRegex(),J=k(/^!?\[(ref)\](?:\[\])?/).replace("ref",T).getRegex(),K={_backpedal:f,anyPunctuation:j,autolink:H,blockSkip:/\[[^[\]]*?\]\([^\(\)]*?\)|`[^`]*?`|<[^<>]*?>/g,br:v,code:/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,del:f,emStrongLDelim:O,emStrongRDelimAst:C,emStrongRDelimUnd:D,escape:Q,link:N,nolink:J,punctuation:M,reflink:G,reflinkSearch:k("reflink|nolink(?!\\()","g").replace("reflink",G).replace("nolink",J).getRegex(),tag:X,text:/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\t+" ".repeat(n.length)));e;)if(!(this.options.extensions&&this.options.extensions.block&&this.options.extensions.block.some((s=>!!(n=s.call({lexer:this},e,t))&&(e=e.substring(n.raw.length),t.push(n),!0)))))if(n=this.tokenizer.space(e))e=e.substring(n.raw.length),1===n.raw.length&&t.length>0?t[t.length-1].raw+="\n":t.push(n);else if(n=this.tokenizer.code(e))e=e.substring(n.raw.length),s=t[t.length-1],!s||"paragraph"!==s.type&&"text"!==s.type?t.push(n):(s.raw+="\n"+n.raw,s.text+="\n"+n.text,this.inlineQueue[this.inlineQueue.length-1].src=s.text);else if(n=this.tokenizer.fences(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.heading(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.hr(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.blockquote(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.list(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.html(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.def(e))e=e.substring(n.raw.length),s=t[t.length-1],!s||"paragraph"!==s.type&&"text"!==s.type?this.tokens.links[n.tag]||(this.tokens.links[n.tag]={href:n.href,title:n.title}):(s.raw+="\n"+n.raw,s.text+="\n"+n.raw,this.inlineQueue[this.inlineQueue.length-1].src=s.text);else if(n=this.tokenizer.table(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.lheading(e))e=e.substring(n.raw.length),t.push(n);else{if(r=e,this.options.extensions&&this.options.extensions.startBlock){let t=1/0;const n=e.slice(1);let s;this.options.extensions.startBlock.forEach((e=>{s=e.call({lexer:this},n),"number"==typeof s&&s>=0&&(t=Math.min(t,s))})),t<1/0&&t>=0&&(r=e.substring(0,t+1))}if(this.state.top&&(n=this.tokenizer.paragraph(r)))s=t[t.length-1],i&&"paragraph"===s.type?(s.raw+="\n"+n.raw,s.text+="\n"+n.text,this.inlineQueue.pop(),this.inlineQueue[this.inlineQueue.length-1].src=s.text):t.push(n),i=r.length!==e.length,e=e.substring(n.raw.length);else if(n=this.tokenizer.text(e))e=e.substring(n.raw.length),s=t[t.length-1],s&&"text"===s.type?(s.raw+="\n"+n.raw,s.text+="\n"+n.text,this.inlineQueue.pop(),this.inlineQueue[this.inlineQueue.length-1].src=s.text):t.push(n);else if(e){const t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}return this.state.top=!0,t}inline(e,t=[]){return this.inlineQueue.push({src:e,tokens:t}),t}inlineTokens(e,t=[]){let n,s,r,i,l,o,a=e;if(this.tokens.links){const e=Object.keys(this.tokens.links);if(e.length>0)for(;null!=(i=this.tokenizer.rules.inline.reflinkSearch.exec(a));)e.includes(i[0].slice(i[0].lastIndexOf("[")+1,-1))&&(a=a.slice(0,i.index)+"["+"a".repeat(i[0].length-2)+"]"+a.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;null!=(i=this.tokenizer.rules.inline.blockSkip.exec(a));)a=a.slice(0,i.index)+"["+"a".repeat(i[0].length-2)+"]"+a.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);for(;null!=(i=this.tokenizer.rules.inline.anyPunctuation.exec(a));)a=a.slice(0,i.index)+"++"+a.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);for(;e;)if(l||(o=""),l=!1,!(this.options.extensions&&this.options.extensions.inline&&this.options.extensions.inline.some((s=>!!(n=s.call({lexer:this},e,t))&&(e=e.substring(n.raw.length),t.push(n),!0)))))if(n=this.tokenizer.escape(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.tag(e))e=e.substring(n.raw.length),s=t[t.length-1],s&&"text"===n.type&&"text"===s.type?(s.raw+=n.raw,s.text+=n.text):t.push(n);else if(n=this.tokenizer.link(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.reflink(e,this.tokens.links))e=e.substring(n.raw.length),s=t[t.length-1],s&&"text"===n.type&&"text"===s.type?(s.raw+=n.raw,s.text+=n.text):t.push(n);else if(n=this.tokenizer.emStrong(e,a,o))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.codespan(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.br(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.del(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.autolink(e))e=e.substring(n.raw.length),t.push(n);else if(this.state.inLink||!(n=this.tokenizer.url(e))){if(r=e,this.options.extensions&&this.options.extensions.startInline){let t=1/0;const n=e.slice(1);let s;this.options.extensions.startInline.forEach((e=>{s=e.call({lexer:this},n),"number"==typeof s&&s>=0&&(t=Math.min(t,s))})),t<1/0&&t>=0&&(r=e.substring(0,t+1))}if(n=this.tokenizer.inlineText(r))e=e.substring(n.raw.length),"_"!==n.raw.slice(-1)&&(o=n.raw.slice(-1)),l=!0,s=t[t.length-1],s&&"text"===s.type?(s.raw+=n.raw,s.text+=n.text):t.push(n);else if(e){const t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}else e=e.substring(n.raw.length),t.push(n);return t}}class se{options;constructor(t){this.options=t||e.defaults}code(e,t,n){const s=(t||"").match(/^\S*/)?.[0];return e=e.replace(/\n$/,"")+"\n",s?'
'+(n?e:c(e,!0))+"
\n":"
"+(n?e:c(e,!0))+"
\n"}blockquote(e){return`
\n${e}
\n`}html(e,t){return e}heading(e,t,n){return`${e}\n`}hr(){return"
\n"}list(e,t,n){const s=t?"ol":"ul";return"<"+s+(t&&1!==n?' start="'+n+'"':"")+">\n"+e+"\n"}listitem(e,t,n){return`
  • ${e}
  • \n`}checkbox(e){return"'}paragraph(e){return`

    ${e}

    \n`}table(e,t){return t&&(t=`${t}`),"\n\n"+e+"\n"+t+"
    \n"}tablerow(e){return`\n${e}\n`}tablecell(e,t){const n=t.header?"th":"td";return(t.align?`<${n} align="${t.align}">`:`<${n}>`)+e+`\n`}strong(e){return`${e}`}em(e){return`${e}`}codespan(e){return`${e}`}br(){return"
    "}del(e){return`${e}`}link(e,t,n){const s=g(e);if(null===s)return n;let r='
    ",r}image(e,t,n){const s=g(e);if(null===s)return n;let r=`${n}0&&"paragraph"===n.tokens[0].type?(n.tokens[0].text=e+" "+n.tokens[0].text,n.tokens[0].tokens&&n.tokens[0].tokens.length>0&&"text"===n.tokens[0].tokens[0].type&&(n.tokens[0].tokens[0].text=e+" "+n.tokens[0].tokens[0].text)):n.tokens.unshift({type:"text",text:e+" "}):o+=e+" "}o+=this.parse(n.tokens,i),l+=this.renderer.listitem(o,r,!!s)}n+=this.renderer.list(l,t,s);continue}case"html":{const e=r;n+=this.renderer.html(e.text,e.block);continue}case"paragraph":{const e=r;n+=this.renderer.paragraph(this.parseInline(e.tokens));continue}case"text":{let i=r,l=i.tokens?this.parseInline(i.tokens):i.text;for(;s+1{const r=e[s].flat(1/0);n=n.concat(this.walkTokens(r,t))})):e.tokens&&(n=n.concat(this.walkTokens(e.tokens,t)))}}return n}use(...e){const t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach((e=>{const n={...e};if(n.async=this.defaults.async||n.async||!1,e.extensions&&(e.extensions.forEach((e=>{if(!e.name)throw new Error("extension name required");if("renderer"in e){const n=t.renderers[e.name];t.renderers[e.name]=n?function(...t){let s=e.renderer.apply(this,t);return!1===s&&(s=n.apply(this,t)),s}:e.renderer}if("tokenizer"in e){if(!e.level||"block"!==e.level&&"inline"!==e.level)throw new Error("extension level must be 'block' or 'inline'");const n=t[e.level];n?n.unshift(e.tokenizer):t[e.level]=[e.tokenizer],e.start&&("block"===e.level?t.startBlock?t.startBlock.push(e.start):t.startBlock=[e.start]:"inline"===e.level&&(t.startInline?t.startInline.push(e.start):t.startInline=[e.start]))}"childTokens"in e&&e.childTokens&&(t.childTokens[e.name]=e.childTokens)})),n.extensions=t),e.renderer){const t=this.defaults.renderer||new se(this.defaults);for(const n in e.renderer){if(!(n in t))throw new Error(`renderer '${n}' does not exist`);if("options"===n)continue;const s=n,r=e.renderer[s],i=t[s];t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n||""}}n.renderer=t}if(e.tokenizer){const t=this.defaults.tokenizer||new w(this.defaults);for(const n in e.tokenizer){if(!(n in t))throw new Error(`tokenizer '${n}' does not exist`);if(["options","rules","lexer"].includes(n))continue;const s=n,r=e.tokenizer[s],i=t[s];t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n}}n.tokenizer=t}if(e.hooks){const t=this.defaults.hooks||new le;for(const n in e.hooks){if(!(n in t))throw new Error(`hook '${n}' does not exist`);if("options"===n)continue;const s=n,r=e.hooks[s],i=t[s];le.passThroughHooks.has(n)?t[s]=e=>{if(this.defaults.async)return Promise.resolve(r.call(t,e)).then((e=>i.call(t,e)));const n=r.call(t,e);return i.call(t,n)}:t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n}}n.hooks=t}if(e.walkTokens){const t=this.defaults.walkTokens,s=e.walkTokens;n.walkTokens=function(e){let n=[];return n.push(s.call(this,e)),t&&(n=n.concat(t.call(this,e))),n}}this.defaults={...this.defaults,...n}})),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return ne.lex(e,t??this.defaults)}parser(e,t){return ie.parse(e,t??this.defaults)}#e(e,t){return(n,s)=>{const r={...s},i={...this.defaults,...r};!0===this.defaults.async&&!1===r.async&&(i.silent||console.warn("marked(): The async option was set to true by an extension. The async: false option sent to parse will be ignored."),i.async=!0);const l=this.#t(!!i.silent,!!i.async);if(null==n)return l(new Error("marked(): input parameter is undefined or null"));if("string"!=typeof n)return l(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(n)+", string expected"));if(i.hooks&&(i.hooks.options=i),i.async)return Promise.resolve(i.hooks?i.hooks.preprocess(n):n).then((t=>e(t,i))).then((e=>i.hooks?i.hooks.processAllTokens(e):e)).then((e=>i.walkTokens?Promise.all(this.walkTokens(e,i.walkTokens)).then((()=>e)):e)).then((e=>t(e,i))).then((e=>i.hooks?i.hooks.postprocess(e):e)).catch(l);try{i.hooks&&(n=i.hooks.preprocess(n));let s=e(n,i);i.hooks&&(s=i.hooks.processAllTokens(s)),i.walkTokens&&this.walkTokens(s,i.walkTokens);let r=t(s,i);return i.hooks&&(r=i.hooks.postprocess(r)),r}catch(e){return l(e)}}}#t(e,t){return n=>{if(n.message+="\nPlease report this to https://github.com/markedjs/marked.",e){const e="

    An error occurred:

    "+c(n.message+"",!0)+"
    ";return t?Promise.resolve(e):e}if(t)return Promise.reject(n);throw n}}}const ae=new oe;function ce(e,t){return ae.parse(e,t)}ce.options=ce.setOptions=function(e){return ae.setOptions(e),ce.defaults=ae.defaults,n(ce.defaults),ce},ce.getDefaults=t,ce.defaults=e.defaults,ce.use=function(...e){return ae.use(...e),ce.defaults=ae.defaults,n(ce.defaults),ce},ce.walkTokens=function(e,t){return ae.walkTokens(e,t)},ce.parseInline=ae.parseInline,ce.Parser=ie,ce.parser=ie.parse,ce.Renderer=se,ce.TextRenderer=re,ce.Lexer=ne,ce.lexer=ne.lex,ce.Tokenizer=w,ce.Hooks=le,ce.parse=ce;const he=ce.options,pe=ce.setOptions,ue=ce.use,ke=ce.walkTokens,ge=ce.parseInline,fe=ce,de=ie.parse,xe=ne.lex;e.Hooks=le,e.Lexer=ne,e.Marked=oe,e.Parser=ie,e.Renderer=se,e.TextRenderer=re,e.Tokenizer=w,e.getDefaults=t,e.lexer=xe,e.marked=ce,e.options=he,e.parse=fe,e.parseInline=ge,e.parser=de,e.setOptions=pe,e.use=ue,e.walkTokens=ke})); \ No newline at end of file diff --git a/src/imwald/ui/composer_dialog.py b/src/imwald/ui/composer_dialog.py index 0e7f238..66026ad 100644 --- a/src/imwald/ui/composer_dialog.py +++ b/src/imwald/ui/composer_dialog.py @@ -6,7 +6,6 @@ import json import time from typing import Any -from PySide6.QtCore import Qt from PySide6.QtWidgets import ( QComboBox, QDialog, @@ -16,7 +15,6 @@ from PySide6.QtWidgets import ( QLabel, QLineEdit, QMessageBox, - QPlainTextEdit, QSpinBox, QVBoxLayout, ) @@ -24,7 +22,7 @@ from PySide6.QtWidgets import ( from imwald.core.accounts_store import StoredAccount, unlock_secret from imwald.core.nostr_crypto import build_signed_event from imwald.core.nostr_publish import publish_to_relays_sync -from imwald.core.relay_policy import DEFAULT_WRITE_RELAYS +from imwald.ui.markdown_editor_widget import MarkdownBodyEditor FEED_KINDS = [1, 20, 21, 30023, 9802, 11] TAG_SUGGESTIONS = ["t", "client", "e", "p", "relay", "imeta"] @@ -38,12 +36,15 @@ class ComposerDialog(QDialog): edit_from: dict[str, Any] | None = None, account: StoredAccount, password: str | None = None, + write_relays: list[str], ) -> None: super().__init__(parent) self.setWindowTitle("New event" if edit_from is None else "Edit event (clone)") + self.resize(960, 640) self._account = account self._password = password self._edit_from = edit_from + self._write_relays = list(write_relays) self.last_published: dict | None = None self._kind = QSpinBox() @@ -56,10 +57,12 @@ class ComposerDialog(QDialog): self._tags = QLineEdit() self._tags.setPlaceholderText('JSON array of tags, e.g. [["t","nostr"]]') - self._content = QPlainTextEdit() + self._content = MarkdownBodyEditor() self._hint = QLabel("Suggestions: " + ", ".join(f'["{t}","…"]' for t in TAG_SUGGESTIONS[:4])) - buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel) + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel, + ) buttons.accepted.connect(self._publish) buttons.rejected.connect(self.reject) @@ -106,12 +109,19 @@ class ComposerDialog(QDialog): return now = int(time.time()) ev = build_signed_event(sec, created_at=now, kind=kind, tags=tags, content=content) - publish_to_relays_sync(list(DEFAULT_WRITE_RELAYS), ev) + publish_to_relays_sync(self._write_relays, ev) self.last_published = ev self.accept() -def open_composer_for_edit(parent, ev: dict[str, Any], account: StoredAccount, password: str | None) -> None: +def open_composer_for_edit( + parent, + ev: dict[str, Any], + account: StoredAccount, + password: str | None, + *, + write_relays: list[str], +) -> None: clone = {k: ev[k] for k in ("kind", "tags", "content") if k in ev} - dlg = ComposerDialog(parent, edit_from=clone, account=account, password=password) + dlg = ComposerDialog(parent, edit_from=clone, account=account, password=password, write_relays=write_relays) dlg.exec() diff --git a/src/imwald/ui/feed_page.py b/src/imwald/ui/feed_page.py index 11fa57f..54e6dd8 100644 --- a/src/imwald/ui/feed_page.py +++ b/src/imwald/ui/feed_page.py @@ -2,6 +2,7 @@ from __future__ import annotations +import html import json from typing import Any @@ -19,6 +20,7 @@ from PySide6.QtWidgets import ( ) from imwald.core.database import Database +from imwald.core.md_render import markdown_html_fragment, markdown_plain_summary from imwald.core.nostr_engine import NostrEngine from imwald.core.ranker import Ranker @@ -124,9 +126,10 @@ class FeedPage(QWidget): return ev = self._queue[self._index % len(self._queue)] if ev.get("deleted"): + raw = html.escape(ev.get("content") or "") self._op.setHtml( - f"

    Marked deleted locally

    {ev.get('content','')}
    " - f"

    {ev['id']}

    " + f"

    Marked deleted locally

    {raw}
    " + f"

    {html.escape(ev['id'])}

    " ) self._thread.clear() self._why.setText("") @@ -142,17 +145,23 @@ class FeedPage(QWidget): sr = ev.get("source_relay") or "" if sr and "nostrarchives.com" in sr: tr = "

    Trending slice (nostrarchives)

    " + pk = html.escape(ev["pubkey"][:16] + "…") + eid = html.escape(ev["id"]) + md_body = markdown_html_fragment(ev.get("content") or "") body = ( - f"

    Kind {ev['kind']}

    " - f"

    {ev['pubkey'][:16]}… · {ev['created_at']}

    " + f"" + f"

    Kind {int(ev['kind'])}

    " + f"

    {pk} · {int(ev['created_at'])}

    " f"{tr}" - f"
    {ev.get('content','')}
    " - f"

    {ev['id']}

    " + f"
    {md_body}
    " + f"

    {eid}

    " + f"" ) self._op.setHtml(body) self._thread.clear() for r in self._db.list_replies_to(ev["id"]): - line = f"k{r['kind']} {r['pubkey'][:8]}… {r['content'][:100]!r}" + snippet = markdown_plain_summary(r.get("content") or "", max_len=90) + line = f"k{r['kind']} {r['pubkey'][:8]}… — {snippet}" it = QListWidgetItem(line) it.setData(Qt.ItemDataRole.UserRole, r["id"]) self._thread.addItem(it) diff --git a/src/imwald/ui/main_window.py b/src/imwald/ui/main_window.py index af0f0bf..26c38c2 100644 --- a/src/imwald/ui/main_window.py +++ b/src/imwald/ui/main_window.py @@ -2,7 +2,7 @@ from __future__ import annotations -from PySide6.QtCore import Qt +from PySide6.QtCore import Qt, QTimer from PySide6.QtGui import QAction from PySide6.QtWidgets import ( QComboBox, @@ -22,6 +22,9 @@ from PySide6.QtWidgets import ( from imwald.core.accounts_store import StoredAccount, load_accounts from imwald.core.database import Database from imwald.core.nostr_engine import NostrEngine +from imwald.core.md_render import markdown_plain_summary +from imwald.core.relay_list import resolve_for_account +from imwald.core.relay_policy import augment_feed_with_trending from imwald.ui.composer_dialog import ComposerDialog from imwald.ui.db_admin_page import DbAdminPage from imwald.ui.feed_page import FeedPage @@ -67,6 +70,11 @@ class MainWindow(QMainWindow): self._wire_engine() self._wire_pages() + self._ingest_ui_timer = QTimer(self) + self._ingest_ui_timer.setSingleShot(True) + self._ingest_ui_timer.setInterval(450) + self._ingest_ui_timer.timeout.connect(self._flush_ingest_ui_refresh) + self._acct_combo.currentIndexChanged.connect(self._on_account_changed) self._on_account_changed() @@ -77,8 +85,7 @@ class MainWindow(QMainWindow): self._reload_account_combo() self._notif.set_accounts(self._accounts) self._dbadm.set_accounts(self._accounts) - - self._feed.reload_queue() + self._on_account_changed() def _reload_account_combo(self) -> None: self._acct_combo.blockSignals(True) @@ -120,6 +127,22 @@ class MainWindow(QMainWindow): list300 = self._db.list_kind30000_list_pubkeys(pk) self._feed.set_context(pk, following, list300) self._feed.reload_queue() + self._restart_relays() + + def _restart_relays(self) -> None: + pk = self._current_pubkey() + resolved = resolve_for_account(self._db, pk) + reads = augment_feed_with_trending(resolved.read_urls) + self._engine.start_relays( + read_urls=reads, + user_write_urls=resolved.write_urls, + list30000_owner=self.list_owner_pubkey_for_relays(), + ) + + def _flush_ingest_ui_refresh(self) -> None: + if self._stack.currentWidget() is self._feed: + self._feed.refresh_tail() + self._notif.refresh_all() def _wire_menu(self) -> None: m_file = self.menuBar().addMenu("&File") @@ -173,9 +196,7 @@ class MainWindow(QMainWindow): if not isinstance(ev, dict): return NostrEngine.apply_ingest_to_db(self._db, ev, relay_url) - if self._stack.currentWidget() is self._feed: - self._feed.refresh_tail() - self._notif.refresh_all() + self._ingest_ui_timer.start() def _wire_pages(self) -> None: self._search.open_event.connect(self._open_event) @@ -201,7 +222,7 @@ class MainWindow(QMainWindow): if QMessageBox.question(self, "NIP-09", f"Publish deletion for {event_id[:16]}…?") != QMessageBox.StandardButton.Yes: return self._engine.publish_nip09_deletion(acc, pw, event_id) - QMessageBox.information(self, "NIP-09", "Deletion request published to default write relays.") + QMessageBox.information(self, "NIP-09", "Deletion request published to your write relays.") def _edit_current(self) -> None: eid = self._feed.current_event_id() @@ -214,7 +235,8 @@ class MainWindow(QMainWindow): if not acc: QMessageBox.information(self, "Edit", "Select an account.") return - dlg = ComposerDialog(self, edit_from=ev, account=acc, password=pw) + writes = resolve_for_account(self._db, acc.pubkey).write_urls + dlg = ComposerDialog(self, edit_from=ev, account=acc, password=pw, write_relays=writes) if dlg.exec() == QDialog.DialogCode.Accepted and dlg.last_published: self._db.upsert_event(dlg.last_published) @@ -223,7 +245,8 @@ class MainWindow(QMainWindow): if not acc: QMessageBox.information(self, "Composer", "Select an account or add keys via onboarding.") return - dlg = ComposerDialog(self, edit_from=None, account=acc, password=pw) + writes = resolve_for_account(self._db, acc.pubkey).write_urls + dlg = ComposerDialog(self, edit_from=None, account=acc, password=pw, write_relays=writes) if dlg.exec() == QDialog.DialogCode.Accepted and dlg.last_published: self._db.upsert_event(dlg.last_published) @@ -237,7 +260,8 @@ class MainWindow(QMainWindow): d.setWindowTitle("Your latest events") lw = QListWidget() for ev in rows: - it = QListWidgetItem(f"k{ev['kind']} {ev['id'][:16]}… {ev['content'][:60]!r}") + snippet = markdown_plain_summary(ev.get("content") or "", max_len=56) + it = QListWidgetItem(f"k{ev['kind']} {ev['id'][:16]}… — {snippet}") it.setData(Qt.ItemDataRole.UserRole, ev["id"]) lw.addItem(it) @@ -260,6 +284,7 @@ class MainWindow(QMainWindow): self._reload_account_combo() self._notif.set_accounts(self._accounts) self._dbadm.set_accounts(self._accounts) + self._on_account_changed() def _account_for_compose(self) -> tuple[StoredAccount | None, str | None]: if self._stack.currentWidget() is self._notif: diff --git a/src/imwald/ui/markdown_editor_widget.py b/src/imwald/ui/markdown_editor_widget.py new file mode 100644 index 0000000..e024a85 --- /dev/null +++ b/src/imwald/ui/markdown_editor_widget.py @@ -0,0 +1,57 @@ +"""Split Markdown composer: monospace source + offline rendered preview (marked via QuickJS).""" + +from __future__ import annotations + +from PySide6.QtCore import Qt, QTimer +from PySide6.QtGui import QFont +from PySide6.QtWidgets import QPlainTextEdit, QSizePolicy, QSplitter, QTextBrowser, QVBoxLayout, QWidget + +from imwald.core.md_render import markdown_html_document + + +class MarkdownBodyEditor(QWidget): + """Plain-text Markdown editor with live rendered preview (local ``marked`` + nh3).""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._split = QSplitter(Qt.Orientation.Horizontal) + self._source = QPlainTextEdit() + self._source.setPlaceholderText("Markdown source — preview updates as you type") + mono = QFont("monospace") + if not mono.exactMatch(): + mono = QFont("Courier New") + self._source.setFont(mono) + self._source.setMinimumHeight(260) + self._source.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + + self._preview = QTextBrowser() + self._preview.setOpenExternalLinks(True) + self._preview.setMinimumHeight(260) + self._preview.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + + self._split.addWidget(self._source) + self._split.addWidget(self._preview) + self._split.setStretchFactor(0, 1) + self._split.setStretchFactor(1, 1) + + lay = QVBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + lay.addWidget(self._split) + + self._debounce = QTimer(self) + self._debounce.setSingleShot(True) + self._debounce.setInterval(280) + self._debounce.timeout.connect(self._update_preview) + self._source.textChanged.connect(lambda: self._debounce.start()) + + self._update_preview() + + def _update_preview(self) -> None: + self._preview.setHtml(markdown_html_document(self._source.toPlainText())) + + def setPlainText(self, text: str) -> None: + self._source.setPlainText(text) + self._debounce.start() + + def toPlainText(self) -> str: + return self._source.toPlainText() diff --git a/src/imwald/ui/notifications_page.py b/src/imwald/ui/notifications_page.py index 9a96c29..328e4c1 100644 --- a/src/imwald/ui/notifications_page.py +++ b/src/imwald/ui/notifications_page.py @@ -7,6 +7,7 @@ from PySide6.QtWidgets import QLabel, QListWidget, QListWidgetItem, QTabWidget, from imwald.core.accounts_store import StoredAccount from imwald.core.database import Database +from imwald.core.md_render import markdown_plain_summary class NotificationsPage(QWidget): @@ -56,13 +57,17 @@ class NotificationsPage(QWidget): lw.clear() cur = self._db.conn().execute( """ - SELECT source_event_id, kind, read, created_at FROM notifications - WHERE recipient_pubkey=? ORDER BY created_at DESC LIMIT 200 + SELECT n.source_event_id, n.kind, n.read, n.created_at, e.content AS content + FROM notifications n + LEFT JOIN events e ON e.id = n.source_event_id AND e.deleted = 0 + WHERE n.recipient_pubkey=? ORDER BY n.created_at DESC LIMIT 200 """, (pubkey,), ) for row in cur: - title = f"{row['kind']} {row['source_event_id'][:12]}… read={row['read']}" + snippet = markdown_plain_summary(row["content"] or "", max_len=56) if row["content"] else "" + tail = f" — {snippet}" if snippet else "" + title = f"{row['kind']} {row['source_event_id'][:12]}… read={row['read']}{tail}" it = QListWidgetItem(title) it.setData(Qt.ItemDataRole.UserRole, row["source_event_id"]) lw.addItem(it) diff --git a/src/imwald/ui/search_page.py b/src/imwald/ui/search_page.py index 191a107..4adaf85 100644 --- a/src/imwald/ui/search_page.py +++ b/src/imwald/ui/search_page.py @@ -6,6 +6,7 @@ from PySide6.QtCore import Qt, Signal from PySide6.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QPushButton, QVBoxLayout, QWidget from imwald.core.database import Database +from imwald.core.md_render import markdown_plain_summary class SearchPage(QWidget): @@ -35,7 +36,8 @@ class SearchPage(QWidget): if not q: return for ev in self._db.search_local(q, limit=200): - title = f"{ev['kind']} {ev['id'][:12]}… — {ev['content'][:80]!r}" + snippet = markdown_plain_summary(ev.get("content") or "", max_len=72) + title = f"{ev['kind']} {ev['id'][:12]}… — {snippet}" it = QListWidgetItem(title) it.setData(Qt.ItemDataRole.UserRole, ev["id"]) self._list.addItem(it) diff --git a/tests/test_event_verify.py b/tests/test_event_verify.py new file mode 100644 index 0000000..47a3286 --- /dev/null +++ b/tests/test_event_verify.py @@ -0,0 +1,25 @@ +from imwald.core.nostr_crypto import build_signed_event, verify_nostr_event + + +def _sk() -> bytes: + return bytes.fromhex("3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683") + + +def test_verify_accepts_signed_roundtrip() -> None: + sk = _sk() + ev = build_signed_event(sk, created_at=1700000000, kind=1, tags=[["client", "imwald"]], content="hello") + assert verify_nostr_event(ev) is True + + +def test_verify_rejects_bad_id() -> None: + sk = _sk() + ev = build_signed_event(sk, created_at=1700000000, kind=1, tags=[], content="x") + ev["id"] = "f" * 64 + assert verify_nostr_event(ev) is False + + +def test_verify_rejects_tampered_content() -> None: + sk = _sk() + ev = build_signed_event(sk, created_at=1700000000, kind=1, tags=[], content="x") + ev["content"] = "y" + assert verify_nostr_event(ev) is False diff --git a/tests/test_md_render.py b/tests/test_md_render.py new file mode 100644 index 0000000..fdc76de --- /dev/null +++ b/tests/test_md_render.py @@ -0,0 +1,17 @@ +from imwald.core.md_render import markdown_html_fragment, markdown_plain_summary + + +def test_plain_summary_strips_markdown_noise() -> None: + s = markdown_plain_summary("# Title\n\nHello **world**", max_len=80) + assert "Title" in s and "world" in s + assert "**" not in s and "#" not in s + + +def test_markdown_renders_strong() -> None: + html = markdown_html_fragment("Hello **world**") + assert "world" in html or "world" in html + + +def test_markdown_fenced_code() -> None: + html = markdown_html_fragment("```\n1 + 1\n```") + assert "
    " in html and "" in html
    diff --git a/tests/test_relay_list.py b/tests/test_relay_list.py
    new file mode 100644
    index 0000000..62309ba
    --- /dev/null
    +++ b/tests/test_relay_list.py
    @@ -0,0 +1,57 @@
    +import tempfile
    +from pathlib import Path
    +
    +from imwald.core.database import Database
    +from imwald.core.nostr_crypto import build_signed_event
    +from imwald.core.relay_list import parse_kind10002_tags, resolve_for_account
    +from imwald.core.relay_policy import DEFAULT_READ_RELAYS, DEFAULT_WRITE_RELAYS
    +
    +
    +def _sk() -> bytes:
    +    return bytes.fromhex("3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683")
    +
    +
    +def test_parse_nip65_triple_tags() -> None:
    +    tags = [
    +        ["r", "wss://read.only", "read"],
    +        ["r", "wss://write.only", "write"],
    +        ["r", "wss://both.relay"],
    +    ]
    +    reads, writes = parse_kind10002_tags(tags)
    +    assert "wss://read.only" in reads and "wss://read.only" not in writes
    +    assert "wss://write.only" in writes and "wss://write.only" not in reads
    +    assert "wss://both.relay" in reads and "wss://both.relay" in writes
    +
    +
    +def test_parse_legacy_read_write_pairs() -> None:
    +    tags = [["r", "wss://a"], ["read", "true"], ["r", "wss://b"], ["write", "true"]]
    +    reads, writes = parse_kind10002_tags(tags)
    +    assert reads == ["wss://a"]
    +    assert writes == ["wss://b"]
    +
    +
    +def test_resolve_defaults_without_kind10002() -> None:
    +    with tempfile.TemporaryDirectory() as td:
    +        db = Database(Path(td) / "x.sqlite")
    +        db.connect()
    +        sk = _sk()
    +        me = build_signed_event(sk, created_at=1, kind=0, tags=[], content="{}")["pubkey"]
    +        r = resolve_for_account(db, me)
    +        assert r.read_urls == list(DEFAULT_READ_RELAYS)
    +        assert r.write_urls == list(DEFAULT_WRITE_RELAYS)
    +        assert r.had_kind10002 is False
    +
    +
    +def test_resolve_uses_stored_kind10002() -> None:
    +    with tempfile.TemporaryDirectory() as td:
    +        db = Database(Path(td) / "y.sqlite")
    +        db.connect()
    +        sk = _sk()
    +        me = build_signed_event(sk, created_at=1, kind=0, tags=[], content="{}")["pubkey"]
    +        tags = [["r", "wss://custom.read", "read"], ["r", "wss://custom.write", "write"]]
    +        ev = build_signed_event(sk, created_at=2, kind=10002, tags=tags, content="")
    +        db.upsert_event(ev)
    +        r = resolve_for_account(db, me)
    +        assert r.read_urls == ["wss://custom.read"]
    +        assert r.write_urls == ["wss://custom.write"]
    +        assert r.had_kind10002 is True