diff --git a/CLAUDE.md b/CLAUDE.md index bed35dc..5783e3e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,6 +87,26 @@ c.approve_node(node_id) c.lock_node(node_id) c.unlock_node(node_id) c.delete_node(node_id) # permanent, moderator only + +# Export (pandoc-powered, 67 output formats) +formats = c.export_formats() +md = c.export_thread(node_id, "markdown") +pdf = c.export_thread(node_id, "pdf") +epub = c.export_namespace("meta.remarkbox.com", "epub") +docx = c.export_node(node_id, "docx") + +# Wiki mode (namespace.wiki=True, any authenticated user can edit root nodes) +c.wiki_edit(node_id, data="Updated content") # creates revision first +revisions = c.get_revisions(node_id) +revision = c.get_revision(revision_id) + +# Themes (auto-generated per namespace, light + dark mode) +css = c.get_theme_css("meta.remarkbox.com") +preview = c.get_theme_preview("meta.remarkbox.com") + +# Multi-syntax input (source_format parameter on create/reply/edit) +c.create_thread(namespace="ns", title="RST", data="Title\n=====\n\nParagraph.", source_format="rst") +c.reply(node_id, data="

HTML reply

", source_format="html") ``` ### Key details @@ -119,6 +139,15 @@ c.delete_node(node_id) # permanent, moderator only | GET | `/api/v1/clients/c` | Download C client (rb.c) | | GET | `/api/v1/admin/namespaces` | List all namespaces (superuser only) | | GET | `/api/v1/admin/recent-nodes?days=7` | Recent nodes network-wide (superuser only) | +| GET | `/api/v1/export/formats` | List available pandoc export formats | +| GET | `/api/v1/export/namespace/{name}.{fmt}` | Export namespace as book | +| GET | `/api/v1/export/threads/{node_id}.{fmt}` | Export thread as document | +| GET | `/api/v1/export/nodes/{node_id}.{fmt}` | Export node subtree (on-demand) | +| GET | `/api/v1/nodes/{id}/revisions` | Revision history for a node | +| POST | `/api/v1/nodes/{id}/wiki-edit` | Wiki-edit a node (creates revision) | +| GET | `/api/v1/revisions/{id}` | Get a specific revision | +| GET | `/api/v1/themes/{namespace}/css` | Auto-generated theme CSS | +| GET | `/api/v1/themes/{namespace}/preview` | Theme palette preview (JSON) | ### Authentication diff --git a/docs/tickets/15.md b/docs/tickets/15.md new file mode 100644 index 0000000..7985f8b --- /dev/null +++ b/docs/tickets/15.md @@ -0,0 +1,120 @@ +# T15: Operation Undigg — Pandoc Export & Wiki Mode + +**Status**: in-progress +**Priority**: high +**Source**: fox directive 2026-03-09 + +## Summary + +Transform remarkbox into a document-first platform. Every namespace is a book, +every root thread is a chapter. Pandoc renders every format it can produce. +Wiki mode lets anyone edit root topics with revision tracking. + +## Architecture + +``` +Namespace (book) → pandoc → all formats (default) + └── Root Node (chapter) → pandoc → all formats (default) + └── Reply (section) → pandoc → on-demand only +``` + +### Data model changes + +1. **Node.source_format** — `Unicode(16)`, default `'markdown'`. Tracks what + syntax the user wrote in (markdown, html, rst, mediawiki, latex, textile, etc). + Pandoc input formats map directly. + +2. **rb_revision** — new table for wiki mode edit history. + - `id` (UUIDType, PK) + - `node_id` (FK → Node) + - `user_id` (FK → User) + - `data` (UnicodeText) — raw source at time of edit + - `source_format` (Unicode(16)) + - `created` (BigInteger) — timestamp + - `revision_number` (Integer) — sequential per node + +3. **Namespace.wiki** — already exists (`Boolean, default=False`), needs + implementation. When True, any authenticated user can edit the root node + of any thread. Each edit creates a revision. + +### Export pipeline + +Pandoc subprocess. Input = markdown/rst/html/whatever `source_format` says. +Output = every format pandoc supports. + +**Default generated (cached on write/edit):** +- Namespace level: one document per namespace containing all root threads +- Root node level: one document per root thread + +**On-demand (generated per request):** +- Any node or subthread at any depth + +**Pandoc output formats** (67 total, subset for default generation): +- markdown, gfm, commonmark, html5, pdf, epub, docx, odt, rst, + mediawiki, latex, man, plain, rtf, asciidoc, textile, org, json + +**URI scheme:** +``` +/api/v1/export/namespace/{name}.{format} — full book +/api/v1/export/threads/{node_id}.{format} — single chapter +/api/v1/export/nodes/{node_id}.{format} — on-demand subthread +``` + +### Content ingestion + +On write (create thread, reply, edit): +1. Accept `source_format` parameter (default: `markdown`) +2. If HTML input, strip to clean body (no head/script/style) +3. Store raw source in `Node.data` with `Node.source_format` +4. Render to HTML via pandoc: `pandoc -f {source_format} -t html5` +5. Sanitize HTML output through existing bleach pipeline +6. Store in `Node.data_html` + +### Wiki mode + +When `Namespace.wiki = True`: +- Any authenticated user can edit root nodes (not just owner/moderator) +- Each edit stores a revision in `rb_revision` before overwriting +- Root node always has the latest version (fast render) +- Revision history accessible via API +- Diff between revisions on-demand + +### Progressive enhancement + +Export menus work without JS (plain links to format URIs). +With JS: dropdown/popover with format picker, async download. + +## Phases + +### Phase 1: Export pipeline (pandoc integration) +- [ ] Tree-to-markdown renderer (walk node tree → single markdown document) +- [ ] Export API endpoints (namespace, thread, node) +- [ ] Pandoc subprocess wrapper +- [ ] Format negotiation (URI suffix or Accept header) + +### Phase 2: Multi-syntax input +- [ ] Add `source_format` column to Node +- [ ] Alembic migration +- [ ] Modify `set_data()` to use pandoc for non-markdown formats +- [ ] Accept `source_format` on create/reply/edit API endpoints +- [ ] HTML stripping for HTML input + +### Phase 3: Wiki mode + revisions +- [ ] Create `rb_revision` model + migration +- [ ] Implement wiki edit permissions in Namespace +- [ ] Store revisions on edit +- [ ] Revision history API endpoint +- [ ] Diff endpoint + +### Phase 4: Auto-generated themes +- [ ] Per-namespace theme generation (light + dark) +- [ ] CSS custom properties for theming +- [ ] Theme preview + +## Notes + +- Pandoc 3.1.3 installed at `/usr/bin/pandoc` +- 43 input formats, 67 output formats +- PDF requires LaTeX (`pdflatex`) or `wkhtmltopdf` — check availability +- `Namespace.wiki` column already exists in schema, just needs implementation +- Export cache invalidation: on any node edit in the tree diff --git a/docs/tickets/index.md b/docs/tickets/index.md index 0c40fb6..2224d3f 100644 --- a/docs/tickets/index.md +++ b/docs/tickets/index.md @@ -19,3 +19,4 @@ Tracked issues from the meta.remarkbox.com and faq.remarkbox.com audit (2026-02- | [T12](12.md) | Reply to API-only CRUD thread confirming done | resolved | low | meta `6db01560` | | [T13](13.md) | Reply to lock/archive thread confirming done | resolved | low | meta `7e9d5864` | | [T14](14.md) | meta/faq SSL outage — missing proxy blocks | resolved | critical | postmortem 2026-02-25 | +| [T15](15.md) | Operation Undigg — Pandoc export & wiki mode | in-progress | high | fox directive 2026-03-09 | diff --git a/remarkbox/api/__init__.py b/remarkbox/api/__init__.py index 5015517..1d67c2d 100644 --- a/remarkbox/api/__init__.py +++ b/remarkbox/api/__init__.py @@ -4,7 +4,11 @@ def includeme(config): config.add_route("api-threads-search", "/api/v1/threads/search") config.add_route("api-thread-detail", "/api/v1/threads/{node_id}") config.add_route("api-thread-replies", "/api/v1/threads/{node_id}/replies") + # Wiki / revision routes (must come before api-node-detail catch-all) + config.add_route("api-node-revisions", "/api/v1/nodes/{node_id}/revisions") + config.add_route("api-node-wiki-edit", "/api/v1/nodes/{node_id}/wiki-edit") config.add_route("api-node-detail", "/api/v1/nodes/{node_id}") + config.add_route("api-revision-detail", "/api/v1/revisions/{revision_id}") config.add_route("api-auth-login", "/api/v1/auth/login") config.add_route("api-auth-verify", "/api/v1/auth/verify") config.add_route("api-user-profile", "/api/v1/user/profile") @@ -13,4 +17,15 @@ def includeme(config): config.add_route("api-webmention", "/api/v1/webmention") config.add_route("api-admin-namespaces", "/api/v1/admin/namespaces") config.add_route("api-admin-recent-nodes", "/api/v1/admin/recent-nodes") + # Export routes — {subpath} captures "name.format" or "node_id.format" + config.add_route("api-export-formats", "/api/v1/export/formats") + config.add_route("api-export-namespace", "/api/v1/export/namespace/{subpath}") + config.add_route("api-export-thread", "/api/v1/export/threads/{subpath}") + config.add_route("api-export-node", "/api/v1/export/nodes/{subpath}") + # Theme routes + config.add_route("api-namespace-theme", "/api/v1/themes/{namespace_name}/css") + config.add_route("api-theme-preview", "/api/v1/themes/{namespace_name}/preview") config.scan("remarkbox.api.views") + config.scan("remarkbox.api.export") + config.scan("remarkbox.api.wiki") + config.scan("remarkbox.api.themes") diff --git a/remarkbox/api/export.py b/remarkbox/api/export.py new file mode 100644 index 0000000..cdf0416 --- /dev/null +++ b/remarkbox/api/export.py @@ -0,0 +1,302 @@ +"""Export API — render threads and namespaces via pandoc. + +URI scheme: + GET /api/v1/export/namespace/{name}.{format} — full book + GET /api/v1/export/threads/{node_id}.{format} — single chapter + GET /api/v1/export/nodes/{node_id}.{format} — on-demand subthread + GET /api/v1/export/formats — list available formats +""" + +import logging +import subprocess + +from pyramid.response import Response +from pyramid.view import view_config + +from remarkbox.models.namespace import get_namespace_by_name +from remarkbox.models.node import ( + get_node_by_id, + get_nodes_who_share_root, +) + +from remarkbox.lib.pandoc import ( + convert, + node_tree_to_markdown, + namespace_to_markdown, + get_available_output_formats, + CONTENT_TYPES, + FILE_EXTENSIONS, + BINARY_FORMATS, +) + +from .views import check_namespace_api_access + +log = logging.getLogger(__name__) + + +def _parse_format_from_subpath(subpath): + """Extract format from the subpath (e.g. 'abc-123.epub' -> ('abc-123', 'epub')).""" + if "." in subpath: + parts = subpath.rsplit(".", 1) + return parts[0], parts[1] + return subpath, "markdown" + + +def _export_response(content, to_format, filename_stem): + """Build a Response for exported content.""" + content_type = CONTENT_TYPES.get(to_format, "application/octet-stream") + ext = FILE_EXTENSIONS.get(to_format, "") + filename = "{}{}".format(filename_stem, ext) + + is_binary = isinstance(content, bytes) + + response = Response( + body=content if is_binary else content.encode("utf-8"), + content_type=content_type, + ) + response.content_disposition = 'attachment; filename="{}"'.format(filename) + return response + + +# --------------------------------------------------------------------------- +# List formats +# --------------------------------------------------------------------------- + + +@view_config( + route_name="api-export-formats", + request_method="GET", + renderer="json", + require_csrf=False, +) +def api_export_formats(request): + """List all available export formats.""" + available = sorted(get_available_output_formats()) + return { + "formats": available, + "count": len(available), + } + + +# --------------------------------------------------------------------------- +# Export thread (single root node = chapter) +# --------------------------------------------------------------------------- + + +@view_config( + route_name="api-export-thread", + request_method="GET", + require_csrf=False, +) +def api_export_thread(request): + """Export a single thread (root node + all replies) in any pandoc format.""" + subpath = request.matchdict["subpath"] + node_id, to_format = _parse_format_from_subpath(subpath) + + available = get_available_output_formats() + if to_format not in available: + request.response.status_code = 400 + request.response.content_type = "application/json" + request.response.json_body = { + "error": "Unsupported format: {}".format(to_format), + "available": sorted(available), + } + return request.response + + node = get_node_by_id(request.dbsession, node_id) + if node is None: + request.response.status_code = 404 + request.response.content_type = "application/json" + request.response.json_body = {"error": "Thread not found"} + return request.response + + root = node if node.is_root else node.root + namespace = root.namespace + + denied = check_namespace_api_access(request, namespace) + if denied: + request.response.content_type = "application/json" + request.response.json_body = denied + return request.response + + # Fetch all visible nodes in the thread + nodes = get_nodes_who_share_root( + request.dbsession, root, + exclude_root=True, + visibility_filters={"disabled": False}, + ).all() + + # Render tree to markdown + md = node_tree_to_markdown(root, nodes) + title = root.title or str(root.id) + + if to_format in ("markdown", "gfm", "commonmark"): + # Short-circuit: already markdown, no pandoc needed + return _export_response(md, to_format, root.slug or str(root.id)) + + try: + output = convert(md, from_format="markdown", to_format=to_format, title=title) + except subprocess.CalledProcessError as e: + log.error("Pandoc conversion failed: %s", e.stderr) + request.response.status_code = 500 + request.response.content_type = "application/json" + request.response.json_body = { + "error": "Conversion failed", + "detail": e.stderr[:500] if e.stderr else "unknown error", + } + return request.response + + return _export_response(output, to_format, root.slug or str(root.id)) + + +# --------------------------------------------------------------------------- +# Export namespace (full book) +# --------------------------------------------------------------------------- + + +@view_config( + route_name="api-export-namespace", + request_method="GET", + require_csrf=False, +) +def api_export_namespace(request): + """Export an entire namespace as a book in any pandoc format.""" + subpath = request.matchdict["subpath"] + name, to_format = _parse_format_from_subpath(subpath) + + available = get_available_output_formats() + if to_format not in available: + request.response.status_code = 400 + request.response.content_type = "application/json" + request.response.json_body = { + "error": "Unsupported format: {}".format(to_format), + "available": sorted(available), + } + return request.response + + namespace = get_namespace_by_name(request.dbsession, name) + if namespace is None: + request.response.status_code = 404 + request.response.content_type = "application/json" + request.response.json_body = {"error": "Namespace not found"} + return request.response + + denied = check_namespace_api_access(request, namespace) + if denied: + request.response.content_type = "application/json" + request.response.json_body = denied + return request.response + + roots = namespace.visible_roots.all() + + def node_fetcher(root): + return get_nodes_who_share_root( + request.dbsession, root, + exclude_root=True, + visibility_filters={"disabled": False}, + ).all() + + md = namespace_to_markdown(namespace, roots, node_fetcher) + title = namespace.description or namespace.name + + if to_format in ("markdown", "gfm", "commonmark"): + return _export_response(md, to_format, name) + + try: + output = convert(md, from_format="markdown", to_format=to_format, title=title) + except subprocess.CalledProcessError as e: + log.error("Pandoc conversion failed: %s", e.stderr) + request.response.status_code = 500 + request.response.content_type = "application/json" + request.response.json_body = { + "error": "Conversion failed", + "detail": e.stderr[:500] if e.stderr else "unknown error", + } + return request.response + + return _export_response(output, to_format, name) + + +# --------------------------------------------------------------------------- +# Export node (on-demand subthread at any depth) +# --------------------------------------------------------------------------- + + +@view_config( + route_name="api-export-node", + request_method="GET", + require_csrf=False, +) +def api_export_node(request): + """Export a node and its subtree in any pandoc format (on-demand).""" + subpath = request.matchdict["subpath"] + node_id, to_format = _parse_format_from_subpath(subpath) + + available = get_available_output_formats() + if to_format not in available: + request.response.status_code = 400 + request.response.content_type = "application/json" + request.response.json_body = { + "error": "Unsupported format: {}".format(to_format), + "available": sorted(available), + } + return request.response + + node = get_node_by_id(request.dbsession, node_id) + if node is None: + request.response.status_code = 404 + request.response.content_type = "application/json" + request.response.json_body = {"error": "Node not found"} + return request.response + + namespace = node.root.namespace + + denied = check_namespace_api_access(request, namespace) + if denied: + request.response.content_type = "application/json" + request.response.json_body = denied + return request.response + + # For a non-root node, we need to get its subtree. + # Fetch all nodes in the root's tree, then filter to descendants. + from remarkbox.models.node import get_graph_from_nodes, flatten_graph + all_nodes = get_nodes_who_share_root( + request.dbsession, node.root, + visibility_filters={"disabled": False}, + ).all() + + # Include root so the graph is complete + all_with_root = [node.root] + list(all_nodes) if not node.is_root else list(all_nodes) + + graph = get_graph_from_nodes(all_with_root) + + # Get descendant IDs of the target node + if node.id in graph: + descendant_ids = set(flatten_graph(node.id, graph, include_given_node_id=False)) + else: + descendant_ids = set() + + # Filter to just the subtree + subtree_nodes = [n for n in all_nodes if n.id in descendant_ids] + + md = node_tree_to_markdown(node, subtree_nodes, include_root=True) + title = node.title or "Thread {}".format(str(node.id)[:8]) + + if to_format in ("markdown", "gfm", "commonmark"): + slug = node.slug or str(node.id) + return _export_response(md, to_format, slug) + + try: + output = convert(md, from_format="markdown", to_format=to_format, title=title) + except subprocess.CalledProcessError as e: + log.error("Pandoc conversion failed: %s", e.stderr) + request.response.status_code = 500 + request.response.content_type = "application/json" + request.response.json_body = { + "error": "Conversion failed", + "detail": e.stderr[:500] if e.stderr else "unknown error", + } + return request.response + + slug = node.slug or str(node.id) + return _export_response(output, to_format, slug) diff --git a/remarkbox/api/remarkbox_client.py b/remarkbox/api/remarkbox_client.py index c656ecb..9d0c9a0 100644 --- a/remarkbox/api/remarkbox_client.py +++ b/remarkbox/api/remarkbox_client.py @@ -225,15 +225,18 @@ class RemarkboxClient: path += "?" + urllib.parse.urlencode(params) return self._request("GET", path) - def create_thread(self, namespace, title, data, anonymous_name=None, email=None): + def create_thread(self, namespace, title, data, anonymous_name=None, email=None, + source_format=None): """Create a new thread. Args: namespace: Target namespace name title: Thread title - data: Markdown content (max 500000 chars) + data: Content (max 500000 chars) anonymous_name: Name for anonymous posting (optional) email: Email to associate with post (optional) + source_format: Input format — markdown (default), html, rst, + mediawiki, latex, textile, org, etc. (any pandoc input format) Returns: dict with keys: node, verified @@ -243,18 +246,21 @@ class RemarkboxClient: body["anonymous_name"] = anonymous_name if email: body["email"] = email + if source_format: + body["source_format"] = source_format return self._request("POST", "/api/v1/threads", body) # ----- Replies ----- - def reply(self, node_id, data, anonymous_name=None, email=None): + def reply(self, node_id, data, anonymous_name=None, email=None, source_format=None): """Reply to a thread or another reply. Args: node_id: UUID of the parent node (thread or reply) - data: Markdown content (max 500000 chars) + data: Content (max 500000 chars) anonymous_name: Name for anonymous posting (optional) email: Email to associate with post (optional) + source_format: Input format (default: markdown) Returns: dict with keys: node, verified @@ -264,6 +270,8 @@ class RemarkboxClient: body["anonymous_name"] = anonymous_name if email: body["email"] = email + if source_format: + body["source_format"] = source_format return self._request("POST", "/api/v1/threads/{}/replies".format(node_id), body) # ----- Nodes ----- @@ -279,13 +287,14 @@ class RemarkboxClient: """ return self._request("GET", "/api/v1/nodes/{}".format(node_id)) - def edit_node(self, node_id, data=None, title=None): + def edit_node(self, node_id, data=None, title=None, source_format=None): """Edit a node (requires authentication). Args: node_id: UUID of the node to edit - data: New markdown content (optional) + data: New content (optional) title: New title, only for root nodes (optional) + source_format: Input format (default: markdown) Returns: dict with key: node @@ -295,6 +304,8 @@ class RemarkboxClient: body["data"] = data if title is not None: body["title"] = title + if source_format is not None: + body["source_format"] = source_format if not body: raise ValueError("data or title is required") return self._request("PATCH", "/api/v1/nodes/{}".format(node_id), body) @@ -431,6 +442,145 @@ class RemarkboxClient: """ return self._request("PATCH", "/api/v1/user/profile", {"name": name}) + # ----- Export ----- + + def export_formats(self): + """List all available export formats. + + Returns: + dict with keys: formats (list of strings), count + """ + return self._request("GET", "/api/v1/export/formats") + + def export_thread(self, node_id, fmt="markdown"): + """Export a thread in the specified format. + + Args: + node_id: UUID of the root thread node + fmt: Pandoc output format (e.g. markdown, html5, pdf, epub, docx) + + Returns: + bytes (binary formats) or str (text formats) + """ + return self._raw_request( + "GET", "/api/v1/export/threads/{}.{}".format(node_id, fmt) + ) + + def export_namespace(self, namespace, fmt="markdown"): + """Export an entire namespace as a book in the specified format. + + Args: + namespace: Namespace name (e.g. "meta.remarkbox.com") + fmt: Pandoc output format + + Returns: + bytes (binary formats) or str (text formats) + """ + return self._raw_request( + "GET", "/api/v1/export/namespace/{}.{}".format(namespace, fmt) + ) + + def export_node(self, node_id, fmt="markdown"): + """Export a node and its subtree in the specified format (on-demand). + + Args: + node_id: UUID of the node + fmt: Pandoc output format + + Returns: + bytes (binary formats) or str (text formats) + """ + return self._raw_request( + "GET", "/api/v1/export/nodes/{}.{}".format(node_id, fmt) + ) + + def _raw_request(self, method, path): + """Make an HTTP request and return raw response body.""" + url = self.url + path + req = urllib.request.Request(url, method=method) + try: + resp = self._opener.open(req) + content_type = resp.headers.get("Content-Type", "") + body = resp.read() + if "text/" in content_type or "json" in content_type or "xml" in content_type: + return body.decode("utf-8") + return body + except urllib.error.HTTPError as e: + raw = e.read().decode("utf-8") + try: + body = json.loads(raw) + except Exception: + body = {"error": raw} + raise RemarkboxError(e.code, body) + + # ----- Wiki / Revisions ----- + + def wiki_edit(self, node_id, data, source_format=None): + """Wiki-edit a node (creates revision, any authenticated user if wiki mode). + + Args: + node_id: UUID of the node to edit + data: New content + source_format: Input format (default: markdown) + + Returns: + dict with key: node + """ + body = {"data": data} + if source_format: + body["source_format"] = source_format + return self._request("POST", "/api/v1/nodes/{}/wiki-edit".format(node_id), body) + + def get_revisions(self, node_id): + """Get revision history for a node. + + Args: + node_id: UUID of the node + + Returns: + dict with keys: node_id, revisions, count + """ + return self._request("GET", "/api/v1/nodes/{}/revisions".format(node_id)) + + def get_revision(self, revision_id): + """Get a specific revision by ID. + + Args: + revision_id: UUID of the revision + + Returns: + dict with key: revision + """ + return self._request("GET", "/api/v1/revisions/{}".format(revision_id)) + + # ----- Themes ----- + + def get_theme_css(self, namespace): + """Get the auto-generated theme CSS for a namespace. + + Args: + namespace: Namespace name + + Returns: + CSS string + """ + return self._raw_request( + "GET", "/api/v1/themes/{}/css".format(namespace) + ) + + def get_theme_preview(self, namespace): + """Get theme palette preview for a namespace. + + Args: + namespace: Namespace name + + Returns: + dict with keys: namespace, palette, css_url + """ + return self._request( + "GET", "/api/v1/themes/{}/preview".format(namespace) + ) + # ----- Admin (superuser only) ----- def admin_list_namespaces(self): @@ -477,11 +627,15 @@ Commands: delete Delete a node (moderator only) login Request OTP verify Verify OTP + formats List export formats + export-thread [format] Export thread (default: markdown) + export-ns [format] Export namespace as book + export-node [format] Export node subtree Examples: python remarkbox_client.py https://my.remarkbox.com threads meta.remarkbox.com - python remarkbox_client.py https://my.remarkbox.com post meta.remarkbox.com "Hello" "World" MyBot - python remarkbox_client.py https://my.remarkbox.com disable + python remarkbox_client.py https://my.remarkbox.com export-ns meta.remarkbox.com epub + python remarkbox_client.py https://my.remarkbox.com export-thread pdf """ if len(sys.argv) < 3: @@ -522,6 +676,32 @@ Examples: result = client.login(args[0]) elif cmd == "verify" and len(args) >= 2: result = client.verify(args[0], args[1]) + elif cmd == "formats": + result = client.export_formats() + elif cmd == "export-thread" and len(args) >= 1: + fmt = args[1] if len(args) > 1 else "markdown" + output = client.export_thread(args[0], fmt) + if isinstance(output, bytes): + sys.stdout.buffer.write(output) + else: + print(output) + sys.exit(0) + elif cmd == "export-ns" and len(args) >= 1: + fmt = args[1] if len(args) > 1 else "markdown" + output = client.export_namespace(args[0], fmt) + if isinstance(output, bytes): + sys.stdout.buffer.write(output) + else: + print(output) + sys.exit(0) + elif cmd == "export-node" and len(args) >= 1: + fmt = args[1] if len(args) > 1 else "markdown" + output = client.export_node(args[0], fmt) + if isinstance(output, bytes): + sys.stdout.buffer.write(output) + else: + print(output) + sys.exit(0) else: print(usage) sys.exit(1) diff --git a/remarkbox/api/serializers.py b/remarkbox/api/serializers.py index be28ecf..3093500 100644 --- a/remarkbox/api/serializers.py +++ b/remarkbox/api/serializers.py @@ -7,6 +7,7 @@ def serialize_node(node, include_children=False): "title": node.title, "data": node.data, "data_html": node.data_html, + "source_format": node.source_format, "is_root": node.is_root, "depth": node.graph_depth, "created": node.created, diff --git a/remarkbox/api/themes.py b/remarkbox/api/themes.py new file mode 100644 index 0000000..f3375c8 --- /dev/null +++ b/remarkbox/api/themes.py @@ -0,0 +1,80 @@ +"""Theme generation API endpoints.""" + +from pyramid.response import Response +from pyramid.view import view_config + +from remarkbox.models.namespace import get_namespace_by_name +from remarkbox.lib.theme_generator import generate_theme_css + + +@view_config( + route_name="api-namespace-theme", + request_method="GET", + require_csrf=False, +) +def api_namespace_theme(request): + """Generate and serve a theme CSS for a namespace. + + The theme is deterministic -- same namespace always gets the same theme. + Cache-friendly: can be cached indefinitely (changes only if we change the algorithm). + """ + namespace_name = request.matchdict["namespace_name"] + + # Validate namespace exists + namespace = get_namespace_by_name(request.dbsession, namespace_name) + if namespace is None: + request.response.status_code = 404 + request.response.content_type = "application/json" + request.response.json_body = {"error": "Namespace not found"} + return request.response + + css = generate_theme_css(namespace_name) + + response = Response( + body=css, + content_type="text/css; charset=utf-8", + ) + # Cache for 1 day -- theme is deterministic but we might update the algorithm + response.cache_control.max_age = 86400 + response.cache_control.public = True + return response + + +@view_config( + route_name="api-theme-preview", + request_method="GET", + renderer="json", + require_csrf=False, +) +def api_theme_preview(request): + """Preview theme variables for a namespace (JSON format). + + Useful for theme customization UI -- shows the computed palette + without needing to parse CSS. + """ + namespace_name = request.matchdict["namespace_name"] + + namespace = get_namespace_by_name(request.dbsession, namespace_name) + if namespace is None: + request.response.status_code = 404 + return {"error": "Namespace not found"} + + from remarkbox.lib.theme_generator import _name_to_seed + seed = _name_to_seed(namespace_name) + + hue = seed[0] % 360 + hue_offset = 30 + (seed[1] % 30) + secondary_hue = (hue + hue_offset) % 360 + accent_hue = (hue + 180 + (seed[2] % 40 - 20)) % 360 + sat_base = 40 + (seed[3] % 25) + + return { + "namespace": namespace_name, + "palette": { + "primary_hue": hue, + "secondary_hue": secondary_hue, + "accent_hue": accent_hue, + "saturation_base": sat_base, + }, + "css_url": "/api/v1/themes/{}/css".format(namespace_name), + } diff --git a/remarkbox/api/views.py b/remarkbox/api/views.py index fb0fe67..9f4826d 100644 --- a/remarkbox/api/views.py +++ b/remarkbox/api/views.py @@ -29,6 +29,7 @@ from remarkbox.models.sudo_otp import create_sudo_otp, verify_sudo_otp from remarkbox.lib.notify import schedule_notifications from remarkbox.views import verify_pending_nodes_in_session +from remarkbox.models.meta import now_timestamp from remarkbox.models.spam import score_content from remarkbox.models.spam_llm import check_thread_relevance, check_reply_relevance @@ -473,6 +474,8 @@ def api_create_thread(request): body.get("anonymous_name") or request.params.get("anonymous_name", "") ).strip() email = body.get("email") or request.params.get("email", "") + source_format = body.get("source_format") or request.params.get("source_format", "") + source_format = source_format.strip() if source_format else None if not namespace_name: request.response.status_code = 400 @@ -538,7 +541,8 @@ def api_create_thread(request): # Create the comment as a child of the root (same as api_reply) node = root.new_child() node.ip_address = str(request.client_addr) - node.set_data(data, namespace=namespace, dbsession=request.dbsession) + node.set_data(data, namespace=namespace, dbsession=request.dbsession, + source_format=source_format) if user_surrogate: node.user_surrogate = user_surrogate @@ -583,7 +587,7 @@ def api_create_thread(request): node.namespace = namespace node.ip_address = str(request.client_addr) node.title = title - node.set_data(data, dbsession=request.dbsession) + node.set_data(data, dbsession=request.dbsession, source_format=source_format) if user_surrogate: node.user_surrogate = user_surrogate @@ -661,6 +665,8 @@ def api_reply(request): body.get("anonymous_name") or request.params.get("anonymous_name", "") ).strip() email = body.get("email") or request.params.get("email", "") + source_format = body.get("source_format") or request.params.get("source_format", "") + source_format = source_format.strip() if source_format else None if not data: request.response.status_code = 400 @@ -706,7 +712,8 @@ def api_reply(request): # Create child node child = parent.new_child() child.ip_address = str(request.client_addr) - child.set_data(data, namespace=namespace, dbsession=request.dbsession) + child.set_data(data, namespace=namespace, dbsession=request.dbsession, + source_format=source_format) if user_surrogate: child.user_surrogate = user_surrogate @@ -812,6 +819,8 @@ def api_edit_node(request): body = get_json_body(request) data = body.get("data") or request.params.get("thread_data", "") title = body.get("title") or request.params.get("thread_title", "") + source_format = body.get("source_format") or request.params.get("source_format", "") + source_format = source_format.strip() if source_format else None # Moderation flags (require can_alter_node, already checked above) disabled = body.get("disabled") @@ -837,7 +846,9 @@ def api_edit_node(request): if title and node.is_root: node.title = title if data: - node.edit(data) + node.set_data(data, source_format=source_format) + node.changed = now_timestamp() + node._invalidate_cache() if disabled is True: node.disable() diff --git a/remarkbox/api/wiki.py b/remarkbox/api/wiki.py new file mode 100644 index 0000000..19b7e45 --- /dev/null +++ b/remarkbox/api/wiki.py @@ -0,0 +1,134 @@ +"""Wiki mode and revision history API endpoints.""" + +from pyramid.view import view_config + +from remarkbox.models.node import get_node_by_id, Node +from remarkbox.models.revision import Revision + +from .views import check_namespace_api_access, get_json_body, MAX_CONTENT_LENGTH +from .serializers import serialize_node + +from remarkbox.models.meta import get_object_by_id + + +def serialize_revision(rev): + """Serialize a Revision to a dict.""" + return { + "id": str(rev.id), + "node_id": str(rev.node_id), + "user_id": str(rev.user_id) if rev.user_id else None, + "author": rev.user.name if rev.user else None, + "data": rev.data, + "source_format": rev.source_format, + "revision_number": rev.revision_number, + "created": rev.created, + } + + +@view_config( + route_name="api-node-revisions", + request_method="GET", + renderer="json", + require_csrf=False, +) +def api_node_revisions(request): + """Get revision history for a node.""" + node_id = request.matchdict["node_id"] + node = get_node_by_id(request.dbsession, node_id) + + if node is None: + request.response.status_code = 404 + return {"error": "Node not found"} + + namespace = node.root.namespace + denied = check_namespace_api_access(request, namespace) + if denied: + return denied + + revisions = ( + request.dbsession.query(Revision) + .filter(Revision.node_id == node.id) + .order_by(Revision.revision_number.desc()) + .all() + ) + + return { + "node_id": str(node.id), + "revisions": [serialize_revision(r) for r in revisions], + "count": len(revisions), + } + + +@view_config( + route_name="api-node-wiki-edit", + request_method="POST", + renderer="json", + require_csrf=False, +) +def api_wiki_edit(request): + """Wiki-edit a node (creates a revision, any authenticated user if wiki mode).""" + node_id = request.matchdict["node_id"] + node = get_node_by_id(request.dbsession, node_id) + + if node is None: + request.response.status_code = 404 + return {"error": "Node not found"} + + if not request.user or not request.user.authenticated: + request.response.status_code = 401 + return {"error": "Authentication required"} + + namespace = node.root.namespace + + denied = check_namespace_api_access(request, namespace) + if denied: + return denied + + if not namespace.can_wiki_edit(node, request.user): + request.response.status_code = 403 + return {"error": "Wiki editing not permitted for this node"} + + body = get_json_body(request) + data = body.get("data", "") + + if not data: + request.response.status_code = 400 + return {"error": "data is required"} + + if len(data) > MAX_CONTENT_LENGTH: + request.response.status_code = 400 + return {"error": "data exceeds maximum length"} + + source_format = body.get("source_format") + + node.wiki_edit(data, user=request.user, source_format=source_format) + request.dbsession.add(node) + request.dbsession.flush() + + return {"node": serialize_node(node)} + + +@view_config( + route_name="api-revision-detail", + request_method="GET", + renderer="json", + require_csrf=False, +) +def api_get_revision(request): + """Get a specific revision by ID.""" + revision_id = request.matchdict["revision_id"] + + revision = get_object_by_id(request.dbsession, revision_id, Revision) + + if revision is None: + request.response.status_code = 404 + return {"error": "Revision not found"} + + node = get_node_by_id(request.dbsession, revision.node_id) + if node: + namespace = node.root.namespace + denied = check_namespace_api_access(request, namespace) + if denied: + return denied + + return {"revision": serialize_revision(revision)} diff --git a/remarkbox/lib/pandoc.py b/remarkbox/lib/pandoc.py new file mode 100644 index 0000000..443fd47 --- /dev/null +++ b/remarkbox/lib/pandoc.py @@ -0,0 +1,324 @@ +"""Pandoc integration for remarkbox. + +Converts node trees to documents in any format pandoc supports. +Subprocess-based — no pip dependencies. +""" + +import logging +import subprocess +import tempfile +import os + +log = logging.getLogger(__name__) + +# Formats that produce binary output (need file-based output, not stdout text). +BINARY_FORMATS = frozenset({ + "pdf", "docx", "odt", "epub", "epub2", "epub3", "pptx", "fb2", +}) + +# Formats we generate by default for namespace/thread-level export. +DEFAULT_FORMATS = [ + "markdown", "gfm", "commonmark", "html5", "rst", + "mediawiki", "latex", "man", "plain", "rtf", + "asciidoc", "textile", "org", "json", + "epub", "docx", "odt", "pdf", +] + +# Content types for HTTP responses. +CONTENT_TYPES = { + "markdown": "text/markdown; charset=utf-8", + "gfm": "text/markdown; charset=utf-8", + "commonmark": "text/markdown; charset=utf-8", + "commonmark_x": "text/markdown; charset=utf-8", + "html": "text/html; charset=utf-8", + "html5": "text/html; charset=utf-8", + "html4": "text/html; charset=utf-8", + "rst": "text/x-rst; charset=utf-8", + "latex": "application/x-latex; charset=utf-8", + "beamer": "application/x-latex; charset=utf-8", + "man": "text/troff; charset=utf-8", + "plain": "text/plain; charset=utf-8", + "json": "application/json; charset=utf-8", + "mediawiki": "text/plain; charset=utf-8", + "asciidoc": "text/plain; charset=utf-8", + "asciidoctor": "text/plain; charset=utf-8", + "textile": "text/plain; charset=utf-8", + "org": "text/plain; charset=utf-8", + "rtf": "application/rtf", + "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "odt": "application/vnd.oasis.opendocument.text", + "epub": "application/epub+zip", + "epub2": "application/epub+zip", + "epub3": "application/epub+zip", + "pdf": "application/pdf", + "pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "fb2": "application/xml", + "jira": "text/plain; charset=utf-8", + "dokuwiki": "text/plain; charset=utf-8", + "xwiki": "text/plain; charset=utf-8", + "zimwiki": "text/plain; charset=utf-8", + "typst": "text/plain; charset=utf-8", + "context": "text/plain; charset=utf-8", + "texinfo": "text/plain; charset=utf-8", + "opml": "application/xml; charset=utf-8", + "tei": "application/xml; charset=utf-8", + "docbook": "application/xml; charset=utf-8", + "docbook5": "application/xml; charset=utf-8", + "icml": "application/xml; charset=utf-8", +} + +# File extensions for download filenames. +FILE_EXTENSIONS = { + "markdown": ".md", + "gfm": ".md", + "commonmark": ".md", + "commonmark_x": ".md", + "html": ".html", + "html5": ".html", + "html4": ".html", + "rst": ".rst", + "latex": ".tex", + "beamer": ".tex", + "man": ".1", + "plain": ".txt", + "json": ".json", + "mediawiki": ".wiki", + "asciidoc": ".adoc", + "asciidoctor": ".adoc", + "textile": ".textile", + "org": ".org", + "rtf": ".rtf", + "docx": ".docx", + "odt": ".odt", + "epub": ".epub", + "epub2": ".epub", + "epub3": ".epub", + "pdf": ".pdf", + "pptx": ".pptx", + "fb2": ".fb2", + "jira": ".jira", + "dokuwiki": ".txt", + "xwiki": ".txt", + "zimwiki": ".txt", + "typst": ".typ", + "context": ".tex", + "texinfo": ".texi", + "opml": ".opml", + "tei": ".xml", + "docbook": ".xml", + "docbook5": ".xml", + "icml": ".icml", +} + + +def get_available_output_formats(): + """Return the set of output formats pandoc supports on this system.""" + try: + result = subprocess.run( + ["pandoc", "--list-output-formats"], + capture_output=True, text=True, timeout=5, + ) + return frozenset(result.stdout.strip().split("\n")) + except Exception: + log.exception("Failed to query pandoc output formats") + return frozenset() + + +def get_available_input_formats(): + """Return the set of input formats pandoc supports on this system.""" + try: + result = subprocess.run( + ["pandoc", "--list-input-formats"], + capture_output=True, text=True, timeout=5, + ) + return frozenset(result.stdout.strip().split("\n")) + except Exception: + log.exception("Failed to query pandoc input formats") + return frozenset() + + +def convert(source, from_format="markdown", to_format="html5", title=None): + """Convert source text from one format to another via pandoc. + + Args: + source: Input text. + from_format: Pandoc input format name. + to_format: Pandoc output format name. + title: Optional document title (sets pandoc metadata). + + Returns: + bytes for binary formats (pdf, docx, etc.), str for text formats. + + Raises: + subprocess.CalledProcessError on pandoc failure. + ValueError if format is not supported. + """ + is_binary = to_format in BINARY_FORMATS + + cmd = ["pandoc", "-f", from_format, "-t", to_format, "--standalone"] + + if title: + cmd.extend(["--metadata", "title={}".format(title)]) + + # PDF needs explicit engine since no pdflatex. + if to_format == "pdf": + cmd.extend(["--pdf-engine=wkhtmltopdf"]) + + if is_binary: + # Binary formats need file output. + with tempfile.NamedTemporaryFile( + suffix=FILE_EXTENSIONS.get(to_format, ""), delete=False + ) as tmp: + tmp_path = tmp.name + + try: + cmd.extend(["-o", tmp_path]) + subprocess.run( + cmd, input=source, text=True, + capture_output=True, timeout=60, check=True, + ) + with open(tmp_path, "rb") as f: + return f.read() + finally: + try: + os.unlink(tmp_path) + except OSError: + pass + else: + result = subprocess.run( + cmd, input=source, text=True, + capture_output=True, timeout=30, check=True, + ) + return result.stdout + + +def node_tree_to_markdown(root_node, nodes, include_root=True): + """Render a node tree as a nested markdown document. + + Args: + root_node: The root Node object. + nodes: Iterable of all Node objects in the tree (flat, any order). + include_root: Whether to include the root node content. + + Returns: + Markdown string with the full tree rendered as a document. + """ + # Build lookup: parent_id -> sorted children + children_map = {} + node_map = {} + for node in nodes: + node_map[node.id] = node + pid = node.parent_id + if pid not in children_map: + children_map[pid] = [] + children_map[pid].append(node) + + # Include root in the map + if include_root and root_node.id not in node_map: + node_map[root_node.id] = root_node + + # Sort children by created timestamp + for pid in children_map: + children_map[pid].sort(key=lambda n: n.created) + + lines = [] + + if include_root and root_node.title: + lines.append("# {}".format(root_node.title)) + lines.append("") + + if include_root and root_node.data: + lines.append(root_node.data) + lines.append("") + + def _render_children(parent_id, depth): + for child in children_map.get(parent_id, []): + if child.disabled: + continue + # Heading level based on depth (h2 for first-level replies, etc.) + heading_level = min(depth + 2, 6) + author = _get_author_name(child) + date = child.created_date or "" + lines.append("{} {} — {}".format("#" * heading_level, author, date)) + lines.append("") + if child.data: + lines.append(child.data) + lines.append("") + _render_children(child.id, depth + 1) + + _render_children(root_node.id, 0) + + return "\n".join(lines) + + +def namespace_to_markdown(namespace, roots, node_fetcher): + """Render an entire namespace as a markdown book. + + Args: + namespace: The Namespace object. + roots: Iterable of root Node objects (chapters). + node_fetcher: Callable(root_node) -> list of all nodes in that tree. + + Returns: + Markdown string with the full namespace as a document. + """ + lines = [] + + # Book title + title = namespace.description or namespace.name + lines.append("# {}".format(title)) + lines.append("") + + for root in roots: + if root.disabled: + continue + # Chapter heading + chapter_title = root.title or str(root.id) + lines.append("## {}".format(chapter_title)) + lines.append("") + if root.data: + lines.append(root.data) + lines.append("") + + # Fetch all nodes in this thread + thread_nodes = node_fetcher(root) + + # Build children map for this thread + children_map = {} + for node in thread_nodes: + pid = node.parent_id + if pid not in children_map: + children_map[pid] = [] + children_map[pid].append(node) + + for pid in children_map: + children_map[pid].sort(key=lambda n: n.created) + + def _render(parent_id, depth): + for child in children_map.get(parent_id, []): + if child.disabled: + continue + heading_level = min(depth + 3, 6) + author = _get_author_name(child) + date = child.created_date or "" + lines.append("{} {} — {}".format( + "#" * heading_level, author, date + )) + lines.append("") + if child.data: + lines.append(child.data) + lines.append("") + _render(child.id, depth + 1) + + _render(root.id, 0) + + return "\n".join(lines) + + +def _get_author_name(node): + """Extract display name from a node's user or surrogate.""" + if node.user: + return node.user.name + if node.user_surrogate: + return node.user_surrogate.name + return "Anonymous" diff --git a/remarkbox/lib/theme_generator.py b/remarkbox/lib/theme_generator.py new file mode 100644 index 0000000..1ee4f36 --- /dev/null +++ b/remarkbox/lib/theme_generator.py @@ -0,0 +1,199 @@ +"""Auto-generate unique themes per namespace. + +Deterministic: same namespace name always produces the same theme. +Uses CSS custom properties with prefers-color-scheme for light/dark mode. +""" + +import hashlib +import struct + + +def _name_to_hue(name): + """Deterministically map a namespace name to a hue (0-360).""" + h = hashlib.sha256(name.encode("utf-8")).digest() + return struct.unpack(">H", h[:2])[0] % 360 + + +def _name_to_seed(name): + """Get multiple deterministic values from a name.""" + h = hashlib.sha256(name.encode("utf-8")).digest() + values = struct.unpack(">8H", h[:16]) + return values + + +def generate_theme_css(namespace_name): + """Generate a complete CSS theme for a namespace. + + Args: + namespace_name: The namespace name (e.g. "meta.remarkbox.com") + + Returns: + CSS string with custom properties for light and dark modes. + """ + seed = _name_to_seed(namespace_name) + + # Primary hue from name + hue = seed[0] % 360 + # Secondary hue offset (analogous or complementary) + hue_offset = 30 + (seed[1] % 30) # 30-60 degree offset + secondary_hue = (hue + hue_offset) % 360 + # Accent hue + accent_hue = (hue + 180 + (seed[2] % 40 - 20)) % 360 # near-complementary + + # Saturation variance + sat_base = 40 + (seed[3] % 25) # 40-65% + + css = '''/* Auto-generated theme for {name} */ +/* Deterministic: regenerating from the same name produces identical output */ + +:root, +.theme-light {{ + --rb-bg: hsl({hue}, {sat_bg}%, 97%); + --rb-bg-card: hsl({hue}, {sat_bg}%, 100%); + --rb-bg-nested: hsl({hue}, {sat_bg}%, 96%); + --rb-bg-code: hsl({hue}, {sat_bg}%, 94%); + --rb-text: hsl({hue}, {sat_base}%, 15%); + --rb-text-secondary: hsl({hue}, {sat_muted}%, 35%); + --rb-text-muted: hsl({hue}, {sat_muted}%, 55%); + --rb-link: hsl({accent_hue}, {sat_link}%, 40%); + --rb-link-hover: hsl({accent_hue}, {sat_link}%, 30%); + --rb-link-visited: hsl({secondary_hue}, {sat_muted}%, 45%); + --rb-border: hsl({hue}, {sat_muted}%, 85%); + --rb-border-light: hsl({hue}, {sat_muted}%, 91%); + --rb-accent: hsl({accent_hue}, {sat_accent}%, 45%); + --rb-accent-hover: hsl({accent_hue}, {sat_accent}%, 35%); + --rb-accent-text: hsl({accent_hue}, 5%, 100%); + --rb-blockquote-border: hsl({secondary_hue}, {sat_base}%, 70%); + --rb-avatar-border: hsl({hue}, {sat_muted}%, 80%); + --rb-shadow: 0 1px 3px hsla({hue}, {sat_muted}%, 30%, 0.08); +}} + +@media (prefers-color-scheme: dark) {{ + :root:not(.theme-light) {{ + --rb-bg: hsl({hue}, {sat_dark}%, 10%); + --rb-bg-card: hsl({hue}, {sat_dark}%, 14%); + --rb-bg-nested: hsl({hue}, {sat_dark}%, 12%); + --rb-bg-code: hsl({hue}, {sat_dark}%, 18%); + --rb-text: hsl({hue}, {sat_bg}%, 90%); + --rb-text-secondary: hsl({hue}, {sat_muted}%, 70%); + --rb-text-muted: hsl({hue}, {sat_muted}%, 50%); + --rb-link: hsl({accent_hue}, {sat_link}%, 65%); + --rb-link-hover: hsl({accent_hue}, {sat_link}%, 75%); + --rb-link-visited: hsl({secondary_hue}, {sat_muted}%, 60%); + --rb-border: hsl({hue}, {sat_muted}%, 25%); + --rb-border-light: hsl({hue}, {sat_muted}%, 20%); + --rb-accent: hsl({accent_hue}, {sat_accent}%, 55%); + --rb-accent-hover: hsl({accent_hue}, {sat_accent}%, 65%); + --rb-accent-text: hsl({accent_hue}, 5%, 10%); + --rb-blockquote-border: hsl({secondary_hue}, {sat_base}%, 40%); + --rb-avatar-border: hsl({hue}, {sat_muted}%, 35%); + --rb-shadow: 0 1px 3px hsla({hue}, {sat_muted}%, 5%, 0.3); + }} +}} + +.theme-dark {{ + --rb-bg: hsl({hue}, {sat_dark}%, 10%); + --rb-bg-card: hsl({hue}, {sat_dark}%, 14%); + --rb-bg-nested: hsl({hue}, {sat_dark}%, 12%); + --rb-bg-code: hsl({hue}, {sat_dark}%, 18%); + --rb-text: hsl({hue}, {sat_bg}%, 90%); + --rb-text-secondary: hsl({hue}, {sat_muted}%, 70%); + --rb-text-muted: hsl({hue}, {sat_muted}%, 50%); + --rb-link: hsl({accent_hue}, {sat_link}%, 65%); + --rb-link-hover: hsl({accent_hue}, {sat_link}%, 75%); + --rb-link-visited: hsl({secondary_hue}, {sat_muted}%, 60%); + --rb-border: hsl({hue}, {sat_muted}%, 25%); + --rb-border-light: hsl({hue}, {sat_muted}%, 20%); + --rb-accent: hsl({accent_hue}, {sat_accent}%, 55%); + --rb-accent-hover: hsl({accent_hue}, {sat_accent}%, 65%); + --rb-accent-text: hsl({accent_hue}, 5%, 10%); + --rb-blockquote-border: hsl({secondary_hue}, {sat_base}%, 40%); + --rb-avatar-border: hsl({hue}, {sat_muted}%, 35%); + --rb-shadow: 0 1px 3px hsla({hue}, {sat_muted}%, 5%, 0.3); +}} + +/* Apply theme variables to remarkbox elements */ +body {{ + background-color: var(--rb-bg); + color: var(--rb-text); +}} + +.node {{ + background-color: var(--rb-bg-card); + border-color: var(--rb-border-light); + box-shadow: var(--rb-shadow); +}} + +.node .node {{ + background-color: var(--rb-bg-nested); +}} + +a {{ + color: var(--rb-link); +}} + +a:hover {{ + color: var(--rb-link-hover); +}} + +a:visited {{ + color: var(--rb-link-visited); +}} + +.text-muted, .rb-meta {{ + color: var(--rb-text-muted); +}} + +pre, code {{ + background-color: var(--rb-bg-code); + border-color: var(--rb-border); +}} + +blockquote {{ + border-left-color: var(--rb-blockquote-border); + color: var(--rb-text-secondary); +}} + +.rb-submit, .btn-primary {{ + background-color: var(--rb-accent); + color: var(--rb-accent-text); + border-color: var(--rb-accent); +}} + +.rb-submit:hover, .btn-primary:hover {{ + background-color: var(--rb-accent-hover); + border-color: var(--rb-accent-hover); +}} + +.nested-avatar {{ + border-color: var(--rb-avatar-border); +}} + +hr {{ + border-color: var(--rb-border-light); +}} + +textarea, input[type="text"], input[type="email"] {{ + background-color: var(--rb-bg-card); + color: var(--rb-text); + border-color: var(--rb-border); +}} + +textarea:focus, input:focus {{ + border-color: var(--rb-accent); + outline-color: var(--rb-accent); +}} +'''.format( + name=namespace_name, + hue=hue, + secondary_hue=secondary_hue, + accent_hue=accent_hue, + sat_base=sat_base, + sat_bg=max(sat_base - 25, 5), + sat_muted=max(sat_base - 15, 10), + sat_link=min(sat_base + 15, 75), + sat_accent=min(sat_base + 20, 80), + sat_dark=max(sat_base - 30, 8), + ) + + return css diff --git a/remarkbox/models/__init__.py b/remarkbox/models/__init__.py index ab62407..4bd4414 100644 --- a/remarkbox/models/__init__.py +++ b/remarkbox/models/__init__.py @@ -21,6 +21,7 @@ from .pay_what_you_can import * from .payment import * from .webmention import * from .sudo_otp import * +from .revision import * # run configure_mappers after defining all of the models to ensure # all relationships can be setup diff --git a/remarkbox/models/meta.py b/remarkbox/models/meta.py index 0dfa62f..78e8fc6 100644 --- a/remarkbox/models/meta.py +++ b/remarkbox/models/meta.py @@ -41,6 +41,7 @@ CLASS_TO_TABLE = { "PayWhatYouCan": "rb_pay_what_you_can", "Payment": "rb_payment", "Webmention": "rb_webmention", + "Revision": "rb_revision", } # node (threads), namespace (forum) diff --git a/remarkbox/models/namespace.py b/remarkbox/models/namespace.py index 2714600..b57e00f 100644 --- a/remarkbox/models/namespace.py +++ b/remarkbox/models/namespace.py @@ -393,6 +393,20 @@ class Namespace(RBase, Base): def can_alter_node(self, node, user): return self.is_moderator(user) or node.is_owner(user) + def can_wiki_edit(self, node, user): + """Return True if user can wiki-edit this node. + + Wiki mode: any authenticated user can edit root nodes. + Normal mode: only owner/moderator can edit. + """ + if not user or not user.authenticated: + return False + if self.can_alter_node(node, user): + return True + if self.wiki and node.is_root: + return True + return False + def can_see_node(self, node, user): visible = True if node.disabled: diff --git a/remarkbox/models/node.py b/remarkbox/models/node.py index 992ec76..f4fb578 100644 --- a/remarkbox/models/node.py +++ b/remarkbox/models/node.py @@ -71,6 +71,7 @@ class Node(RBase, Base): title = Column(Unicode(256), default=None) data = Column(UnicodeText, default=None) data_html = Column(UnicodeText, default=None) + source_format = Column(Unicode(16), default="markdown", nullable=False) # the depth of this node in the thread's graph / tree. graph_depth = Column(Integer, nullable=False, default=-1) # TODO: someday this should be renamed to created_timestamp @@ -315,13 +316,34 @@ class Node(RBase, Base): def enabled(self): return not self.disabled - def set_data(self, data, namespace=None, dbsession=None): + def set_data(self, data, namespace=None, dbsession=None, source_format=None): if namespace is None: namespace = self.root.namespace if dbsession is None: dbsession = self.dbsession + if source_format is not None: + self.source_format = source_format self.data = data - self.data_html = markdown_to_html(data, namespace, dbsession=dbsession) + if not self.source_format or self.source_format == "markdown": + self.data_html = markdown_to_html(data, namespace, dbsession=dbsession) + elif self.source_format == "html": + from remarkbox.lib.pandoc import convert + # Convert HTML to markdown (clean it), then render through standard pipeline + cleaned = convert(data, from_format="html", to_format="markdown") + self.data = cleaned + self.source_format = "markdown" + self.data_html = markdown_to_html(cleaned, namespace, dbsession=dbsession) + else: + # Any other pandoc input format (rst, mediawiki, latex, etc.) + from remarkbox.lib.pandoc import convert + from remarkbox.lib.render import make_cleaner_from_namespace + from remarkbox.lib.sanitize_html import default_cleaner, clean_raw_html + html = convert(data, from_format=self.source_format, to_format="html5") + if namespace: + cleaner = make_cleaner_from_namespace(namespace) + else: + cleaner = default_cleaner() + self.data_html = clean_raw_html(html, cleaner) def _invalidate_cache(self): if self.root.cache: @@ -333,6 +355,30 @@ class Node(RBase, Base): self.changed = now_timestamp() self._invalidate_cache() + def wiki_edit(self, data, user=None, source_format=None): + """Edit node in wiki mode, creating a revision of the previous state.""" + from .revision import Revision + + # Count existing revisions for this node + rev_count = self.dbsession.query(Revision).filter( + Revision.node_id == self.id + ).count() + + # Save current state as a revision + revision = Revision( + node=self, + user=user, + data=self.data or "", + source_format=self.source_format, + revision_number=rev_count + 1, + ) + self.dbsession.add(revision) + + # Apply the edit + if source_format: + self.source_format = source_format + self.edit(data) + def disable(self): """disable node.""" self.disabled = True diff --git a/remarkbox/models/revision.py b/remarkbox/models/revision.py new file mode 100644 index 0000000..024c876 --- /dev/null +++ b/remarkbox/models/revision.py @@ -0,0 +1,39 @@ +"""Revision model — tracks edit history for wiki mode.""" + +from sqlalchemy import BigInteger, Column, Integer, Unicode, UnicodeText +from sqlalchemy.orm import relationship + +import uuid + +from .meta import Base, RBase, UUIDType, now_timestamp, foreign_key + + +class Revision(RBase, Base): + """Stores a snapshot of node content before each edit. + + When wiki mode is enabled on a namespace, every edit to a root node + creates a revision record preserving the previous state. + """ + id = Column(UUIDType, primary_key=True, index=True) + node_id = Column(UUIDType, foreign_key("Node", "id"), nullable=False, index=True) + user_id = Column(UUIDType, foreign_key("User", "id"), nullable=True) + data = Column(UnicodeText, nullable=False) + source_format = Column(Unicode(16), default="markdown", nullable=False) + revision_number = Column(Integer, nullable=False) + created = Column(BigInteger, nullable=False) + + node = relationship("Node", backref="revisions") + user = relationship("User") + + def __init__(self, node=None, user=None, data="", source_format="markdown", revision_number=1): + self.id = uuid.uuid1() + self.created = now_timestamp() + if node: + self.node_id = node.id + self.node = node + if user: + self.user_id = user.id + self.user = user + self.data = data + self.source_format = source_format + self.revision_number = revision_number diff --git a/remarkbox/scripts/alembic/versions/8e3c406e4049_add_revision_table_for_wiki_mode.py b/remarkbox/scripts/alembic/versions/8e3c406e4049_add_revision_table_for_wiki_mode.py new file mode 100644 index 0000000..07c5cd0 --- /dev/null +++ b/remarkbox/scripts/alembic/versions/8e3c406e4049_add_revision_table_for_wiki_mode.py @@ -0,0 +1,42 @@ +"""add revision table for wiki mode + +Revision ID: 8e3c406e4049 +Revises: 7c624a8fae9e +Create Date: 2026-03-09 23:09:54.651756 + +""" + +# revision identifiers, used by Alembic. +revision = '8e3c406e4049' +down_revision = 'd47dc908d2ea' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + +from sqlalchemy_utils import UUIDType as TempUUIDType +UUIDType = TempUUIDType(binary=False) + + +def upgrade(): + op.create_table('rb_revision', + sa.Column('id', UUIDType, nullable=False), + sa.Column('node_id', UUIDType, nullable=False), + sa.Column('user_id', UUIDType, nullable=True), + sa.Column('data', sa.UnicodeText(), nullable=False), + sa.Column('source_format', sa.Unicode(length=16), nullable=False), + sa.Column('revision_number', sa.Integer(), nullable=False), + sa.Column('created', sa.BigInteger(), nullable=False), + sa.ForeignKeyConstraint(['node_id'], ['rb_node.id']), + sa.ForeignKeyConstraint(['user_id'], ['rb_user.id']), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index(op.f('ix_rb_revision_id'), 'rb_revision', ['id'], unique=False) + op.create_index(op.f('ix_rb_revision_node_id'), 'rb_revision', ['node_id'], unique=False) + + +def downgrade(): + op.drop_index(op.f('ix_rb_revision_node_id'), table_name='rb_revision') + op.drop_index(op.f('ix_rb_revision_id'), table_name='rb_revision') + op.drop_table('rb_revision') diff --git a/remarkbox/scripts/alembic/versions/d47dc908d2ea_add_source_format_to_node.py b/remarkbox/scripts/alembic/versions/d47dc908d2ea_add_source_format_to_node.py new file mode 100644 index 0000000..d011d8c --- /dev/null +++ b/remarkbox/scripts/alembic/versions/d47dc908d2ea_add_source_format_to_node.py @@ -0,0 +1,27 @@ +"""add source_format to node + +Revision ID: d47dc908d2ea +Revises: 7c624a8fae9e +Create Date: 2026-03-09 23:08:19.266162 + +""" + +# revision identifiers, used by Alembic. +revision = 'd47dc908d2ea' +down_revision = '7c624a8fae9e' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.add_column( + 'rb_node', + sa.Column('source_format', sa.Unicode(length=16), nullable=False, server_default='markdown'), + ) + + +def downgrade(): + op.drop_column('rb_node', 'source_format') diff --git a/remarkbox/tests/test_pandoc.py b/remarkbox/tests/test_pandoc.py new file mode 100644 index 0000000..70aa369 --- /dev/null +++ b/remarkbox/tests/test_pandoc.py @@ -0,0 +1,274 @@ +"""Unit tests for remarkbox.lib.pandoc — pandoc subprocess wrapper.""" + +import unittest +import subprocess + +from remarkbox.lib.pandoc import ( + convert, + node_tree_to_markdown, + namespace_to_markdown, + get_available_output_formats, + get_available_input_formats, + CONTENT_TYPES, + FILE_EXTENSIONS, + BINARY_FORMATS, + _get_author_name, +) + + +class TestConvert(unittest.TestCase): + """Unit tests for the pandoc convert() function.""" + + def test_markdown_to_html(self): + result = convert("# Hello\n\nWorld.", "markdown", "html5", title="Test") + self.assertIn("Title

Paragraph.

", "html", "markdown") + self.assertIn("Title", result) + self.assertIn("Paragraph.", result) + + def test_rst_to_html(self): + rst = "Title\n=====\n\nA paragraph." + result = convert(rst, "rst", "html5") + self.assertIn("Title", result) + self.assertIn("paragraph", result) + + def test_mediawiki_to_html(self): + wiki = "== Section ==\n\n'''bold''' text" + result = convert(wiki, "mediawiki", "html5") + self.assertIn("Section", result) + + def test_latex_to_html(self): + latex = r"\section{Hello}\textbf{bold}" + result = convert(latex, "latex", "html5") + self.assertIn("Hello", result) + + def test_markdown_to_json(self): + result = convert("# Test", "markdown", "json") + self.assertIn('"pandoc-api-version"', result) + + def test_markdown_to_epub_returns_bytes(self): + result = convert("# Test\n\nContent.", "markdown", "epub", title="Test") + self.assertIsInstance(result, bytes) + self.assertGreater(len(result), 100) + + def test_markdown_to_pdf_returns_bytes(self): + result = convert("# Test\n\nContent.", "markdown", "pdf", title="Test") + self.assertIsInstance(result, bytes) + # PDF starts with %PDF + self.assertTrue(result[:4] == b"%PDF") + + def test_markdown_to_docx_returns_bytes(self): + result = convert("# Test\n\nContent.", "markdown", "docx", title="Test") + self.assertIsInstance(result, bytes) + # DOCX is a ZIP file (starts with PK) + self.assertTrue(result[:2] == b"PK") + + def test_title_metadata(self): + result = convert("Content.", "markdown", "html5", title="My Title") + self.assertIn("My Title", result) + + def test_empty_input(self): + result = convert("", "markdown", "html5") + self.assertIsInstance(result, str) + + def test_invalid_format_raises(self): + with self.assertRaises(subprocess.CalledProcessError): + convert("test", "markdown", "not_a_real_format_xyz") + + +class TestAvailableFormats(unittest.TestCase): + """Test format discovery.""" + + def test_output_formats_returns_frozenset(self): + formats = get_available_output_formats() + self.assertIsInstance(formats, frozenset) + self.assertGreater(len(formats), 50) + self.assertIn("html5", formats) + self.assertIn("markdown", formats) + self.assertIn("pdf", formats) + self.assertIn("epub", formats) + + def test_input_formats_returns_frozenset(self): + formats = get_available_input_formats() + self.assertIsInstance(formats, frozenset) + self.assertGreater(len(formats), 30) + self.assertIn("html", formats) + self.assertIn("markdown", formats) + self.assertIn("rst", formats) + + def test_content_types_has_common_formats(self): + self.assertIn("html5", CONTENT_TYPES) + self.assertIn("pdf", CONTENT_TYPES) + self.assertIn("epub", CONTENT_TYPES) + self.assertIn("docx", CONTENT_TYPES) + self.assertIn("markdown", CONTENT_TYPES) + self.assertEqual(CONTENT_TYPES["pdf"], "application/pdf") + + def test_file_extensions_has_common_formats(self): + self.assertEqual(FILE_EXTENSIONS["markdown"], ".md") + self.assertEqual(FILE_EXTENSIONS["html5"], ".html") + self.assertEqual(FILE_EXTENSIONS["pdf"], ".pdf") + self.assertEqual(FILE_EXTENSIONS["epub"], ".epub") + self.assertEqual(FILE_EXTENSIONS["docx"], ".docx") + + def test_binary_formats(self): + self.assertIn("pdf", BINARY_FORMATS) + self.assertIn("docx", BINARY_FORMATS) + self.assertIn("epub", BINARY_FORMATS) + self.assertNotIn("html5", BINARY_FORMATS) + self.assertNotIn("markdown", BINARY_FORMATS) + + +class MockNode: + """Minimal mock of Node for tree rendering tests.""" + + def __init__(self, id, title=None, data=None, parent_id=None, + created=0, disabled=False, user=None, user_surrogate=None, + created_date="2026-01-01"): + self.id = id + self.title = title + self.data = data + self.parent_id = parent_id + self.created = created + self.disabled = disabled + self.user = user + self.user_surrogate = user_surrogate + self.created_date = created_date + self.is_root = parent_id is None + + +class MockUser: + def __init__(self, name): + self.name = name + + +class MockNamespace: + def __init__(self, name, description=None): + self.name = name + self.description = description + + +class TestNodeTreeToMarkdown(unittest.TestCase): + """Test tree-to-markdown rendering.""" + + def test_single_root_node(self): + root = MockNode(1, title="Thread Title", data="Root content.") + md = node_tree_to_markdown(root, []) + self.assertIn("# Thread Title", md) + self.assertIn("Root content.", md) + + def test_root_with_children(self): + root = MockNode(1, title="Thread", data="Root.") + child = MockNode(2, data="Reply text.", parent_id=1, created=1, + user=MockUser("alice")) + md = node_tree_to_markdown(root, [child]) + self.assertIn("# Thread", md) + self.assertIn("Root.", md) + self.assertIn("alice", md) + self.assertIn("Reply text.", md) + + def test_disabled_children_excluded(self): + root = MockNode(1, title="Thread", data="Root.") + child = MockNode(2, data="Visible.", parent_id=1, created=1, + user=MockUser("bob")) + disabled = MockNode(3, data="Hidden.", parent_id=1, created=2, + disabled=True, user=MockUser("spam")) + md = node_tree_to_markdown(root, [child, disabled]) + self.assertIn("Visible.", md) + self.assertNotIn("Hidden.", md) + + def test_nested_children(self): + root = MockNode(1, title="Thread", data="Root.") + child = MockNode(2, data="Level 1.", parent_id=1, created=1, + user=MockUser("alice")) + grandchild = MockNode(3, data="Level 2.", parent_id=2, created=2, + user=MockUser("bob")) + md = node_tree_to_markdown(root, [child, grandchild]) + self.assertIn("Level 1.", md) + self.assertIn("Level 2.", md) + # Grandchild should have deeper heading + self.assertIn("### bob", md) + + def test_exclude_root(self): + root = MockNode(1, title="Hidden Title", data="Hidden root.") + md = node_tree_to_markdown(root, [], include_root=False) + self.assertNotIn("Hidden Title", md) + self.assertNotIn("Hidden root.", md) + + def test_anonymous_author(self): + root = MockNode(1, title="Thread") + child = MockNode(2, data="Anon post.", parent_id=1, created=1) + md = node_tree_to_markdown(root, [child]) + self.assertIn("Anonymous", md) + + def test_surrogate_author(self): + root = MockNode(1, title="Thread") + child = MockNode(2, data="Bot post.", parent_id=1, created=1, + user_surrogate=MockUser("MyBot")) + md = node_tree_to_markdown(root, [child]) + self.assertIn("MyBot", md) + + +class TestNamespaceToMarkdown(unittest.TestCase): + """Test namespace (book) rendering.""" + + def test_empty_namespace(self): + ns = MockNamespace("test.com", "Test Forum") + md = namespace_to_markdown(ns, [], lambda r: []) + self.assertIn("# Test Forum", md) + + def test_namespace_with_threads(self): + ns = MockNamespace("test.com", "My Forum") + root1 = MockNode(1, title="First Thread", data="Content 1.") + root2 = MockNode(2, title="Second Thread", data="Content 2.") + + def fetcher(root): + return [] + + md = namespace_to_markdown(ns, [root1, root2], fetcher) + self.assertIn("# My Forum", md) + self.assertIn("## First Thread", md) + self.assertIn("## Second Thread", md) + self.assertIn("Content 1.", md) + self.assertIn("Content 2.", md) + + def test_namespace_name_as_fallback_title(self): + ns = MockNamespace("test.com") + md = namespace_to_markdown(ns, [], lambda r: []) + self.assertIn("# test.com", md) + + def test_disabled_roots_excluded(self): + ns = MockNamespace("test.com") + root = MockNode(1, title="Visible", data="Yes.", disabled=False) + hidden = MockNode(2, title="Hidden", data="No.", disabled=True) + md = namespace_to_markdown(ns, [root, hidden], lambda r: []) + self.assertIn("Visible", md) + self.assertNotIn("Hidden", md) + + +class TestGetAuthorName(unittest.TestCase): + """Test _get_author_name helper.""" + + def test_user(self): + node = MockNode(1, user=MockUser("alice")) + self.assertEqual(_get_author_name(node), "alice") + + def test_surrogate(self): + node = MockNode(1, user_surrogate=MockUser("BotName")) + self.assertEqual(_get_author_name(node), "BotName") + + def test_anonymous(self): + node = MockNode(1) + self.assertEqual(_get_author_name(node), "Anonymous") diff --git a/remarkbox/tests/test_revision.py b/remarkbox/tests/test_revision.py new file mode 100644 index 0000000..0f73678 --- /dev/null +++ b/remarkbox/tests/test_revision.py @@ -0,0 +1,93 @@ +"""Unit tests for remarkbox.models.revision — wiki mode revision tracking.""" + +import unittest +from unittest import mock + +from remarkbox.models.revision import Revision +from remarkbox.models.node import Node +from remarkbox.models.namespace import Namespace + + +class TestRevisionModel(unittest.TestCase): + """Unit tests for the Revision model.""" + + def test_init_sets_uuid(self): + rev = Revision() + self.assertIsNotNone(rev.id) + + def test_init_sets_timestamp(self): + rev = Revision() + self.assertIsNotNone(rev.created) + self.assertGreater(rev.created, 0) + + def test_init_with_data(self): + rev = Revision( + data="Some content", + source_format="markdown", + revision_number=3, + ) + self.assertEqual(rev.data, "Some content") + self.assertEqual(rev.source_format, "markdown") + self.assertEqual(rev.revision_number, 3) + + def test_default_source_format(self): + rev = Revision() + self.assertEqual(rev.source_format, "markdown") + + def test_default_revision_number(self): + rev = Revision() + self.assertEqual(rev.revision_number, 1) + + +class TestNamespaceCanWikiEdit(unittest.TestCase): + """Unit tests for Namespace.can_wiki_edit().""" + + def setUp(self): + self.ns = Namespace("test-wiki.com") + self.ns.subscription_type = "production" + + def test_unauthenticated_user_denied(self): + user = mock.Mock() + user.authenticated = False + node = mock.Mock() + self.assertFalse(self.ns.can_wiki_edit(node, user)) + + def test_none_user_denied(self): + node = mock.Mock() + self.assertFalse(self.ns.can_wiki_edit(node, None)) + + def test_owner_can_always_edit(self): + user = mock.Mock() + user.authenticated = True + node = mock.Mock() + node.is_root = False + # can_alter_node returns True for owners + with mock.patch.object(self.ns, 'can_alter_node', return_value=True): + self.assertTrue(self.ns.can_wiki_edit(node, user)) + + def test_wiki_mode_allows_root_edit(self): + self.ns.wiki = True + user = mock.Mock() + user.authenticated = True + node = mock.Mock() + node.is_root = True + with mock.patch.object(self.ns, 'can_alter_node', return_value=False): + self.assertTrue(self.ns.can_wiki_edit(node, user)) + + def test_wiki_mode_denies_non_root_edit(self): + self.ns.wiki = True + user = mock.Mock() + user.authenticated = True + node = mock.Mock() + node.is_root = False + with mock.patch.object(self.ns, 'can_alter_node', return_value=False): + self.assertFalse(self.ns.can_wiki_edit(node, user)) + + def test_non_wiki_mode_denies_non_owner(self): + self.ns.wiki = False + user = mock.Mock() + user.authenticated = True + node = mock.Mock() + node.is_root = True + with mock.patch.object(self.ns, 'can_alter_node', return_value=False): + self.assertFalse(self.ns.can_wiki_edit(node, user)) diff --git a/remarkbox/tests/test_theme_generator.py b/remarkbox/tests/test_theme_generator.py new file mode 100644 index 0000000..826755a --- /dev/null +++ b/remarkbox/tests/test_theme_generator.py @@ -0,0 +1,113 @@ +"""Unit tests for remarkbox.lib.theme_generator.""" + +import unittest + +from remarkbox.lib.theme_generator import ( + generate_theme_css, + _name_to_hue, + _name_to_seed, +) + + +class TestNameToHue(unittest.TestCase): + """Test deterministic hue generation.""" + + def test_returns_int_in_range(self): + hue = _name_to_hue("test.com") + self.assertIsInstance(hue, int) + self.assertGreaterEqual(hue, 0) + self.assertLess(hue, 360) + + def test_deterministic(self): + h1 = _name_to_hue("meta.remarkbox.com") + h2 = _name_to_hue("meta.remarkbox.com") + self.assertEqual(h1, h2) + + def test_different_names_different_hues(self): + h1 = _name_to_hue("meta.remarkbox.com") + h2 = _name_to_hue("faq.remarkbox.com") + # Could theoretically collide but extremely unlikely + self.assertNotEqual(h1, h2) + + +class TestNameToSeed(unittest.TestCase): + """Test seed generation.""" + + def test_returns_tuple_of_ints(self): + seed = _name_to_seed("test.com") + self.assertIsInstance(seed, tuple) + self.assertEqual(len(seed), 8) + for v in seed: + self.assertIsInstance(v, int) + + def test_deterministic(self): + s1 = _name_to_seed("test.com") + s2 = _name_to_seed("test.com") + self.assertEqual(s1, s2) + + +class TestGenerateThemeCSS(unittest.TestCase): + """Test CSS theme generation.""" + + def test_returns_string(self): + css = generate_theme_css("test.com") + self.assertIsInstance(css, str) + self.assertGreater(len(css), 500) + + def test_deterministic(self): + css1 = generate_theme_css("meta.remarkbox.com") + css2 = generate_theme_css("meta.remarkbox.com") + self.assertEqual(css1, css2) + + def test_different_namespaces_different_css(self): + css1 = generate_theme_css("meta.remarkbox.com") + css2 = generate_theme_css("faq.remarkbox.com") + self.assertNotEqual(css1, css2) + + def test_contains_css_custom_properties(self): + css = generate_theme_css("test.com") + self.assertIn("--rb-bg:", css) + self.assertIn("--rb-text:", css) + self.assertIn("--rb-link:", css) + self.assertIn("--rb-accent:", css) + self.assertIn("--rb-border:", css) + + def test_contains_light_mode(self): + css = generate_theme_css("test.com") + self.assertIn(":root", css) + self.assertIn(".theme-light", css) + + def test_contains_dark_mode(self): + css = generate_theme_css("test.com") + self.assertIn("prefers-color-scheme: dark", css) + self.assertIn(".theme-dark", css) + + def test_contains_namespace_name_comment(self): + css = generate_theme_css("my-forum.example.org") + self.assertIn("my-forum.example.org", css) + + def test_contains_element_styles(self): + css = generate_theme_css("test.com") + self.assertIn(".node", css) + self.assertIn("blockquote", css) + self.assertIn(".rb-submit", css) + self.assertIn("textarea", css) + + def test_uses_hsl_colors(self): + css = generate_theme_css("test.com") + self.assertIn("hsl(", css) + + def test_various_namespaces_all_valid(self): + """Ensure no crashes for various namespace name patterns.""" + names = [ + "a.com", + "very-long-domain-name-that-goes-on.example.com", + "unicode-test-日本語.com", + "123.456.789", + "", + "single", + ] + for name in names: + css = generate_theme_css(name) + self.assertIsInstance(css, str) + self.assertGreater(len(css), 100) diff --git a/remarkbox/tests/test_undigg.py b/remarkbox/tests/test_undigg.py new file mode 100644 index 0000000..73612ef --- /dev/null +++ b/remarkbox/tests/test_undigg.py @@ -0,0 +1,762 @@ +"""Integration and functional tests for Operation Undigg. + +Tests the export, multi-syntax, wiki mode, and theme APIs end-to-end +via webtest against the full Pyramid app. +""" + +import transaction +import unittest +import webtest + +from remarkbox.models import ( + Node, + get_tm_session, + get_or_create_user_by_email, + get_or_create_namespace, + UserSurrogate, +) + +from remarkbox.models.meta import Base +from remarkbox.models.revision import Revision + +from pyramid.paster import get_appsettings + + +class UndiggFunctionalTests(unittest.TestCase): + """Base class for Undigg functional tests.""" + + @classmethod + def setUpClass(cls): + from remarkbox import main + + cls.settings = get_appsettings("test.ini") + cls.app = main({}, **cls.settings) + cls.testapp = webtest.TestApp(cls.app) + + cls.session_factory = cls.app.registry["dbsession_factory"] + cls.engine = cls.session_factory.kw["bind"] + Base.metadata.create_all(bind=cls.engine) + + cls.tm = transaction.manager + cls.dbsession = get_tm_session(cls.session_factory, cls.tm) + + @classmethod + def tearDownClass(cls): + cls.dbsession.close() + Base.metadata.drop_all(bind=cls.engine) + + def tearDown(self): + self.testapp.get("/log-out") + + +# --------------------------------------------------------------------------- +# Export API +# --------------------------------------------------------------------------- + + +class TestExportFormats(UndiggFunctionalTests): + """Test GET /api/v1/export/formats.""" + + def test_list_formats(self): + res = self.testapp.get("/api/v1/export/formats") + self.assertEqual(res.status_int, 200) + body = res.json + self.assertIn("formats", body) + self.assertIn("count", body) + self.assertGreater(body["count"], 50) + self.assertIn("html5", body["formats"]) + self.assertIn("pdf", body["formats"]) + self.assertIn("markdown", body["formats"]) + + +class TestExportThread(UndiggFunctionalTests): + """Test GET /api/v1/export/threads/{id}.{format}.""" + + @classmethod + def setUpClass(cls): + try: + UndiggFunctionalTests.setUpClass.im_func(cls) + except AttributeError: + UndiggFunctionalTests.setUpClass.__func__(cls) + + def setUp(self): + ns = get_or_create_namespace(self.dbsession, "export-test.example.com") + ns.allow_anonymous = True + self.dbsession.add(ns) + self.dbsession.flush() + self.namespace_name = str(ns.name) + self.namespace_id = ns.id + self.tm.commit() + + def tearDown(self): + super(TestExportThread, self).tearDown() + self.dbsession.query(UserSurrogate).filter( + UserSurrogate.namespace_id == self.namespace_id + ).delete(synchronize_session=False) + self.dbsession.query(Node).filter( + Node.namespace_id == self.namespace_id + ).delete(synchronize_session=False) + self.dbsession.flush() + self.tm.commit() + + def _create_thread(self, title="Test Thread", data="Thread content."): + res = self.testapp.post_json( + "/api/v1/threads", + { + "namespace": self.namespace_name, + "title": title, + "data": data, + "anonymous_name": "Bot", + }, + expect_errors=True, + ) + return res.json["node"]["id"] + + def test_export_thread_markdown(self): + node_id = self._create_thread() + res = self.testapp.get( + "/api/v1/export/threads/{}.markdown".format(node_id), + expect_errors=True, + ) + self.assertEqual(res.status_int, 200) + self.assertIn("text/markdown", res.content_type) + self.assertIn(b"Test Thread", res.body) + self.assertIn(b"Thread content.", res.body) + + def test_export_thread_html(self): + node_id = self._create_thread() + res = self.testapp.get( + "/api/v1/export/threads/{}.html5".format(node_id), + expect_errors=True, + ) + self.assertEqual(res.status_int, 200) + self.assertIn("text/html", res.content_type) + self.assertIn(b"Test Thread", res.body) + + def test_export_thread_rst(self): + node_id = self._create_thread() + res = self.testapp.get( + "/api/v1/export/threads/{}.rst".format(node_id), + expect_errors=True, + ) + self.assertEqual(res.status_int, 200) + self.assertIn(b"Test Thread", res.body) + + def test_export_thread_pdf(self): + node_id = self._create_thread() + res = self.testapp.get( + "/api/v1/export/threads/{}.pdf".format(node_id), + expect_errors=True, + ) + self.assertEqual(res.status_int, 200) + self.assertEqual(res.content_type, "application/pdf") + self.assertTrue(res.body[:4] == b"%PDF") + + def test_export_thread_epub(self): + node_id = self._create_thread() + res = self.testapp.get( + "/api/v1/export/threads/{}.epub".format(node_id), + expect_errors=True, + ) + self.assertEqual(res.status_int, 200) + self.assertEqual(res.content_type, "application/epub+zip") + self.assertGreater(len(res.body), 100) + + def test_export_thread_docx(self): + node_id = self._create_thread() + res = self.testapp.get( + "/api/v1/export/threads/{}.docx".format(node_id), + expect_errors=True, + ) + self.assertEqual(res.status_int, 200) + # DOCX is a ZIP + self.assertTrue(res.body[:2] == b"PK") + + def test_export_thread_not_found(self): + res = self.testapp.get( + "/api/v1/export/threads/00000000-0000-0000-0000-000000000000.markdown", + expect_errors=True, + ) + self.assertEqual(res.status_int, 404) + + def test_export_thread_unsupported_format(self): + node_id = self._create_thread() + res = self.testapp.get( + "/api/v1/export/threads/{}.not_a_format".format(node_id), + expect_errors=True, + ) + self.assertEqual(res.status_int, 400) + + def test_export_thread_with_replies(self): + """Thread with replies includes reply content in export.""" + node_id = self._create_thread() + self.testapp.post_json( + "/api/v1/threads/{}/replies".format(node_id), + {"data": "A reply from alice.", "anonymous_name": "alice"}, + expect_errors=True, + ) + res = self.testapp.get( + "/api/v1/export/threads/{}.markdown".format(node_id), + expect_errors=True, + ) + self.assertEqual(res.status_int, 200) + body = res.body.decode("utf-8") + self.assertIn("A reply from alice.", body) + self.assertIn("alice", body) + + def test_export_content_disposition(self): + node_id = self._create_thread(title="My Export Test") + res = self.testapp.get( + "/api/v1/export/threads/{}.markdown".format(node_id), + expect_errors=True, + ) + self.assertIn("attachment", res.headers.get("Content-Disposition", "")) + + +class TestExportNamespace(UndiggFunctionalTests): + """Test GET /api/v1/export/namespace/{name}.{format}.""" + + @classmethod + def setUpClass(cls): + try: + UndiggFunctionalTests.setUpClass.im_func(cls) + except AttributeError: + UndiggFunctionalTests.setUpClass.__func__(cls) + + def setUp(self): + ns = get_or_create_namespace(self.dbsession, "ns-export.example.com") + ns.allow_anonymous = True + self.dbsession.add(ns) + self.dbsession.flush() + self.namespace_name = str(ns.name) + self.namespace_id = ns.id + self.tm.commit() + + def tearDown(self): + super(TestExportNamespace, self).tearDown() + self.dbsession.query(UserSurrogate).filter( + UserSurrogate.namespace_id == self.namespace_id + ).delete(synchronize_session=False) + self.dbsession.query(Node).filter( + Node.namespace_id == self.namespace_id + ).delete(synchronize_session=False) + self.dbsession.flush() + self.tm.commit() + + def test_export_namespace_markdown(self): + # Create a thread + self.testapp.post_json( + "/api/v1/threads", + { + "namespace": self.namespace_name, + "title": "Chapter One", + "data": "First chapter content.", + "anonymous_name": "Bot", + }, + expect_errors=True, + ) + res = self.testapp.get( + "/api/v1/export/namespace/{}.markdown".format(self.namespace_name), + expect_errors=True, + ) + self.assertEqual(res.status_int, 200) + body = res.body.decode("utf-8") + self.assertIn("Chapter One", body) + self.assertIn("First chapter content.", body) + + def test_export_namespace_html(self): + self.testapp.post_json( + "/api/v1/threads", + { + "namespace": self.namespace_name, + "title": "HTML Chapter", + "data": "Content.", + "anonymous_name": "Bot", + }, + expect_errors=True, + ) + res = self.testapp.get( + "/api/v1/export/namespace/{}.html5".format(self.namespace_name), + expect_errors=True, + ) + self.assertEqual(res.status_int, 200) + self.assertIn("text/html", res.content_type) + + def test_export_namespace_not_found(self): + res = self.testapp.get( + "/api/v1/export/namespace/nonexistent.example.com.markdown", + expect_errors=True, + ) + self.assertEqual(res.status_int, 404) + + def test_export_empty_namespace(self): + ns = get_or_create_namespace(self.dbsession, "empty-ns.example.com") + self.dbsession.add(ns) + self.dbsession.flush() + self.tm.commit() + + res = self.testapp.get( + "/api/v1/export/namespace/empty-ns.example.com.markdown", + expect_errors=True, + ) + self.assertEqual(res.status_int, 200) + + +class TestExportNode(UndiggFunctionalTests): + """Test GET /api/v1/export/nodes/{id}.{format} (on-demand subthread).""" + + @classmethod + def setUpClass(cls): + try: + UndiggFunctionalTests.setUpClass.im_func(cls) + except AttributeError: + UndiggFunctionalTests.setUpClass.__func__(cls) + + def setUp(self): + ns = get_or_create_namespace(self.dbsession, "node-export.example.com") + ns.allow_anonymous = True + self.dbsession.add(ns) + self.dbsession.flush() + self.namespace_name = str(ns.name) + self.namespace_id = ns.id + self.tm.commit() + + def tearDown(self): + super(TestExportNode, self).tearDown() + self.dbsession.query(UserSurrogate).filter( + UserSurrogate.namespace_id == self.namespace_id + ).delete(synchronize_session=False) + self.dbsession.query(Node).filter( + Node.namespace_id == self.namespace_id + ).delete(synchronize_session=False) + self.dbsession.flush() + self.tm.commit() + + def test_export_node_subtree(self): + # Create thread with a reply + res = self.testapp.post_json( + "/api/v1/threads", + { + "namespace": self.namespace_name, + "title": "Thread", + "data": "Root content.", + "anonymous_name": "Bot", + }, + expect_errors=True, + ) + root_id = res.json["node"]["id"] + + self.testapp.post_json( + "/api/v1/threads/{}/replies".format(root_id), + {"data": "Reply content.", "anonymous_name": "Bot"}, + expect_errors=True, + ) + + # Export the reply's subtree + export_res = self.testapp.get( + "/api/v1/export/nodes/{}.markdown".format(root_id), + expect_errors=True, + ) + self.assertEqual(export_res.status_int, 200) + body = export_res.body.decode("utf-8") + self.assertIn("Root content.", body) + + def test_export_node_not_found(self): + res = self.testapp.get( + "/api/v1/export/nodes/00000000-0000-0000-0000-000000000000.markdown", + expect_errors=True, + ) + self.assertEqual(res.status_int, 404) + + +# --------------------------------------------------------------------------- +# Multi-Syntax Input +# --------------------------------------------------------------------------- + + +class TestMultiSyntaxInput(UndiggFunctionalTests): + """Test source_format parameter on create/reply/edit.""" + + @classmethod + def setUpClass(cls): + try: + UndiggFunctionalTests.setUpClass.im_func(cls) + except AttributeError: + UndiggFunctionalTests.setUpClass.__func__(cls) + + def setUp(self): + ns = get_or_create_namespace(self.dbsession, "syntax-test.example.com") + ns.allow_anonymous = True + self.dbsession.add(ns) + self.dbsession.flush() + self.namespace_name = str(ns.name) + self.namespace_id = ns.id + self.tm.commit() + + def tearDown(self): + super(TestMultiSyntaxInput, self).tearDown() + self.dbsession.query(UserSurrogate).filter( + UserSurrogate.namespace_id == self.namespace_id + ).delete(synchronize_session=False) + self.dbsession.query(Node).filter( + Node.namespace_id == self.namespace_id + ).delete(synchronize_session=False) + self.dbsession.flush() + self.tm.commit() + + def test_create_thread_default_markdown(self): + res = self.testapp.post_json( + "/api/v1/threads", + { + "namespace": self.namespace_name, + "title": "Markdown Thread", + "data": "**bold** text", + "anonymous_name": "Bot", + }, + expect_errors=True, + ) + self.assertEqual(res.status_int, 201) + self.assertEqual(res.json["node"]["source_format"], "markdown") + self.assertIn("bold", res.json["node"]["data_html"]) + + def test_create_thread_rst(self): + res = self.testapp.post_json( + "/api/v1/threads", + { + "namespace": self.namespace_name, + "title": "RST Thread", + "data": "A paragraph with **bold** text.", + "anonymous_name": "Bot", + "source_format": "rst", + }, + expect_errors=True, + ) + self.assertEqual(res.status_int, 201) + self.assertEqual(res.json["node"]["source_format"], "rst") + self.assertIn("bold", res.json["node"]["data_html"]) + + def test_create_thread_html_stripped(self): + res = self.testapp.post_json( + "/api/v1/threads", + { + "namespace": self.namespace_name, + "title": "HTML Thread", + "data": "

Title

Paragraph.

", + "anonymous_name": "Bot", + "source_format": "html", + }, + expect_errors=True, + ) + self.assertEqual(res.status_int, 201) + # HTML input gets normalized to markdown + self.assertEqual(res.json["node"]["source_format"], "markdown") + self.assertIn("Paragraph.", res.json["node"]["data"]) + + def test_reply_with_source_format(self): + # Create thread + create_res = self.testapp.post_json( + "/api/v1/threads", + { + "namespace": self.namespace_name, + "title": "Thread", + "data": "Content.", + "anonymous_name": "Bot", + }, + expect_errors=True, + ) + node_id = create_res.json["node"]["id"] + + # Reply with RST + reply_res = self.testapp.post_json( + "/api/v1/threads/{}/replies".format(node_id), + { + "data": "**RST bold** reply.", + "anonymous_name": "Bot", + "source_format": "rst", + }, + expect_errors=True, + ) + self.assertEqual(reply_res.status_int, 201) + self.assertEqual(reply_res.json["node"]["source_format"], "rst") + + def test_serializer_includes_source_format(self): + res = self.testapp.post_json( + "/api/v1/threads", + { + "namespace": self.namespace_name, + "title": "Test", + "data": "Content.", + "anonymous_name": "Bot", + }, + expect_errors=True, + ) + self.assertIn("source_format", res.json["node"]) + + +class TestMultiSyntaxMediawiki(UndiggFunctionalTests): + """Test mediawiki input format.""" + + @classmethod + def setUpClass(cls): + try: + UndiggFunctionalTests.setUpClass.im_func(cls) + except AttributeError: + UndiggFunctionalTests.setUpClass.__func__(cls) + + def setUp(self): + ns = get_or_create_namespace(self.dbsession, "wiki-syntax.example.com") + ns.allow_anonymous = True + self.dbsession.add(ns) + self.dbsession.flush() + self.namespace_name = str(ns.name) + self.namespace_id = ns.id + self.tm.commit() + + def tearDown(self): + super(TestMultiSyntaxMediawiki, self).tearDown() + self.dbsession.query(UserSurrogate).filter( + UserSurrogate.namespace_id == self.namespace_id + ).delete(synchronize_session=False) + self.dbsession.query(Node).filter( + Node.namespace_id == self.namespace_id + ).delete(synchronize_session=False) + self.dbsession.flush() + self.tm.commit() + + def test_create_thread_mediawiki(self): + res = self.testapp.post_json( + "/api/v1/threads", + { + "namespace": self.namespace_name, + "title": "Wiki Thread", + "data": "== Section ==\n\n'''bold''' text", + "anonymous_name": "Bot", + "source_format": "mediawiki", + }, + expect_errors=True, + ) + self.assertEqual(res.status_int, 201) + self.assertEqual(res.json["node"]["source_format"], "mediawiki") + # The HTML should contain the rendered content + self.assertIn("Section", res.json["node"]["data_html"]) + + +# --------------------------------------------------------------------------- +# Wiki Mode & Revisions +# --------------------------------------------------------------------------- + + +class TestWikiModeAndRevisions(UndiggFunctionalTests): + """Test wiki-edit and revision history endpoints.""" + + @classmethod + def setUpClass(cls): + try: + UndiggFunctionalTests.setUpClass.im_func(cls) + except AttributeError: + UndiggFunctionalTests.setUpClass.__func__(cls) + + def setUp(self): + ns = get_or_create_namespace(self.dbsession, "wiki-test.example.com") + ns.allow_anonymous = True + # Enable wiki mode + ns.subscription_type = "production" + ns.wiki = True + self.dbsession.add(ns) + self.dbsession.flush() + self.namespace_name = str(ns.name) + self.namespace_id = ns.id + self.tm.commit() + + def tearDown(self): + super(TestWikiModeAndRevisions, self).tearDown() + # Clean up revisions first (FK constraint) + self.dbsession.query(Revision).filter( + Revision.node_id.in_( + self.dbsession.query(Node.id).filter( + Node.namespace_id == self.namespace_id + ) + ) + ).delete(synchronize_session=False) + self.dbsession.query(UserSurrogate).filter( + UserSurrogate.namespace_id == self.namespace_id + ).delete(synchronize_session=False) + self.dbsession.query(Node).filter( + Node.namespace_id == self.namespace_id + ).delete(synchronize_session=False) + self.dbsession.flush() + self.tm.commit() + + def test_wiki_edit_requires_auth(self): + # Create a thread + create_res = self.testapp.post_json( + "/api/v1/threads", + { + "namespace": self.namespace_name, + "title": "Wiki Thread", + "data": "Original.", + "anonymous_name": "Bot", + }, + expect_errors=True, + ) + node_id = create_res.json["node"]["id"] + + # Try wiki-edit without auth + res = self.testapp.post_json( + "/api/v1/nodes/{}/wiki-edit".format(node_id), + {"data": "Updated."}, + expect_errors=True, + ) + self.assertEqual(res.status_int, 401) + + def test_revisions_empty_initially(self): + create_res = self.testapp.post_json( + "/api/v1/threads", + { + "namespace": self.namespace_name, + "title": "Wiki Thread", + "data": "Original.", + "anonymous_name": "Bot", + }, + expect_errors=True, + ) + node_id = create_res.json["node"]["id"] + + res = self.testapp.get( + "/api/v1/nodes/{}/revisions".format(node_id), + expect_errors=True, + ) + self.assertEqual(res.status_int, 200) + self.assertEqual(res.json["count"], 0) + self.assertEqual(res.json["revisions"], []) + + def test_revisions_not_found(self): + res = self.testapp.get( + "/api/v1/nodes/00000000-0000-0000-0000-000000000000/revisions", + expect_errors=True, + ) + self.assertEqual(res.status_int, 404) + + def test_wiki_edit_missing_data(self): + create_res = self.testapp.post_json( + "/api/v1/threads", + { + "namespace": self.namespace_name, + "title": "Wiki Thread", + "data": "Original.", + "anonymous_name": "Bot", + }, + expect_errors=True, + ) + node_id = create_res.json["node"]["id"] + + # Need to be authenticated to even get past the auth check + # Since we can't easily authenticate in these tests, just verify + # the 401 response for unauthenticated users + res = self.testapp.post_json( + "/api/v1/nodes/{}/wiki-edit".format(node_id), + {}, + expect_errors=True, + ) + self.assertEqual(res.status_int, 401) + + def test_wiki_edit_node_not_found(self): + res = self.testapp.post_json( + "/api/v1/nodes/00000000-0000-0000-0000-000000000000/wiki-edit", + {"data": "Updated."}, + expect_errors=True, + ) + self.assertEqual(res.status_int, 404) + + def test_revision_detail_not_found(self): + res = self.testapp.get( + "/api/v1/revisions/00000000-0000-0000-0000-000000000000", + expect_errors=True, + ) + self.assertEqual(res.status_int, 404) + + +# --------------------------------------------------------------------------- +# Theme API +# --------------------------------------------------------------------------- + + +class TestThemeAPI(UndiggFunctionalTests): + """Test theme generation endpoints.""" + + @classmethod + def setUpClass(cls): + try: + UndiggFunctionalTests.setUpClass.im_func(cls) + except AttributeError: + UndiggFunctionalTests.setUpClass.__func__(cls) + + def setUp(self): + ns = get_or_create_namespace(self.dbsession, "theme-test.example.com") + self.dbsession.add(ns) + self.dbsession.flush() + self.namespace_name = str(ns.name) + self.tm.commit() + + def test_get_theme_css(self): + res = self.testapp.get( + "/api/v1/themes/{}/css".format(self.namespace_name), + expect_errors=True, + ) + self.assertEqual(res.status_int, 200) + self.assertIn("text/css", res.content_type) + body = res.body.decode("utf-8") + self.assertIn("--rb-bg:", body) + self.assertIn("--rb-text:", body) + self.assertIn("prefers-color-scheme: dark", body) + + def test_get_theme_css_deterministic(self): + res1 = self.testapp.get( + "/api/v1/themes/{}/css".format(self.namespace_name)) + res2 = self.testapp.get( + "/api/v1/themes/{}/css".format(self.namespace_name)) + self.assertEqual(res1.body, res2.body) + + def test_get_theme_css_cached(self): + res = self.testapp.get( + "/api/v1/themes/{}/css".format(self.namespace_name)) + self.assertIn("max-age", res.headers.get("Cache-Control", "")) + + def test_get_theme_css_not_found(self): + res = self.testapp.get( + "/api/v1/themes/nonexistent-ns-xyz.example.com/css", + expect_errors=True, + ) + self.assertEqual(res.status_int, 404) + + def test_get_theme_preview(self): + res = self.testapp.get( + "/api/v1/themes/{}/preview".format(self.namespace_name), + expect_errors=True, + ) + self.assertEqual(res.status_int, 200) + body = res.json + self.assertIn("palette", body) + self.assertIn("primary_hue", body["palette"]) + self.assertIn("secondary_hue", body["palette"]) + self.assertIn("accent_hue", body["palette"]) + self.assertIn("css_url", body) + + def test_get_theme_preview_not_found(self): + res = self.testapp.get( + "/api/v1/themes/nonexistent-ns-xyz.example.com/preview", + expect_errors=True, + ) + self.assertEqual(res.status_int, 404) + + def test_different_namespaces_different_themes(self): + ns2 = get_or_create_namespace(self.dbsession, "theme-test-2.example.com") + self.dbsession.add(ns2) + self.dbsession.flush() + self.tm.commit() + + res1 = self.testapp.get( + "/api/v1/themes/{}/css".format(self.namespace_name)) + res2 = self.testapp.get( + "/api/v1/themes/theme-test-2.example.com/css") + self.assertNotEqual(res1.body, res2.body)