- `{contact}` or `{contact: Label}` to add a button on article, additional access on top right corner letter emoji
- Article button styling (with custom shape for Nitro theme)
- Visitor contact panel: live thread over the activity socket, rich composer (formatting,/file uploads), PoW anti-spam gating, contact email reveal after proof
- Admin inbox in the same overlay: thread list, replies, read marking, unread badge; background watch keeps badge and notifications live
- Messaging site settings (button label, email subject, our email) with per-language overrides and auto-translation support
- Browser notifications for new messages on both sides, with click-through to the new content
- Close conversation: archives the thread for admins, starts visitors fresh
Reviewed-on: https://git.zi.fi/LeoVasanko/pagerite/pulls/3
856 lines
40 KiB
Python
856 lines
40 KiB
Python
"""Editor REST API and WebSocket sessions.
|
|
|
|
The management endpoints behind the SSO forward-auth gate: the site tree
|
|
(``/_api/pages``), structure operations (``/_api/structure``), site-wide
|
|
settings (``/_api/settings``), task-list toggles (``/_api/toggle-task``),
|
|
the translations refresh (``/_api/translations``), the editor session
|
|
socket (``/_api/ws/editor``), and the translator service channel
|
|
(``/_translate/{clientkey}`` — deliberately NOT under ``/_api``: the
|
|
server-generated key in the path is the access control).
|
|
"""
|
|
|
|
import logging
|
|
import re
|
|
from datetime import UTC, datetime
|
|
|
|
from fastapi import (
|
|
APIRouter,
|
|
HTTPException,
|
|
Request,
|
|
WebSocket,
|
|
WebSocketDisconnect,
|
|
)
|
|
from html5tagger import E
|
|
from pydantic import BaseModel
|
|
|
|
from pagerite import i18n, messages, views
|
|
from pagerite.chunks import chunk_key, store_chunks
|
|
from pagerite.data import (
|
|
Node,
|
|
append_order,
|
|
find_slot,
|
|
node_markdown,
|
|
resolve,
|
|
sorted_nodes,
|
|
)
|
|
from pagerite.markdown import render, toggle_task
|
|
from pagerite.state import (
|
|
_check_reserved,
|
|
_ensure,
|
|
_invalidate_pages,
|
|
_remove_page,
|
|
data,
|
|
dispatcher,
|
|
kanta,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
class PageIn(BaseModel):
|
|
"""Payload for creating or replacing a page."""
|
|
|
|
title: str
|
|
markdown: str
|
|
published: bool = True
|
|
banner: str | None = None # None keeps the existing banner
|
|
|
|
|
|
@router.get("/_api/pages")
|
|
async def list_pages(lang: str | None = None) -> list[dict]:
|
|
"""The site tree for the structure editor (all nodes, drafts included).
|
|
|
|
Nested by slug; each node carries its full path, menu order, flags and
|
|
language settings (``language`` is the node's own primary-language
|
|
setting, "" = inherit; ``primary`` is the resolved effective one).
|
|
``has_content`` tells whether a landing page exists at all (even an
|
|
empty one — the structure editor offers deleting it); ``empty`` is the
|
|
navigation's notion: no content of its own (None or empty markdown),
|
|
so nav links bypass the node to its first child page.
|
|
With a ``?lang=`` translation, titles come out in that language where a
|
|
translation exists (``translated`` flags it — true trivially for rows
|
|
whose primary language IS the selected one; other rows fall back to
|
|
the original title, dimmed) — the structure itself (slugs, order,
|
|
hierarchy) is language-independent.
|
|
"""
|
|
tag = i18n.base_tag(lang or "")
|
|
titles = i18n.title_map(data, tag) if tag else {}
|
|
|
|
def dump(nodes: dict[str, Node], prefix: str, inherited: str) -> list[dict]:
|
|
out = []
|
|
for slug, node in sorted_nodes(nodes):
|
|
path = f"{prefix}/{slug}" if prefix else slug
|
|
primary = node.language or inherited
|
|
out.append(
|
|
{
|
|
"slug": slug,
|
|
"path": path,
|
|
"title": titles.get(path) or node.title,
|
|
"translated": path in titles or (bool(tag) and primary == tag),
|
|
"order": node.order,
|
|
"published": node.published,
|
|
"has_content": node.chunks is not None,
|
|
# No content of its own (None or empty markdown): the
|
|
# navigation bypasses the node, linking its first child
|
|
# page instead (views nav_html/first_leaf).
|
|
"empty": not node.chunks,
|
|
"language": node.language,
|
|
"primary": primary,
|
|
"children": dump(node.children, path, primary),
|
|
}
|
|
)
|
|
return out
|
|
|
|
return dump(data.menu, "", i18n.ORIGINAL_LANGUAGE)
|
|
|
|
|
|
@router.put("/_api/pages/{path:path}", status_code=204)
|
|
async def save_page(
|
|
path: str, page: PageIn, request: Request, lang: str | None = None
|
|
) -> None:
|
|
"""Create or replace the page at a slug path ("" or "/" = front page).
|
|
|
|
Missing ancestors are created as content-less category labels. Giving
|
|
a category markdown turns it into a landing page. Empty markdown (after
|
|
stripping) creates an empty page that renders with just its title —
|
|
saving never deletes; use DELETE to remove a page (the page editor
|
|
issues DELETE when you save empty text).
|
|
|
|
With a ``?lang=`` query (a translation, not the primary language) the
|
|
save is a translated-view edit (docs/localization.md): the markdown is
|
|
diffed against the currently served hybrid and recorded as user
|
|
overrides under ``overrides[path][lang]`` — node.chunks and the
|
|
original-language fields (title, published, banner) stay untouched.
|
|
"""
|
|
path = path.strip("/")
|
|
_check_reserved(path)
|
|
lang = i18n.base_tag(lang or "")
|
|
if lang and lang != i18n.primary_lang(data.menu, path):
|
|
chain = resolve(data.menu, path)
|
|
node = chain[-1] if chain else None
|
|
if node is None or node.chunks is None:
|
|
raise HTTPException(404, "no such page")
|
|
if not node.chunks:
|
|
raise HTTPException(400, "the page has no content to translate")
|
|
with kanta.transaction(
|
|
f"page:{lang}", user=request.headers.get("remote-user"), extra=path
|
|
):
|
|
# Overrides alone make the translated version exist.
|
|
if i18n.record_override(data, node, path, lang, page.markdown):
|
|
_invalidate_pages()
|
|
return
|
|
with kanta.transaction("page", user=request.headers.get("remote-user"), extra=path):
|
|
node = _ensure(data.menu, path)
|
|
node.title = page.title
|
|
node.chunks = store_chunks(data.chunks, page.markdown)
|
|
node.published = page.published
|
|
if page.banner is not None:
|
|
node.banner = page.banner
|
|
node.modified = datetime.now(UTC)
|
|
_invalidate_pages()
|
|
|
|
|
|
@router.delete("/_api/pages/{path:path}", status_code=204)
|
|
async def delete_page(path: str, request: Request) -> None:
|
|
"""Delete a node by slug path.
|
|
|
|
A category (node with children) loses only its landing page and stays
|
|
as a content-less label; a childless node is removed entirely.
|
|
"""
|
|
path = path.strip("/")
|
|
_check_reserved(path)
|
|
with kanta.transaction(
|
|
"page:delete", user=request.headers.get("remote-user"), extra=path
|
|
):
|
|
if not _remove_page(data.menu, path):
|
|
raise HTTPException(404, "no such page")
|
|
_invalidate_pages()
|
|
|
|
|
|
class StructureOp(BaseModel):
|
|
"""Rearrange the site tree: reorder, move/rename or retitle a node.
|
|
|
|
`order` is a fresh fractional key computed client-side from the node's
|
|
new siblings (a value halfway between them); all other items keep
|
|
theirs. `move_to` is the full target path — the parent must exist and
|
|
the new slug be free. Moves carry the whole subtree. The front page is
|
|
just the top-level node with slug "": renaming it away leaves no front
|
|
page ("/" then redirects to the first nav item), and any childless
|
|
top-level node can take the empty slug to become the front page.
|
|
|
|
With `lang` (a translation, not the node's primary language) a `title`
|
|
edit writes a per-language title fragment instead of the original — the
|
|
same storage as machine title translations (docs/localization.md);
|
|
sending the original's text removes the override. Structural fields are
|
|
not combinable with a translated title edit.
|
|
|
|
`language` sets the node's primary language (a BCP-47 base tag; "" =
|
|
inherit from the nearest ancestor, the front page last, site default
|
|
"en" final — Node.language), inherited by the whole subtree.
|
|
"""
|
|
|
|
path: str
|
|
order: float | None = None
|
|
move_to: str | None = None
|
|
title: str | None = None
|
|
lang: str | None = None
|
|
language: str | None = None
|
|
|
|
|
|
@router.post("/_api/structure", status_code=204)
|
|
async def update_structure(op: StructureOp, request: Request) -> None:
|
|
"""Apply one structure operation (see StructureOp)."""
|
|
path = op.path.strip("/")
|
|
chain = resolve(data.menu, path)
|
|
if chain is None:
|
|
raise HTTPException(404, "no such page")
|
|
node = chain[-1]
|
|
lang = i18n.base_tag(op.lang or "")
|
|
if op.language is not None:
|
|
# Primary-language setting (inherited by the subtree): reselects
|
|
# what "the original" means for the node — its language is part of
|
|
# every render, so a change invalidates everywhere.
|
|
language = i18n.base_tag(op.language)
|
|
with kanta.transaction(
|
|
"page:language", user=request.headers.get("remote-user"), extra=path
|
|
):
|
|
if language != node.language:
|
|
node.language = language
|
|
_invalidate_pages()
|
|
return
|
|
if op.title is not None and lang and lang != i18n.primary_lang(data.menu, path):
|
|
# Translated title (i18n.set_title_translation): original title,
|
|
# slugs and hierarchy stay untouched.
|
|
with kanta.transaction(
|
|
f"page:{lang}:title", user=request.headers.get("remote-user"), extra=path
|
|
):
|
|
if i18n.set_title_translation(data, node, lang, op.title):
|
|
_invalidate_pages()
|
|
return
|
|
target = op.move_to.strip("/") if op.move_to is not None else None
|
|
if target is not None and target != path:
|
|
_check_reserved(target)
|
|
if path and target.startswith(f"{path}/"):
|
|
raise HTTPException(400, "cannot move a page under itself")
|
|
slot = find_slot(data.menu, target)
|
|
if slot is None:
|
|
raise HTTPException(404, "target parent does not exist")
|
|
tnodes, tslug = slot
|
|
if tslug in tnodes:
|
|
raise HTTPException(400, "target path exists")
|
|
if not tslug and node.children:
|
|
raise HTTPException(400, "the front page cannot have children")
|
|
# One structure call can combine a title set, a move/rename and a
|
|
# reorder; the action names the most significant of them.
|
|
action = (
|
|
"page:slug"
|
|
if target is not None and target != path
|
|
else "page:title"
|
|
if op.title is not None
|
|
else "structure:reorder"
|
|
)
|
|
with kanta.transaction(action, user=request.headers.get("remote-user"), extra=path):
|
|
if op.title is not None:
|
|
node.title = op.title
|
|
if target is not None and target != path:
|
|
snodes, sslug = find_slot(data.menu, path)
|
|
del snodes[sslug]
|
|
# A pure rename (same parent) keeps its position; only a move
|
|
# to another level appends at the end (unless an order came
|
|
# with the drop).
|
|
same_level = path.rpartition("/")[0] == target.rpartition("/")[0]
|
|
node.order = (
|
|
op.order
|
|
if op.order is not None
|
|
else node.order
|
|
if same_level
|
|
else append_order(tnodes)
|
|
)
|
|
tnodes[tslug] = node
|
|
elif op.order is not None:
|
|
node.order = op.order
|
|
node.modified = datetime.now(UTC)
|
|
_invalidate_pages()
|
|
|
|
|
|
@router.get("/_api/settings")
|
|
async def get_settings(lang: str | None = None) -> dict:
|
|
"""Site-wide settings (brand, theme, custom CSS and favicon URL), plus
|
|
the themes, banner designs and user fonts available on disk for the
|
|
selectors, the translator service keys and the wanted translation
|
|
languages (for the /_translate socket), and the contact-messaging
|
|
configuration.
|
|
|
|
With a ``?lang=`` translation language the contact label/subject also
|
|
come out as ``*_tr`` (their per-language override, "" = none — the
|
|
site settings' messaging section edits those, falling back to the
|
|
original it edits without the query)."""
|
|
out = {
|
|
"brand": data.brand,
|
|
"brand_html": data.brand_html,
|
|
"theme": data.theme,
|
|
"custom_css": data.custom_css,
|
|
"favicon": f"/_f/{data.favicon}" if data.favicon else "",
|
|
"themes": views._theme_info(),
|
|
"banner_designs": views._banner_design_names(),
|
|
"fonts": views._user_fonts(),
|
|
"transition": data.transition,
|
|
"transitions": views._transition_names(),
|
|
"translate_keys": data.translate_keys,
|
|
# Unanswered contact threads: the corner ✉️ icon's badge for
|
|
# admins (this probe runs on every page load; no polling).
|
|
"contact_unread": messages.unread_count(),
|
|
# Messaging is enabled by configuring a label (empty = off).
|
|
"contact_label": data.contact_label,
|
|
"contact_email": data.contact_email,
|
|
"contact_subject": data.contact_subject,
|
|
"contact_mailserver": data.contact_mailserver,
|
|
# The site default primary language: the front page's resolved
|
|
# setting (every page may override it, inherited down the tree).
|
|
"primary_lang": i18n.primary_lang(data.menu, ""),
|
|
"translate_langs": sorted(data.translate_langs),
|
|
}
|
|
tag = i18n.base_tag(lang or "")
|
|
if tag and tag != out["primary_lang"]:
|
|
for field in ("contact_label", "contact_subject"):
|
|
original = getattr(data, field)
|
|
out[f"{field}_tr"] = (
|
|
data.trans.get(chunk_key(original), {}).get(tag, "") if original else ""
|
|
)
|
|
return out
|
|
|
|
|
|
class SettingsIn(BaseModel):
|
|
"""Payload for updating site-wide settings."""
|
|
|
|
brand: str
|
|
theme: str
|
|
custom_css: str
|
|
brand_html: str = ""
|
|
transition: str = "cube"
|
|
translate_langs: list[str] | None = None # None keeps the current set
|
|
translate_keys: dict[str, str] | None = None # None keeps the current keys
|
|
contact_label: str | None = None
|
|
contact_email: str | None = None
|
|
contact_subject: str | None = None
|
|
contact_mailserver: str | None = None
|
|
|
|
|
|
@router.put("/_api/settings", status_code=204)
|
|
async def put_settings(
|
|
settings: SettingsIn, request: Request, lang: str | None = None
|
|
) -> None:
|
|
"""Update site-wide settings; invalidates cached pages and ETags.
|
|
|
|
With a ``?lang=`` translation language only the contact label/subject
|
|
apply — as per-language overrides in the chunk-keyed trans store, the
|
|
same storage machine translations use (i18n.set_string_translation);
|
|
empty or the original's text drops the override."""
|
|
tag = i18n.base_tag(lang or "")
|
|
if tag and tag != i18n.primary_lang(data.menu, ""):
|
|
with kanta.transaction(
|
|
f"settings:{tag}", user=request.headers.get("remote-user")
|
|
):
|
|
changed = False
|
|
for field in ("contact_label", "contact_subject"):
|
|
value = getattr(settings, field)
|
|
if value is None:
|
|
continue
|
|
original = getattr(data, field)
|
|
if original:
|
|
changed |= i18n.set_string_translation(
|
|
data, tag, original, value.strip()
|
|
)
|
|
if changed:
|
|
_invalidate_pages()
|
|
return
|
|
with kanta.transaction("settings", user=request.headers.get("remote-user")):
|
|
data.brand = settings.brand
|
|
data.brand_html = settings.brand_html
|
|
data.theme = settings.theme
|
|
data.custom_css = settings.custom_css
|
|
data.transition = settings.transition
|
|
if settings.contact_label is not None:
|
|
data.contact_label = settings.contact_label.strip()
|
|
if settings.contact_email is not None:
|
|
data.contact_email = settings.contact_email.strip()
|
|
if settings.contact_subject is not None:
|
|
data.contact_subject = settings.contact_subject.strip()
|
|
if settings.contact_mailserver is not None:
|
|
data.contact_mailserver = settings.contact_mailserver.strip()
|
|
if settings.translate_langs is not None:
|
|
# Any language may be a target — including the site default
|
|
# (an article in another language can be translated INTO it);
|
|
# a node's own primary is excluded per article, not here.
|
|
data.translate_langs = {
|
|
tag: True
|
|
for lang in settings.translate_langs
|
|
if (tag := i18n.base_tag(lang))
|
|
}
|
|
if settings.translate_keys is not None:
|
|
data.translate_keys = settings.translate_keys
|
|
_invalidate_pages()
|
|
|
|
|
|
@router.delete("/_api/translations", status_code=204)
|
|
async def delete_translations(request: Request) -> None:
|
|
"""Drop all machine translations (Data.trans) so the dispatcher
|
|
re-translates everything from scratch (a translate:reset action:
|
|
the invalidation hook re-offers every fragment to connected
|
|
translators). User overrides are kept; the availability index
|
|
(node.langs) is rebuilt from them — overrides alone still make a
|
|
language exist on a page."""
|
|
with kanta.transaction("translate:reset", user=request.headers.get("remote-user")):
|
|
i18n.clear_translations(data)
|
|
_invalidate_pages()
|
|
# Fragments rejected this run (segment validation) stay skipped no
|
|
# longer: a refresh is precisely the "another chance" for them.
|
|
dispatcher.reset_validation_failures()
|
|
|
|
|
|
class ToggleTaskIn(BaseModel):
|
|
"""Payload for toggling one task-list checkbox."""
|
|
|
|
path: str
|
|
index: int
|
|
markdown: str | None = None
|
|
|
|
|
|
@router.post("/_api/toggle-task")
|
|
async def toggle_task_endpoint(body: ToggleTaskIn, request: Request) -> dict[str, str]:
|
|
"""Toggle the Nth task-list checkbox in a page's Markdown source.
|
|
|
|
If ``markdown`` is provided the source is left untouched and the toggled
|
|
Markdown is returned (used while the page editor is open, so the live
|
|
CodeMirror document can be updated). Otherwise the stored page at
|
|
``path`` is read, toggled, and saved.
|
|
"""
|
|
path = body.path.strip("/")
|
|
_check_reserved(path)
|
|
if body.markdown is not None:
|
|
new_markdown = toggle_task(body.markdown, body.index)
|
|
if new_markdown is None:
|
|
raise HTTPException(400, "invalid task index")
|
|
return {"markdown": new_markdown}
|
|
chain = resolve(data.menu, path)
|
|
node = chain[-1] if chain else None
|
|
if node is None or node.chunks is None:
|
|
raise HTTPException(404, "no such page")
|
|
new_markdown = toggle_task(node_markdown(data, node) or "", body.index)
|
|
if new_markdown is None:
|
|
raise HTTPException(400, "invalid task index")
|
|
with kanta.transaction("page", user=request.headers.get("remote-user"), extra=path):
|
|
# Re-chunk like any save: only the chunk containing the toggled
|
|
# checkbox gets a new hash, the rest keep theirs.
|
|
node.chunks = store_chunks(data.chunks, new_markdown)
|
|
node.modified = datetime.now(UTC)
|
|
_invalidate_pages()
|
|
return {"markdown": new_markdown}
|
|
|
|
|
|
# WebSocket API for external translation services (not under /_api: it is keyed
|
|
# with Data.translate_keys instead of the SSO forward-auth). The dispatcher —
|
|
# protocol, connected clients and the job pipeline — lives in translate.py.
|
|
@router.websocket("/_translate/{clientkey}")
|
|
async def translate_ws(ws: WebSocket, clientkey: str) -> None:
|
|
"""Translator service channel (docs/localization.md).
|
|
|
|
Deliberately NOT under /_api/: the external forward-auth is skipped;
|
|
the server-generated client key in the path is the access control
|
|
(``Data.translate_keys``: key -> display name; the first is generated
|
|
at bootstrap, all are shown in the admin's /_api/settings).
|
|
"""
|
|
await dispatcher.handle_ws(ws, clientkey)
|
|
|
|
|
|
@router.websocket("/_api/ws/editor")
|
|
async def editor_ws(ws: WebSocket) -> None:
|
|
"""Editor session: open pages, render previews, save — over one socket.
|
|
|
|
Stateless protocol (each message carries the path):
|
|
<- {"type": "open", "path", "lang"?}
|
|
-> {"type": "doc", "path", "exists", "title", "markdown", "published",
|
|
"banner", "banner_design", "banner_from", "banner_design_from",
|
|
"banner_design_inherited", "description", "image", "image_resolved",
|
|
"image_mined", "image_source", "favicon", "has_children", "large",
|
|
"lang", "primary_lang", "langs",
|
|
"translate_langs"}
|
|
<- {"type": "render", "path", "markdown"}
|
|
-> {"type": "html", "path", "html"}
|
|
<- {"type": "save", "path", "title"?, "markdown"?, "published"?,
|
|
"banner"?, "banner_design"?, "image"?, "large"?, "move_from"?,
|
|
"lang"?, "base"?}
|
|
(absent fields keep their old values; move_from: rename/move a
|
|
page, subtree included; image: a store hash, "@favicon", "" to
|
|
inherit, or an http(s) URL the server fetches and stores)
|
|
-> {"type": "saved", "path"} | {"type": "error", "detail"}
|
|
|
|
With "lang" (a translation, not the primary language), open returns the
|
|
effective hybrid Markdown and title for that language plus the language
|
|
metadata the picker's UI needs; save diffs the submitted Markdown
|
|
against "base" (the editor's shadow copy of the hybrid it started from
|
|
— absent: the current hybrid) and records it as user overrides, and a
|
|
changed title becomes a fragment in Data.trans — node.chunks and the
|
|
other fields stay untouched (docs/localization.md).
|
|
"""
|
|
await ws.accept()
|
|
try:
|
|
while True:
|
|
msg = await ws.receive_json()
|
|
path = msg.get("path", "").strip("/")
|
|
try:
|
|
_check_reserved(path)
|
|
except HTTPException:
|
|
await ws.send_json({"type": "error", "detail": "reserved path"})
|
|
continue
|
|
match msg.get("type"):
|
|
case "open":
|
|
chain = resolve(data.menu, path)
|
|
node = chain[-1] if chain else None
|
|
# The article's primary language: its own setting,
|
|
# inherited down the tree ("en" final fallback).
|
|
node_lang = i18n.primary_lang(data.menu, path)
|
|
lang = i18n.base_tag(str(msg.get("lang") or ""))
|
|
if lang == node_lang:
|
|
lang = ""
|
|
markdown = ""
|
|
title = node.title if node else ""
|
|
if node is not None:
|
|
markdown = node_markdown(data, node) or ""
|
|
if lang and node.chunks is not None:
|
|
# Translation view: the effective (hybrid)
|
|
# Markdown and title for that language —
|
|
# machine fragments + user overrides over the
|
|
# original (docs/localization.md editor flow).
|
|
markdown = i18n.hybrid_markdown(data, node, path, lang)
|
|
title = i18n.title_map(data, lang).get(path) or title
|
|
# The node's card image: its own setting ("" = inherit,
|
|
# "@favicon" = the site icon), the effective one after
|
|
# inheritance ("" = none, resolved to a store name) and
|
|
# which node supplied an inherited one ("" = front page;
|
|
# "" also when own/none — mirrors banner_from).
|
|
img, img_source = views.card_image(data.menu, path)
|
|
img = views._resolve_image_name(data, img)
|
|
# The card preview's description and mined image,
|
|
# from the same rendered-article heuristics as the
|
|
# og:/twitter: meta (_description, _media).
|
|
html = render(markdown, path).html if markdown else ""
|
|
img_mined = views._media(html)[0] if html else ""
|
|
await ws.send_json(
|
|
{
|
|
"type": "doc",
|
|
"path": path,
|
|
"exists": node is not None,
|
|
"title": title,
|
|
"markdown": markdown,
|
|
"published": node.published if node else True,
|
|
"banner": node.banner if node else "",
|
|
# Own banner design setting: null = inherit,
|
|
# "" = none, otherwise a design name.
|
|
"banner_design": node.banner_design if node else None,
|
|
# Which node's banner applies here ("" = front page,
|
|
# null = default artwork); the site editor shows it
|
|
# as the banner field's placeholder.
|
|
"banner_from": views.banner_source(data.menu, path),
|
|
# Which node's banner-design setting would apply on
|
|
# inherit ("" = front page, null = the active
|
|
# theme's default) and what design that resolves to.
|
|
"banner_design_from": (
|
|
src := views.banner_design_source(
|
|
data.menu, path, data.theme
|
|
)
|
|
),
|
|
"banner_design_inherited": (
|
|
views.banner_design(data.menu, src, data.theme)
|
|
if src is not None
|
|
else views.theme_banner_design(data.theme)
|
|
),
|
|
"description": views._description(html) if html else "",
|
|
"image": node.image if node else "",
|
|
# For the banner panel's image label ("…used in
|
|
# /<path>/*"): the subtree inherits it.
|
|
"has_children": bool(node.children) if node else False,
|
|
"image_resolved": img,
|
|
# The site icon ("" = none): the "@favicon" own
|
|
# setting previews/resolves against it.
|
|
"favicon": data.favicon,
|
|
# The image the og:/twitter: heuristics would mine
|
|
# from the article itself ("" = none): the previews
|
|
# show it when the node has no image of its own
|
|
# (it beats an inherited one).
|
|
"image_mined": img_mined,
|
|
"image_source": (
|
|
"" if node is None or node.image else img_source
|
|
),
|
|
# Card-mode override (per-article, not
|
|
# inherited): null = automatic, otherwise
|
|
# false = small, true = large.
|
|
"large": node.large if node else None,
|
|
# Language context for the editor's picker: the
|
|
# language this Markdown represents ("" = primary),
|
|
# the page's own primary language, the translations
|
|
# this page already has, and the site-wide
|
|
# configured target languages.
|
|
"lang": lang,
|
|
"primary_lang": node_lang,
|
|
"langs": sorted(node.langs) if node else [],
|
|
"translate_langs": sorted(data.translate_langs),
|
|
}
|
|
)
|
|
case "render":
|
|
markdown = msg.get("markdown", "")
|
|
chain = resolve(data.menu, path)
|
|
node = chain[-1] if chain else None
|
|
has_cards_tag = views._CARDS_TAG_RE.search(markdown) is not None
|
|
# {contact} buttons preview like the served page: inert,
|
|
# and nothing at all when messaging is disabled (no
|
|
# configured label). {cards} expands like page_content
|
|
# does, so the preview shows real cards, not the literal
|
|
# tag. No translation context: the preview has no lang of
|
|
# its own, so cards render in their originals.
|
|
directives: dict = {
|
|
"contact": (
|
|
(lambda args, _env: views._contact_tag(args, _env, data))
|
|
if data.contact_label.strip()
|
|
else (lambda _args, _env: "")
|
|
)
|
|
}
|
|
if node is not None and has_cards_tag:
|
|
directives["cards"] = (
|
|
lambda args, _env, node=node, path=path: views._cards_tag(
|
|
data.menu, data, node, path, args
|
|
)
|
|
)
|
|
rendered = render(
|
|
markdown,
|
|
path,
|
|
node.created if node else None,
|
|
node.modified if node else None,
|
|
# The title is injected as h1 when the markdown has
|
|
# none; the editor's title field edits live-preview.
|
|
title=msg.get("title") or (node.title if node else ""),
|
|
# Pin section anchors to the original language so the
|
|
# preview of a translation matches the served page
|
|
# (no-op when the previewed markdown is the original).
|
|
anchors_from=(
|
|
(node_markdown(data, node) or "", node.title)
|
|
if node
|
|
else None
|
|
),
|
|
directives=directives,
|
|
)
|
|
html = rendered.html
|
|
if node is not None and not has_cards_tag:
|
|
# Without a {cards} tag page_content appends the
|
|
# children's cards after the content — the preview
|
|
# replaces the whole article, so include them here.
|
|
doc = E.div
|
|
with doc:
|
|
views._cards(doc, data.menu, data, node, path)
|
|
html += str(doc)
|
|
await ws.send_json(
|
|
{
|
|
"type": "html",
|
|
"path": path,
|
|
"html": html,
|
|
# Column-layout flag: the preview toggles the
|
|
# article's .multicol class and swaps in the
|
|
# segmented (.colseg/.cols) article html.
|
|
"multicol": rendered.multicol,
|
|
}
|
|
)
|
|
case "save":
|
|
move_from = (msg.get("move_from") or path).strip("/")
|
|
lang = i18n.base_tag(str(msg.get("lang") or ""))
|
|
translated = bool(
|
|
lang and lang != i18n.primary_lang(data.menu, move_from)
|
|
)
|
|
try:
|
|
_check_reserved(move_from)
|
|
except HTTPException:
|
|
await ws.send_json({"type": "error", "detail": "reserved path"})
|
|
continue
|
|
old_chain = resolve(data.menu, move_from)
|
|
old = old_chain[-1] if old_chain else None
|
|
if old is None and move_from != path:
|
|
move_from = path # nothing to carry over; plain save
|
|
if move_from != path:
|
|
# Rename/move: detach the node (subtree included)
|
|
# and attach it at the new path. The target slug
|
|
# must be free and the front page childless.
|
|
if move_from and path.startswith(f"{move_from}/"):
|
|
await ws.send_json(
|
|
{
|
|
"type": "error",
|
|
"detail": "cannot move a page under itself",
|
|
}
|
|
)
|
|
continue
|
|
tslug = path.rpartition("/")[2]
|
|
if not tslug and old.children:
|
|
await ws.send_json(
|
|
{
|
|
"type": "error",
|
|
"detail": "the front page cannot have children",
|
|
}
|
|
)
|
|
continue
|
|
tchain = resolve(data.menu, path)
|
|
if tchain is not None:
|
|
await ws.send_json(
|
|
{
|
|
"type": "error",
|
|
"detail": "target path exists",
|
|
}
|
|
)
|
|
continue
|
|
if translated and (
|
|
move_from != path or old is None or old.chunks is None
|
|
):
|
|
# A translated-view save patches an existing
|
|
# original; it cannot create or move pages.
|
|
await ws.send_json({"type": "error", "detail": "no such page"})
|
|
continue
|
|
if translated and not old.chunks:
|
|
# Nothing to anchor a translation to: the original
|
|
# page has no content.
|
|
await ws.send_json(
|
|
{
|
|
"type": "error",
|
|
"detail": "the page has no content to translate",
|
|
}
|
|
)
|
|
continue
|
|
if translated and "markdown" in msg and not msg["markdown"].strip():
|
|
# Saving never deletes; an emptied translation would
|
|
# render as a blank page in that language.
|
|
await ws.send_json(
|
|
{
|
|
"type": "error",
|
|
"detail": "a translation cannot be emptied",
|
|
}
|
|
)
|
|
continue
|
|
image = msg.get("image")
|
|
if image is not None:
|
|
# Card-image setting (inherited by the subtree): a
|
|
# 12-hex content-addressed store name, "@favicon" =
|
|
# the site icon, "" = inherit, or an http(s) URL —
|
|
# pasted image links are fetched and stored
|
|
# server-side (cross-origin is CORS-blocked for the
|
|
# browser), the stored name becomes the setting.
|
|
image = str(image).strip()
|
|
if image.startswith(("http://", "https://")):
|
|
from pagerite.files import fetch_image
|
|
|
|
try:
|
|
# Bare hash name, like an upload's setting.
|
|
image = (await fetch_image(image)).split(".")[0]
|
|
except HTTPException as e:
|
|
await ws.send_json(
|
|
{"type": "error", "detail": str(e.detail)}
|
|
)
|
|
continue
|
|
if (
|
|
image
|
|
and image != "@favicon"
|
|
and not re.fullmatch(r"[0-9a-f]{12}", image)
|
|
):
|
|
await ws.send_json(
|
|
{
|
|
"type": "error",
|
|
"detail": "image must be a store file name",
|
|
}
|
|
)
|
|
continue
|
|
large = msg.get("large")
|
|
if "large" in msg and not (
|
|
large is None or isinstance(large, bool)
|
|
):
|
|
# Card-mode override: null = automatic, true =
|
|
# large, false = small.
|
|
await ws.send_json(
|
|
{
|
|
"type": "error",
|
|
"detail": "large must be null or a boolean",
|
|
}
|
|
)
|
|
continue
|
|
with kanta.transaction(
|
|
f"page:{lang}" if translated else "page",
|
|
user=ws.headers.get("remote-user"),
|
|
extra=path,
|
|
):
|
|
if move_from != path:
|
|
same_menu = (
|
|
move_from.rpartition("/")[0] == path.rpartition("/")[0]
|
|
)
|
|
snodes, sslug = find_slot(data.menu, move_from)
|
|
node = snodes.pop(sslug)
|
|
parent = path.rpartition("/")[0]
|
|
if parent:
|
|
_ensure(data.menu, parent)
|
|
tnodes, tslug = find_slot(data.menu, path)
|
|
node.order = (
|
|
node.order if same_menu else append_order(tnodes)
|
|
)
|
|
tnodes[tslug] = node
|
|
else:
|
|
node = old if old is not None else _ensure(data.menu, path)
|
|
if translated:
|
|
# node.chunks and the original-language fields
|
|
# stay untouched: the markdown diff (against the
|
|
# editor's shadow "base" — the hybrid it started
|
|
# from; absent: the current hybrid) is recorded
|
|
# as user overrides, a changed title becomes a
|
|
# per-language title override (i18n).
|
|
changed = False
|
|
if "markdown" in msg:
|
|
base = msg.get("base")
|
|
changed = i18n.record_override(
|
|
data,
|
|
node,
|
|
path,
|
|
lang,
|
|
msg["markdown"],
|
|
base=base if isinstance(base, str) else None,
|
|
)
|
|
if "title" in msg and node.title:
|
|
changed = (
|
|
i18n.set_title_translation(
|
|
data, node, lang, msg["title"]
|
|
)
|
|
or changed
|
|
)
|
|
if changed:
|
|
_invalidate_pages()
|
|
else:
|
|
if "markdown" in msg:
|
|
# Saving never deletes; empty markdown is an
|
|
# empty page. Deletion is an explicit choice
|
|
# by the page editor (REST DELETE).
|
|
node.chunks = store_chunks(data.chunks, msg["markdown"])
|
|
if "title" in msg:
|
|
node.title = msg["title"]
|
|
if "published" in msg:
|
|
node.published = bool(msg["published"])
|
|
if "banner" in msg:
|
|
node.banner = msg["banner"]
|
|
if "banner_design" in msg:
|
|
node.banner_design = msg["banner_design"]
|
|
if image is not None:
|
|
# Part of every render's social meta and card
|
|
# covers: a change invalidates everywhere.
|
|
node.image = image
|
|
if "large" in msg:
|
|
# Per-article card-mode override (not
|
|
# inherited); None = automatic.
|
|
node.large = large
|
|
node.modified = datetime.now(UTC)
|
|
_invalidate_pages()
|
|
await ws.send_json({"type": "saved", "path": path})
|
|
except WebSocketDisconnect:
|
|
pass
|