From e77b5c5c93b0c738ccece1306e86423e3914678f Mon Sep 17 00:00:00 2001 From: Tim Date: Thu, 24 Sep 2026 14:54:40 -0700 Subject: [PATCH 01/10] notice --- CLAUDE.md | 2 +- backend/gamma/notices.py | 141 +++++++++++++++++++++++++++++----- backend/gamma/sync_engine.py | 10 +++ backend/tests/test_notices.py | 78 +++++++++++++++++++ docs/dev/api.md | 2 +- docs/dev/settings.md | 24 ++++-- 6 files changed, 228 insertions(+), 29 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 187b90a5..02e48820 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,7 +115,7 @@ Frontend has no linter. UI changes are verified by relevant flows in the browser - The rendered view's `![[embed]]` cards are a full editing surface for the SOURCE block at the synced position: checkboxes, image hover tools and table editing on the card write through (same mdTools transforms); clicking the text edits the raw source in place with the block editor's math live preview + `\command` autocomplete (shared `useMathUi` hook) and image/table paste. Same-page sources route through onChangeText/autosave, cross-page sources `PUT /blocks/{id}` + ref-cache merge. The footer jumps to the source. Notion-style external-link chips show favicon + fetched title via `/api/link-preview`; GitHub URLs are parsed locally to `owner/repo #N`. - `src/editor/MdTools.jsx` — in-place tools on the RENDERED notes (Notion-style hover affordances, markdown-native storage): `MdImage` (hover toolbar of flat `ctlBtn` icons — zoom lightbox, caption stored as the alt text, download, delete — plus a drag grip on each side of the centred picture (`shared/ui/ResizeGrip.jsx`, shared with Mermaid diagrams, whose size lands in the fence info string as `width=N`) writing the image size Obsidian-style, `![alt|300](url)`; the legacy Logseq `{:width N}` suffix still renders and is normalized to the pipe form on any edit) and `MdTableWrap` (every note table's scroll wrapper; when editable, hover "+" strips add a row/column, column/row handle pills move their column/row by drag (drop-line indicator, one moveCol/moveRow op on release) and a plain click opens a compact menu — insert/delete plus, for columns, a settingsKit `Segmented` alignment chooser, and clicking a cell edits it IN PLACE — tables are deliberately never edited as raw markdown, unlike equations: the cell mousedown stops the block row's edit-on-mousedown, Tab/Shift-Tab hop cells across the commit remount via a module-level session map keyed by block id + table index, Enter commits, Esc cancels). All edits are pure source transforms: `scanImages`/`scanTables` locate the nth rendered construct with the same span-exclusion rules as `mdPreprocess` (tables inside blockquotes stay index-aligned but uneditable), `applyImageEdit`/`applyTableEdit` rewrite it (tables re-serialized pretty-printed — so every table edit auto-formats; `formatTables` additionally runs when a block's raw editor closes, hooked on the `editMode` transition in `BlockRow`, not editor blur), and a failed scan is a no-op — never a guess. `BlockMarkdown` counts rendered images/tables in document order to hand each its index (same idiom as task checkboxes). - Settings dialog: `src/settings/SettingsDialog.jsx` (one sidebar in three groups — Preferences / AI / Manage — and most panes) + `src/settings/SettingsKit.jsx` (pane primitives and shared controls) + `src/settings/SettingsAi.jsx` + `src/settings/SettingsUsers.jsx`. Panes are built ONLY from the settingsKit primitives; a choice is a `Segmented` (two or three words), `IconChoices` (a small exclusive set as icon tiles) or a `MenuSelect`/`ActionMenu` dropdown (`shared/ui/Menus.jsx`); new controls belong in the shared set — `settings/settings.css` is layout only. Panes carry no explanatory subtitles: a row's short hint plus its hover `title`. Pane map and design rules: [docs/dev/settings.md](docs/dev/settings.md), [docs/dev/ui-design.md](docs/dev/ui-design.md). -- Notices (`src/app/notices.js` + `useNotices.js`, `gamma/notices.py`, `/api/notices`): the red dot on the account button for what wants a look once (a newer release, log errors); each notice names the Settings pane that resolves it, showing the pane records its fingerprint per account, and "Settings…" opens on the strongest one. New sources are `@source` functions in `gamma/notices.py`, cheap reads only. Details: [docs/dev/settings.md](docs/dev/settings.md). +- Notices (`src/app/notices.js` + `useNotices.js`, `gamma/notices.py`, `/api/notices`): the red dot on the account button for what wants a look once (a newer release and log errors for admins; a failed backup task, open clone conflicts, a failed cloud sync, storage nearly full for everyone); each notice names the Settings pane that resolves it, showing the pane records its fingerprint per account, and "Settings…" opens on the strongest one. New sources are `@source` functions in `gamma/notices.py`, cheap reads only. Details: [docs/dev/settings.md](docs/dev/settings.md). - Theme: Settings → Appearance — System plus the seven pinned themes in `THEMES` (`app/prefs.js`; `gamma-theme` in localStorage, an inline script in `index.html` applies a pinned theme before first paint) plus display-only "Flip page colors" (`gamma-pdf-dark`). - Sharing a page: `src/sharing/SharePopover.jsx` (the header link button, a popover under it like the account menu) — link + Copy + Stop sharing, access as three audience tiles (anyone / signed in / invited only) plus a View / Edit toggle, invited people with their own access, the citation section; built from the settings kit like the workspace Manage dialog. There is no "reset link" — stop and share again. Data functions stay in App.jsx (`loadShareSettings`, `updateShareSettings`, …). - View modes are derived from the URL: `/` home, `/?page=` page (with PDF if it has `source_url`), `/?share=` the share view (`shareMode`: no library/chat/prefs; `readOnly` is state — false once the link resolves with edit rights, and `utils.withShare` puts the token on every API call), `/?block=` jump-to-block; every non-share URL also carries `ws=`. diff --git a/backend/gamma/notices.py b/backend/gamma/notices.py index ec4f90ef..cde397b0 100644 --- a/backend/gamma/notices.py +++ b/backend/gamma/notices.py @@ -1,31 +1,37 @@ """What wants a look: the notices behind the red dot on the account button. A notice is one thing an account should see once — a newer Gamma release, -errors in the server log — and it points at the Settings pane that shows -it. Each carries a *fingerprint* naming what changed (the release version, -the seq of the newest error); "resolved" means the account has seen that -fingerprint, recorded in the account-wide ``notices-seen`` pref as -``{id: fingerprint}``. Visiting the pane records it (the frontend's -``useNotices``); a new release or a fresh error changes the fingerprint -and the notice is back on its own. Nothing is ever dismissed for good. - -Sources are plain functions registered with ``@source``; each returns a -Notice or None and must be cheap — a cached or in-memory read — because -``for_user`` runs on every poll of ``GET /api/notices``. Admin-only -sources are skipped for everyone else, so a member's poll does no work -beyond that. The one network call, the release check, sits behind -``version.latest_release``'s six-hour cache. +errors in the server log, a failed backup task — and it points at the +Settings pane that shows it. Each carries a *fingerprint* naming what +changed (the release version, the seq of the newest error, the failed +task's run time); "resolved" means the account has seen that fingerprint, +recorded in the account-wide ``notices-seen`` pref as ``{id: fingerprint}``. +Visiting the pane records it (the frontend's ``useNotices``); a new release +or a fresh error changes the fingerprint and the notice is back on its own. +Nothing is ever dismissed for good. + +Sources are plain functions ``fn(username) -> Notice | None`` registered +with ``@source``; each must be cheap — a cached, in-memory or small +database read — because ``for_user`` runs them on every poll of +``GET /api/notices``. Admin-only sources are skipped for everyone else. +The one network call, the release check, sits behind +``version.latest_release``'s six-hour cache; the one directory walk, the +storage usage, runs only for an account under a quota and is remembered +for a while. """ import re +import threading +import time from dataclasses import asdict, dataclass -from . import logbuf, version +from . import backup_schedule, cloud_sync, logbuf, server_settings, sync_engine, version from .db import NOTICES_SEEN_PREF_KEY, get_pref, set_pref TONES = ("info", "warn", "error") _ID_RE = re.compile(r"^[a-z][a-z0-9-]{0,31}$") _MAX_SEEN = 64 +MB = 1024 * 1024 @dataclass(frozen=True) @@ -41,15 +47,19 @@ class Notice: def source(*, admin_only=False): - """Register a notice source: ``fn() -> Notice | None``.""" + """Register a notice source: ``fn(username) -> Notice | None``.""" def wrap(fn): _SOURCES.append((fn, admin_only)) return fn return wrap +def _plural(n: int, word: str) -> str: + return f"{n} {word}" + ("" if n == 1 else "s") + + @source(admin_only=True) -def update_available(): +def update_available(_username): """A newer GitHub release than this build (nothing for a checkout, an air-gapped server or an unreachable GitHub).""" release, _error = version.latest_release() @@ -62,7 +72,7 @@ def update_available(): @source(admin_only=True) -def log_errors(): +def log_errors(_username): """Errors logged since the account last looked at the server log. The fingerprint is the start time plus the newest error's seq: a restart resets both, so an old ack never covers a new error.""" @@ -73,6 +83,99 @@ def log_errors(): return Notice("log-errors", f"{started}:{seq}", "error", "server", "New errors in the server log") +@source() +def backup_failed(username): + """The account's backup tasks whose last run failed (Settings → + Backups shows the error). Another failed run, of any of them, is a new + fingerprint.""" + failed = [t for t in backup_schedule.list_tasks(username) if t.get("state") == "failed"] + if not failed: + return None + mark = ",".join(f"{t['id'][:12]}:{t.get('last_run') or ''}" for t in sorted(failed, key=lambda t: t["id"])) + title = (f'The backup task "{failed[0]["name"]}" failed' if len(failed) == 1 + else f"{_plural(len(failed), 'backup task')} failed") + return Notice("backup-failed", mark, "error", "backups", title) + + +@source() +def mirror_conflicts(username): + """Open conflicts in the clones the account owns (Settings → + Workspaces → Clones). Fingerprint: per clone, the count and the newest + conflict — a new one brings the notice back, resolving old ones does + not.""" + marks, total = [], 0 + for mirror in sync_engine.list_mirrors(username): + count, newest = sync_engine.open_conflict_mark(mirror["workspace_id"]) + if count: + marks.append(f"{mirror['workspace_id']}:{count}:{newest}") + total += count + if not total: + return None + return Notice("mirror-conflicts", ",".join(marks), "warn", "workspaces", + f"{_plural(total, 'sync conflict')} to look at in your clones") + + +@source() +def cloud_sync_failed(username): + """The account's Gamma Cloud sync in its error state (the Account + pane's cloud row says why).""" + status = cloud_sync.profile_status(username) + if status.get("state") != "error": + return None + error = (status.get("error") or "").strip().rstrip(".") + return Notice("cloud-sync", status.get("at") or "", "warn", "account", + f"Gamma Cloud sync failed: {error}" if error else "Gamma Cloud sync failed") + + +# The storage walk is the one source that is not a free read: usage is +# remembered per account for a few minutes, and only computed at all when +# the account is under a quota. +_USAGE_TTL = 10 * 60 +_usage: dict[str, tuple[float, int]] = {} +_usage_lock = threading.Lock() + + +def _usage_bytes(username: str) -> int: + now = time.monotonic() + with _usage_lock: + known = _usage.get(username) + if known and now - known[0] < _USAGE_TTL: + return known[1] + used = server_settings.usage_bytes(username) + with _usage_lock: + _usage[username] = (now, used) + return used + + +def forget_usage(username: str | None = None) -> None: + """Drop the remembered usage (tests; a caller that just changed it).""" + with _usage_lock: + if username is None: + _usage.clear() + else: + _usage.pop(username, None) + + +@source() +def storage_nearly_full(username): + """The account's personal storage past nine tenths of its quota (warn) + or full (error). Fingerprint: the threshold crossed, so each fires once + until the pane is seen — and again after the usage drops and climbs + back.""" + quota_mb = server_settings.user_limits(username).get("quota_mb") or 0 + if not quota_mb: + return None + used = _usage_bytes(username) + share = used / (quota_mb * MB) + if share >= 1: + return Notice("storage", "full", "error", "account", + f"Your storage is full ({used // MB} of {quota_mb} MB used)") + if share >= 0.9: + return Notice("storage", "90", "warn", "account", + f"Your storage is nearly full ({used // MB} of {quota_mb} MB used)") + return None + + def seen_map(username: str) -> dict: value, _ = get_pref(username, NOTICES_SEEN_PREF_KEY) return value if isinstance(value, dict) else {} @@ -81,7 +184,7 @@ def seen_map(username: str) -> dict: def for_user(username: str, is_admin: bool) -> list[dict]: """The unresolved notices of an account, strongest tone first.""" found = [notice for fn, admin_only in _SOURCES if is_admin or not admin_only - if (notice := fn()) is not None] + if (notice := fn(username)) is not None] if not found: return [] seen = seen_map(username) diff --git a/backend/gamma/sync_engine.py b/backend/gamma/sync_engine.py index e97d5bf2..813c76cd 100644 --- a/backend/gamma/sync_engine.py +++ b/backend/gamma/sync_engine.py @@ -647,6 +647,16 @@ def open_conflicts(ws: str) -> int: return conn.execute("SELECT COUNT(*) FROM sync_conflicts WHERE resolved = 0").fetchone()[0] +def open_conflict_mark(ws: str) -> tuple[int, int]: + """``(count, newest id)`` of the open conflicts — the notice's + fingerprint (gamma/notices.py): a new conflict changes it, resolving + some of the old ones does not bring the notice back.""" + with connect_pages_db(ws) as conn: + count, newest = conn.execute( + "SELECT COUNT(*), COALESCE(MAX(id), 0) FROM sync_conflicts WHERE resolved = 0").fetchone() + return int(count), int(newest) + + def list_conflicts(ws: str, *, resolved: bool = False, page_id: str = "") -> list[dict]: """The decisions to look at (or the looked-at ones), newest first, one page's only when ``page_id`` is given.""" diff --git a/backend/tests/test_notices.py b/backend/tests/test_notices.py index 91e1b430..6a40999e 100644 --- a/backend/tests/test_notices.py +++ b/backend/tests/test_notices.py @@ -94,3 +94,81 @@ def test_strongest_first_and_bad_acks(nadmin, monkeypatch): nadmin.post(f"/api/notices/{n['id']}/seen", json={"fingerprint": n["fingerprint"]}) assert _ids(nadmin) == [] assert set(notices.seen_map("nadmin")) == {"update", "log-errors"} + + +# --- the account sources (their helpers stubbed: each is a plain read) ------ + +def _only(client, notice_id): + found = [n for n in client.get("/api/notices").json()["notices"] if n["id"] == notice_id] + return found[0] if found else None + + +def test_backup_failed_until_seen_and_again_on_the_next_failure(nuser, monkeypatch): + tasks = [{"id": "a" * 32, "name": "Nightly", "state": "finished", "last_run": "2026-09-20T01:00:00"}] + monkeypatch.setattr(notices.backup_schedule, "list_tasks", lambda owner: tasks if owner == "nuser" else []) + assert _only(nuser, "backup-failed") is None + tasks[0].update(state="failed", last_run="2026-09-21T01:00:00", last_error="disk full") + notice = _only(nuser, "backup-failed") + assert notice["tone"] == "error" and notice["pane"] == "backups" and 'task "Nightly" failed' in notice["title"] + nuser.post("/api/notices/backup-failed/seen", json={"fingerprint": notice["fingerprint"]}) + assert _only(nuser, "backup-failed") is None + tasks[0]["last_run"] = "2026-09-22T01:00:00" # failed again + assert _only(nuser, "backup-failed")["fingerprint"] != notice["fingerprint"] + tasks.append({"id": "b" * 32, "name": "Weekly", "state": "failed", "last_run": "2026-09-22T02:00:00"}) + assert _only(nuser, "backup-failed")["title"] == "2 backup tasks failed" + + +def test_mirror_conflicts_count_new_ones_only(nuser, monkeypatch): + marks = {"ws-clone": (0, 0)} + monkeypatch.setattr(notices.sync_engine, "list_mirrors", lambda owner: [{"workspace_id": "ws-clone"}] if owner == "nuser" else []) + monkeypatch.setattr(notices.sync_engine, "open_conflict_mark", lambda ws: marks[ws]) + assert _only(nuser, "mirror-conflicts") is None + marks["ws-clone"] = (3, 7) + notice = _only(nuser, "mirror-conflicts") + assert notice["pane"] == "workspaces" and notice["tone"] == "warn" and notice["title"].startswith("3 sync conflicts") + nuser.post("/api/notices/mirror-conflicts/seen", json={"fingerprint": notice["fingerprint"]}) + marks["ws-clone"] = (3, 7) + assert _only(nuser, "mirror-conflicts") is None + marks["ws-clone"] = (2, 8) # one resolved, one new + assert _only(nuser, "mirror-conflicts")["title"].startswith("2 sync conflicts") + + +def test_cloud_sync_error_names_the_reason(nuser, monkeypatch): + status = {"state": "off", "at": "", "error": ""} + monkeypatch.setattr(notices.cloud_sync, "profile_status", lambda username: status) + assert _only(nuser, "cloud-sync") is None + status.update(state="error", at="2026-09-24T10:00:00Z", error="Gamma Cloud could not be reached.") + notice = _only(nuser, "cloud-sync") + assert notice["pane"] == "account" and notice["title"] == "Gamma Cloud sync failed: Gamma Cloud could not be reached" + assert notice["fingerprint"] == "2026-09-24T10:00:00Z" + status.update(state="synced") + assert _only(nuser, "cloud-sync") is None + + +def test_storage_thresholds_and_the_remembered_walk(nuser, monkeypatch): + limits = {"quota_mb": 0} + walks = [] + + def usage(username): + walks.append(username) + return used[0] + used = [0] + monkeypatch.setattr(notices.server_settings, "user_limits", lambda username: dict(limits)) + monkeypatch.setattr(notices.server_settings, "usage_bytes", usage) + notices.forget_usage() + assert _only(nuser, "storage") is None and walks == [] # no quota: no walk at all + limits["quota_mb"] = 100 + used[0] = 50 * notices.MB + assert _only(nuser, "storage") is None and walks == ["nuser"] + nuser.get("/api/notices") + assert walks == ["nuser"] # remembered + notices.forget_usage("nuser") + used[0] = 95 * notices.MB + notice = _only(nuser, "storage") + assert notice["tone"] == "warn" and notice["fingerprint"] == "90" and "95 of 100 MB" in notice["title"] + nuser.post("/api/notices/storage/seen", json={"fingerprint": "90"}) + assert _only(nuser, "storage") is None + notices.forget_usage() + used[0] = 100 * notices.MB + notice = _only(nuser, "storage") + assert notice["tone"] == "error" and notice["fingerprint"] == "full" diff --git a/docs/dev/api.md b/docs/dev/api.md index 14dd0927..284c4c15 100644 --- a/docs/dev/api.md +++ b/docs/dev/api.md @@ -330,7 +330,7 @@ archived conversation browsing remains session-only. ### Notices (`notices.py`, `gamma/notices.py`) — see [settings.md](settings.md) "Notices" | Method | Path | Purpose | |---|---|---| -| GET | `/notices` | `{notices: [{id, fingerprint, tone, pane, title}]}` the account has not looked at yet, strongest `tone` (`info` / `warn` / `error`) first; `pane` is the Settings pane that resolves it. Admin-only sources (`update`: a newer GitHub release; `log-errors`: errors logged since the last look) are skipped for members; guests and integration tokens get `[]`. Sync: the release check may hit the network when its cache is stale | +| GET | `/notices` | `{notices: [{id, fingerprint, tone, pane, title}]}` the account has not looked at yet, strongest `tone` (`info` / `warn` / `error`) first; `pane` is the Settings pane that resolves it. Sources: `update` and `log-errors` (admins: a newer GitHub release, errors logged since the last look), `backup-failed`, `mirror-conflicts`, `cloud-sync`, `storage` (everyone: a failed backup task, open conflicts in an owned clone, a failed Gamma Cloud sync, personal storage past 90 % or full) — the table in [settings.md](settings.md); guests and integration tokens get `[]`. Sync: the release check may hit the network when its cache is stale | | POST | `/notices/{id}/seen` | `{fingerprint}` — the account has seen this version of the notice (kept in the account-wide `notices-seen` pref); it stays quiet until the fingerprint changes. 403 for guests and tokens, 400 for a malformed id or fingerprint | ### Integrations and MCP (`routers/integrations.py`, `mcp_oauth.py`, `mcp_server.py`) — see [mcp.md](mcp.md) diff --git a/docs/dev/settings.md b/docs/dev/settings.md index a217d21b..7ebc660a 100644 --- a/docs/dev/settings.md +++ b/docs/dev/settings.md @@ -126,14 +126,22 @@ that fingerprint, kept in the `notices-seen` pref; a new release or a fresh error changes the fingerprint and the notice is back by itself. Nothing is dismissed for good, and nothing is per browser. -Sources are functions registered with `@source` in `gamma/notices.py`; each -returns a Notice or None and must be a cached or in-memory read, because -`GET /api/notices` runs them on every poll. Admin-only sources are skipped -for members, so a member's poll does no work at all; guests, share views -and integration tokens get an empty list. The first two sources — the -release check (behind `version.latest_release`'s six-hour cache; sync -endpoint on purpose) and the log errors (`logbuf.last_seq("error")`) — both -point at the Server pane. +Sources are functions `fn(username)` registered with `@source` in +`gamma/notices.py`; each returns a Notice or None and must be a cached, +in-memory or small database read, because `GET /api/notices` runs them on +every poll. Admin-only sources are skipped for members; guests, share views +and integration tokens get an empty list. The sources: + +| id | who | pane | tone | fires when | fingerprint | +|---|---|---|---|---|---| +| `update` | admins | Server | warn | a newer GitHub release than this build (`version.latest_release`, six-hour cache; the endpoint is sync on purpose) | the release version | +| `log-errors` | admins | Server | error | an error was logged since the last look (`logbuf.last_seq("error")`) | server start time + the newest error's seq | +| `backup-failed` | everyone | Backups | error | a backup task of the account is in state `failed` (`backup_schedule.list_tasks`) | each failed task's id + its last run | +| `mirror-conflicts` | everyone | Workspaces | warn | a clone the account owns has open sync conflicts (`sync_engine.open_conflict_mark`) | per clone, the count + the newest conflict id — resolving old ones never brings it back | +| `cloud-sync` | everyone | Account | warn | the account's Gamma Cloud sync is in its `error` state (`cloud_sync.profile_status`) | the failure's timestamp | +| `storage` | everyone | Account | warn / error | personal storage past 90 % of the quota / full; only computed for an account under a quota, and the upload walk is remembered ten minutes (`notices.forget_usage`) | `90` / `full` | + +Warnings in the log are deliberately not a notice (too noisy for a dot). The frontend: `app/useNotices.js` (one instance in App.jsx) polls every five minutes and on window focus, and `app/notices.js` (pure, From a0d31c6155373ce7cdf7eb6063c0f1df7e57b780 Mon Sep 17 00:00:00 2001 From: Tim Date: Thu, 24 Sep 2026 15:27:38 -0700 Subject: [PATCH 02/10] cloud sync publish page, locale support, first trial --- CLAUDE.md | 2 + backend/gamma/app.py | 6 + backend/gamma/config.py | 25 ++ backend/gamma/publish.py | 263 ++++++++++++++++++- backend/gamma/routers/cloud_auth.py | 9 +- backend/gamma/routers/pages.py | 10 +- backend/gamma/routers/publish.py | 53 +++- backend/tests/test_publish.py | 187 +++++++++++++ docs/dev/api.md | 10 +- docs/dev/cloud_accounts.md | 47 ++++ docs/dev/i18n.md | 176 +++++++++++++ docs/dev/mirror.md | 75 +++++- docs/dev/settings.md | 8 +- frontend/package.json | 3 +- frontend/src/README.md | 3 +- frontend/src/app/App.jsx | 114 +++++--- frontend/src/app/prefDefs.js | 4 + frontend/src/main.jsx | 25 +- frontend/src/settings/SettingsAppearance.jsx | 52 ++-- frontend/src/settings/SettingsDialog.jsx | 57 ++-- frontend/src/settings/sectionPrefs.js | 1 + frontend/src/settings/settingsNavigation.js | 1 + frontend/src/shared/i18n/i18n.js | 107 ++++++++ frontend/src/shared/i18n/locales.js | 19 ++ frontend/src/shared/i18n/locales/zh.json | 66 +++++ frontend/src/shared/lib/slug.js | 37 +++ frontend/src/shared/lib/utils.js | 15 +- frontend/src/shared/styles/app.css | 1 + frontend/src/sharing/SharePopover.jsx | 56 ++-- frontend/tests/e2e/harness.mjs | 15 +- frontend/tests/e2e/run.mjs | 2 + frontend/tests/e2e/scenarios/i18n.mjs | 49 ++++ frontend/tests/e2e/scenarios/publish.mjs | 62 ++++- frontend/tests/i18n.test.mjs | 53 ++++ frontend/tests/sectionPrefs.test.mjs | 2 +- frontend/tests/slug.test.mjs | 28 ++ frontend/tools/i18n.mjs | 109 ++++++++ tests/shared/slug.json | 95 +++++++ 38 files changed, 1688 insertions(+), 159 deletions(-) create mode 100644 docs/dev/i18n.md create mode 100644 frontend/src/shared/i18n/i18n.js create mode 100644 frontend/src/shared/i18n/locales.js create mode 100644 frontend/src/shared/i18n/locales/zh.json create mode 100644 frontend/src/shared/lib/slug.js create mode 100644 frontend/tests/e2e/scenarios/i18n.mjs create mode 100644 frontend/tests/i18n.test.mjs create mode 100644 frontend/tests/slug.test.mjs create mode 100644 frontend/tools/i18n.mjs create mode 100644 tests/shared/slug.json diff --git a/CLAUDE.md b/CLAUDE.md index 02e48820..c9e3f1dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,6 +40,7 @@ Topic docs live in `docs/dev/` — **read the relevant one before working in tha - [docs/dev/desktop.md](docs/dev/desktop.md) — the Windows/macOS/Linux desktop app (`desktop/`, Electron): servers (local sidecars + remote URLs) and Gamma's workspaces in one switcher, launcher, sidecar lifecycle, PyInstaller freeze, the release workflow. - [docs/dev/ipad.md](docs/dev/ipad.md) — Gamma on the iPad: the web app installed to the home screen (the manifest and icons under `/media/`, the status-bar colour following the theme, standalone-mode CSS), why there is no native client, what only the device can verify. - [docs/dev/onboarding.md](docs/dev/onboarding.md) — the first-run guide (`src/guide/`: engine + first tour built, started from `?guide=`; welcome page, synced pref, checklist and hints still design): `data-guide` anchors and their registry (`guide/anchors.js` — the guide never selects by class), declarative tours (`guide/tours/*.js`) checked off by named events (`guide/events.js`), the tests that keep tours from rotting. Read before adding, moving or removing a control the guide points at. +- [docs/dev/i18n.md](docs/dev/i18n.md) — the interface language: `t()` / `tn()` / `T()` with the English sentence as the key (`src/shared/i18n/`), one JSON catalog per language, the `language` preference and its Settings row, `npm run i18n -- --sync` + `tests/i18n.test.mjs` keeping every catalog complete, the Chinese glossary. Read before adding or changing any user-visible string. - [docs/dev/settings.md](docs/dev/settings.md) — where every setting is stored (localStorage / synced prefs / server), the Settings dialog's pane and file layout, storage limits. - [docs/dev/ui-design.md](docs/dev/ui-design.md) — the unified control classes, settings primitives, theme system, layout rules, frontend file map. - [docs/dev/debugging.md](docs/dev/debugging.md) — run/test/debug: commands, test suite, log surfaces, the in-app problem report (`src/support/`, the GitHub issue forms), common gotchas. @@ -116,6 +117,7 @@ Frontend has no linter. UI changes are verified by relevant flows in the browser - `src/editor/MdTools.jsx` — in-place tools on the RENDERED notes (Notion-style hover affordances, markdown-native storage): `MdImage` (hover toolbar of flat `ctlBtn` icons — zoom lightbox, caption stored as the alt text, download, delete — plus a drag grip on each side of the centred picture (`shared/ui/ResizeGrip.jsx`, shared with Mermaid diagrams, whose size lands in the fence info string as `width=N`) writing the image size Obsidian-style, `![alt|300](url)`; the legacy Logseq `{:width N}` suffix still renders and is normalized to the pipe form on any edit) and `MdTableWrap` (every note table's scroll wrapper; when editable, hover "+" strips add a row/column, column/row handle pills move their column/row by drag (drop-line indicator, one moveCol/moveRow op on release) and a plain click opens a compact menu — insert/delete plus, for columns, a settingsKit `Segmented` alignment chooser, and clicking a cell edits it IN PLACE — tables are deliberately never edited as raw markdown, unlike equations: the cell mousedown stops the block row's edit-on-mousedown, Tab/Shift-Tab hop cells across the commit remount via a module-level session map keyed by block id + table index, Enter commits, Esc cancels). All edits are pure source transforms: `scanImages`/`scanTables` locate the nth rendered construct with the same span-exclusion rules as `mdPreprocess` (tables inside blockquotes stay index-aligned but uneditable), `applyImageEdit`/`applyTableEdit` rewrite it (tables re-serialized pretty-printed — so every table edit auto-formats; `formatTables` additionally runs when a block's raw editor closes, hooked on the `editMode` transition in `BlockRow`, not editor blur), and a failed scan is a no-op — never a guess. `BlockMarkdown` counts rendered images/tables in document order to hand each its index (same idiom as task checkboxes). - Settings dialog: `src/settings/SettingsDialog.jsx` (one sidebar in three groups — Preferences / AI / Manage — and most panes) + `src/settings/SettingsKit.jsx` (pane primitives and shared controls) + `src/settings/SettingsAi.jsx` + `src/settings/SettingsUsers.jsx`. Panes are built ONLY from the settingsKit primitives; a choice is a `Segmented` (two or three words), `IconChoices` (a small exclusive set as icon tiles) or a `MenuSelect`/`ActionMenu` dropdown (`shared/ui/Menus.jsx`); new controls belong in the shared set — `settings/settings.css` is layout only. Panes carry no explanatory subtitles: a row's short hint plus its hover `title`. Pane map and design rules: [docs/dev/settings.md](docs/dev/settings.md), [docs/dev/ui-design.md](docs/dev/ui-design.md). - Notices (`src/app/notices.js` + `useNotices.js`, `gamma/notices.py`, `/api/notices`): the red dot on the account button for what wants a look once (a newer release and log errors for admins; a failed backup task, open clone conflicts, a failed cloud sync, storage nearly full for everyone); each notice names the Settings pane that resolves it, showing the pane records its fingerprint per account, and "Settings…" opens on the strongest one. New sources are `@source` functions in `gamma/notices.py`, cheap reads only. Details: [docs/dev/settings.md](docs/dev/settings.md). +- Interface language ([docs/dev/i18n.md](docs/dev/i18n.md)): every user-visible string in the frontend goes through `t("English sentence")` from `src/shared/i18n/i18n.js` (`tn` for counts, `T` to mark a string in a static table); the English text is the key, `locales/zh.json` holds the Chinese. A new string needs its catalog line in the same change: `npm run i18n -- --sync` adds the empty key, `node --test tests/i18n.test.mjs` fails until it is filled. The browser suite pins `en-US` on every context. - Theme: Settings → Appearance — System plus the seven pinned themes in `THEMES` (`app/prefs.js`; `gamma-theme` in localStorage, an inline script in `index.html` applies a pinned theme before first paint) plus display-only "Flip page colors" (`gamma-pdf-dark`). - Sharing a page: `src/sharing/SharePopover.jsx` (the header link button, a popover under it like the account menu) — link + Copy + Stop sharing, access as three audience tiles (anyone / signed in / invited only) plus a View / Edit toggle, invited people with their own access, the citation section; built from the settings kit like the workspace Manage dialog. There is no "reset link" — stop and share again. Data functions stay in App.jsx (`loadShareSettings`, `updateShareSettings`, …). - View modes are derived from the URL: `/` home, `/?page=` page (with PDF if it has `source_url`), `/?share=` the share view (`shareMode`: no library/chat/prefs; `readOnly` is state — false once the link resolves with edit rights, and `utils.withShare` puts the token on every API call), `/?block=` jump-to-block; every non-share URL also carries `ws=`. diff --git a/backend/gamma/app.py b/backend/gamma/app.py index d15d95f2..21c0c21b 100644 --- a/backend/gamma/app.py +++ b/backend/gamma/app.py @@ -10,6 +10,7 @@ from . import backup_schedule, cloud_sync, config, migrations from . import sync_engine, version +from .publish import check_config as check_publish_config from .auth import session_middleware from .db import connect_data_db, connect_pages_db, connect_users_db from .logbuf import log, setup_logging @@ -76,6 +77,11 @@ def _startup_maintenance(): then per workspace: prune orphaned uploads and apply the per-file schema statements (a restored backup gains page_ops, WAL, ...).""" log.info(f"[startup] Gamma {version.label()}") + try: + check_publish_config() + except ValueError as e: + log.error(f"[startup] {e}") + raise SystemExit(1) try: done = migrations.ensure_current() except migrations.MigrationError as e: diff --git a/backend/gamma/config.py b/backend/gamma/config.py index eaa7be14..fcca0ea8 100644 --- a/backend/gamma/config.py +++ b/backend/gamma/config.py @@ -57,6 +57,31 @@ def cloud_env() -> dict: "share_host": os.environ.get("GAMMA_CLOUD_SHARE_HOST", "").strip().lower() in ("1", "true", "yes", "on")} +# The share host's published-page cap per Gamma Cloud plan (gamma/publish.py +# page_cap): a plan missing here is unlimited. GAMMA_FREE_PAGE_LIMIT +# overrides the free plan's number (0 lifts the cap). +PLAN_PAGE_LIMITS = {"free": 5} + + +def plan_page_limits() -> dict: + limits = dict(PLAN_PAGE_LIMITS) + raw = os.environ.get("GAMMA_FREE_PAGE_LIMIT", "").strip() + if raw: + n = int(raw) # checked at startup (publish.check_config) + if n > 0: + limits["free"] = n + else: + limits.pop("free", None) + return limits + + +def page_host_pattern() -> str: + """``GAMMA_PAGE_HOST``: the share host's per-account page hostname with + a ``{username}`` placeholder, e.g. ``{username}-pages.gammapdf.com`` + ("" = no pretty addresses, token links only; gamma/publish.py).""" + return os.environ.get("GAMMA_PAGE_HOST", "").strip().lower() + + def sync_interval_s() -> int: """Seconds between mirror sync rounds (gamma/sync_engine.py); 0 turns the background loop off (the API's "sync now" still works).""" diff --git a/backend/gamma/publish.py b/backend/gamma/publish.py index a21b07bc..770d1590 100644 --- a/backend/gamma/publish.py +++ b/backend/gamma/publish.py @@ -18,13 +18,20 @@ A workspace that already mirrors another server (a lab NAS) cannot publish: one remote per copy, and the page's home is that other server. + +The share host also caps how many pages a plan may publish (``page_cap``, +``config.PLAN_PAGE_LIMITS``) and, with ``GAMMA_PAGE_HOST`` set, gives every +published page a pretty address on a hostname per account +(``https://-pages.gammapdf.com/-``, resolved by +``resolve_public``; the slug is decoration, the trailing id routes). """ import json import re -from urllib.parse import urlsplit +import unicodedata +from urllib.parse import unquote, urlsplit -from . import cloud_auth, integrations, ratelimit, sync_engine, workspaces +from . import cloud_auth, config, integrations, ratelimit, sync_engine, workspaces from .auth import SHARE_AUDIENCES, SHARE_ROLES from .cloud_auth import CloudAuthError from .db import connect_pages_db, connect_users_db @@ -38,12 +45,14 @@ class PublishError(Exception): - """A refusal with the HTTP status the router answers.""" + """A refusal with the HTTP status the router answers (``extra``: more + fields for the answer's body, next to ``detail``).""" - def __init__(self, status: int, message: str): + def __init__(self, status: int, message: str, extra: dict | None = None): super().__init__(message) self.status = status self.message = message + self.extra = extra or {} def this_is_share_host() -> bool: @@ -56,6 +65,145 @@ def publishing_blocked() -> bool: return this_is_share_host() +# --- slugs and page hosts --------------------------------------------------------- + +SLUG_MAX = 60 +PLACEHOLDER = "{username}" +_LABEL_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$") + + +def slug(title: str) -> str: + """The decorative part of a page's public path: the title ASCII-folded + (NFKD, marks dropped), lowercased, every run of anything but ``[a-z0-9]`` + one ``-``, trimmed, at most ``SLUG_MAX`` characters. "" when nothing is + left (a CJK title). Mirrored in frontend/src/shared/lib/slug.js; + tests/shared/slug.json pins both.""" + text = unicodedata.normalize("NFKD", title or "") + text = "".join(c for c in text if not unicodedata.category(c).startswith("M")).lower() + text = re.sub(r"[^a-z0-9]+", "-", text).strip("-") + return text[:SLUG_MAX].rstrip("-") + + +def _valid_host(host: str) -> bool: + labels = host.split(".") + return len(host) <= 253 and len(labels) >= 2 and all(_LABEL_RE.match(label) for label in labels) + + +def check_config() -> None: + """Startup: ``GAMMA_PAGE_HOST`` names ``{username}`` exactly once and is + a hostname otherwise; ``GAMMA_FREE_PAGE_LIMIT`` is a whole number. + ValueError with the reason.""" + try: + config.plan_page_limits() + except ValueError: + raise ValueError("GAMMA_FREE_PAGE_LIMIT must be a whole number (0 lifts the cap).") from None + pattern = config.page_host_pattern() + if not pattern: + return + if pattern.count(PLACEHOLDER) != 1: + raise ValueError("GAMMA_PAGE_HOST must contain {username} exactly once, e.g. {username}-pages.example.org.") + if not _valid_host(pattern.replace(PLACEHOLDER, "x")): + raise ValueError("GAMMA_PAGE_HOST must be a hostname with {username} in it (no scheme, port or path), " + "e.g. {username}-pages.example.org.") + + +def page_host_user(pattern: str, host: str) -> str: + """The username a page hostname names under ``pattern`` ("" when + ``host`` — a Host header, port allowed — does not match it).""" + if not pattern or PLACEHOLDER not in pattern: + return "" + head, tail = pattern.split(PLACEHOLDER, 1) + host = re.sub(r":\d+$", "", (host or "").strip().lower()) + m = re.fullmatch(re.escape(head) + r"([a-z0-9](?:[a-z0-9-]*[a-z0-9])?)" + re.escape(tail), host) + return m.group(1) if m and _valid_host(host) else "" + + +def public_url(base_url: str, pattern: str, username: str, title: str, page_id: str) -> str: + """A published page's pretty address for the share host at ``base_url`` + (its scheme and port): ``:///-``, + just ``/`` without a slug. "" when there is no pattern or the + username cannot be a hostname label.""" + host = pattern.replace(PLACEHOLDER, (username or "").lower()) if pattern and username else "" + if not host or not _valid_host(host): + return "" + parts = urlsplit(base_url) + port = f":{parts.port}" if parts.port else "" + name = slug(title) + return f"{parts.scheme or 'https'}://{host}{port}/{name + '-' if name else ''}{page_id}" + + +def resolve_public(host: str, path: str) -> dict: + """``{share, page_id}`` for a page host's path: ``host`` names the + account (``page_host_user``), ``path`` is ``/-`` or ``/``, + and only the trailing id counts — a root page of that account's default + personal workspace that has a share. PublishError(404) otherwise. A page + id may hold a ``-`` itself, so every tail of the path after a ``-`` is a + candidate, the longest shared page winning.""" + missing = PublishError(404, "page not found") + username = page_host_user(config.page_host_pattern(), host) + segment = unquote(path or "").strip("/") + if not username or not segment or "/" in segment or len(segment) > 200: + raise missing + with connect_users_db() as conn: + rows = conn.execute("SELECT default_workspace FROM users WHERE LOWER(username) = ? AND is_guest = 0", + (username,)).fetchall() + ws = rows[0][0] if len(rows) == 1 else "" + if not ws or (workspaces.get(ws) or {}).get("kind") != "personal": + raise missing + candidates = [segment] + [segment[i + 1:] for i, c in enumerate(segment) if c == "-" and segment[i + 1:]] + marks = ",".join("?" * len(candidates)) + with connect_users_db() as conn: + shares = dict(conn.execute(f"SELECT page_id, token FROM shares WHERE workspace_id = ? AND page_id IN ({marks})", + (ws, *candidates)).fetchall()) + if not shares: + raise missing + with connect_pages_db(ws) as conn: + roots = {r[0] for r in conn.execute( + f"SELECT id FROM unified_blocks WHERE parent_id = 'root' AND id IN ({marks})", candidates)} + for page_id in candidates: + if page_id in roots and page_id in shares: + return {"share": shares[page_id], "page_id": page_id} + raise missing + + +# --- the plan's page cap (the share host) ----------------------------------------- + +def cap_message(plan: str, limit: int) -> str: + return (f"{(plan or 'Your').capitalize()} plan: up to {limit} published pages. " + "Unpublish one, or upgrade your Gamma Cloud plan.") + + +def page_cap(ws: str) -> dict: + """``{used, max, plan}`` of a workspace here: its root pages, and the + cap its owner's plan (the linked identity's last ``plan`` claim, stored + at every exchange and sign-in) puts on it. ``max`` is None when there is + none: on a server that is not a share host, for a workspace that is not + its owner's default personal one (the one the exchange publishes into), + for an owner without a cloud identity, and for a plan + ``config.PLAN_PAGE_LIMITS`` does not name.""" + owner = workspaces.personal_owner(ws) + plan = str((cloud_auth.status_of(owner) or {}).get("plan") or "") if owner else "" + cap = None + if owner and this_is_share_host() and workspaces.default_workspace(owner) == ws: + cap = config.plan_page_limits().get(plan) + with connect_pages_db(ws) as conn: + used = conn.execute("SELECT COUNT(*) FROM unified_blocks WHERE parent_id = 'root'").fetchone()[0] + return {"used": used, "max": cap, "plan": plan} + + +def cap_refusal(ws: str) -> dict | None: + """The 402 body (``{detail, limit, used, plan}``) when one more root page + in ``ws`` would pass its plan's cap, else None. Reads nothing more unless + this server is a share host.""" + if not this_is_share_host(): + return None + cap = page_cap(ws) + if cap["max"] is None or cap["used"] < cap["max"]: + return None + return {"detail": cap_message(cap["plan"], cap["max"]), "limit": cap["max"], "used": cap["used"], + "plan": cap["plan"]} + + # --- the share host --------------------------------------------------------------- def token_name(caller: str) -> str: @@ -220,6 +368,76 @@ def _json(remote: Remote, method: str, path: str, payload: dict | None = None) - return json.loads(data) if data else {} +def _read_limit(remote: Remote) -> dict | None: + """``{used, max, plan}`` of the person's workspace on the share host + (``GET /api/publish/limit`` under the mirror's token), None when it + cannot be read. Nothing is cached.""" + try: + out = _json(remote, "GET", "/api/publish/limit") + except (RemoteError, ValueError): + return None + if not isinstance(out, dict) or not isinstance(out.get("used"), int): + return None + cap = out.get("max") + return {"used": out["used"], "max": cap if isinstance(cap, int) else None, "plan": str(out.get("plan") or "")} + + +def _page_host(host: str) -> str: + """The share host's page-host pattern from its ``/api/server-config`` + ("" when it has none or cannot be read).""" + try: + cfg = Remote(host, "", "").get("/api/server-config") or {} + except (RemoteError, ValueError): + return "" + return str(cfg.get("page_host") or "").strip().lower() if isinstance(cfg, dict) else "" + + +def _title(ws: str, page_id: str) -> str: + with connect_pages_db(ws) as conn: + row = conn.execute("SELECT content FROM unified_blocks WHERE id = ?", (page_id,)).fetchone() + return row[0] if row else "" + + +def _addresses(mirror: dict, ws: str, page_id: str, token: str) -> dict: + """The answer's ``url`` (the token link, which always works) and + ``public_url`` (the pretty address when the share host has page hosts, + else the token link).""" + url = f"{mirror['remote_url']}/?share={token}" + pretty = public_url(mirror["remote_url"], _page_host(mirror["remote_url"]), + mirror["status"].get("remote_user") or "", _title(ws, page_id), page_id) + return {"url": url, "public_url": pretty or url} + + +def _cap_error(limit: dict, detail: str = "") -> PublishError: + return PublishError(409, detail or cap_message(limit["plan"], limit["max"]), {"limit": limit}) + + +def _check_cap(user: str, ws: str, page_id: str, host: str, server_name: str) -> None: + """Before a page goes to the share host for the first time: refuse it + (409, the cap message, ``limit``) when the person's workspace there is + full, taking it back out of the filter. The share host's plan is the + one its last exchange saw, so a full workspace is exchanged once more + first: an upgrade counts at once.""" + mirror = sync_engine.get_mirror(ws, with_token=True) + if _synced(ws, page_id) or mirror["page_filter"] is None: + return + limit = _read_limit(_remote(mirror)) + if limit and limit["max"] is not None and limit["used"] >= limit["max"]: + try: + _exchange(user, host, server_name) # hands every publishing mirror of the account the new token + limit = _read_limit(_remote(sync_engine.get_mirror(ws, with_token=True))) or limit + except PublishError: + pass + if limit and limit["max"] is not None and limit["used"] >= limit["max"]: + _drop(ws, page_id) + raise _cap_error(limit) + + +def _drop(ws: str, page_id: str) -> None: + with sync_engine.round_lock(ws): + sync_engine.filter_remove(ws, [page_id]) + + def _synced(ws: str, page_id: str) -> bool: with connect_pages_db(ws) as conn: return bool(conn.execute("SELECT 1 FROM sync_pages WHERE page_id = ?", (page_id,)).fetchone()) @@ -237,7 +455,8 @@ def publish(user: str, ws: str, page_id: str, *, audience: str | None = None, ro """Publish ``page_id``: into the filtered mirror (made when missing), one round now, then the share on the share host (default anyone / view; ``audience`` / ``role`` set it, on a new link or an existing one). - ``{url, share, mirror: {ws, status, ...}}``.""" + ``{url, public_url, share, mirror: {ws, status, ...}}``. A page new to + the share host must fit the person's plan there (``_check_cap``).""" if audience is not None and audience not in SHARE_AUDIENCES: raise PublishError(400, "audience must be anyone, users or list") if role is not None and role not in SHARE_ROLES: @@ -249,9 +468,19 @@ def publish(user: str, ws: str, page_id: str, *, audience: str | None = None, ro raise PublishError(409, SIGN_IN) host = share_host() _mirror_for(user, ws, page_id, host, server_name) + _check_cap(user, ws, page_id, host, server_name) status = sync_engine.sync_workspace(ws) if not _synced(ws, page_id): - raise PublishError(502, f"The page did not reach the share host: {status.get('last_error') or 'try again'}") + error = status.get("last_error") or "" + if error.startswith(f"{page_id}: 402: "): + # the cap reached between the check and the round: the share host's own words + _drop(ws, page_id) + mirror = sync_engine.get_mirror(ws, with_token=True) + if mirror["page_filter"]: + sync_engine.sync_workspace(ws) # a clean round, so the refusal does not stay the mirror's error + limit = _read_limit(_remote(mirror)) or {"used": 0, "max": None, "plan": ""} + raise _cap_error(limit, error[len(f"{page_id}: 402: "):]) + raise PublishError(502, f"The page did not reach the share host: {error or 'try again'}") mirror = sync_engine.get_mirror(ws, with_token=True) remote = _remote(mirror) wanted = {k: v for k, v in (("audience", audience), ("role", role)) if v is not None} @@ -261,7 +490,7 @@ def publish(user: str, ws: str, page_id: str, *, audience: str | None = None, ro share = _json(remote, "PUT", f"/api/share-settings/{page_id}", wanted) except RemoteError as e: raise PublishError(502, f"The share host refused the share: {e.detail or e}") from e - return {"url": f"{mirror['remote_url']}/?share={share['token']}", "share": share, "mirror": _mirror_view(mirror)} + return {**_addresses(mirror, ws, page_id, share["token"]), "share": share, "mirror": _mirror_view(mirror)} def unpublish(user: str, ws: str, page_id: str) -> dict: @@ -288,11 +517,13 @@ def unpublish(user: str, ws: str, page_id: str) -> dict: def state(user: str, ws: str, page_id: str) -> dict: - """``{published, can_publish, reason?, url?, share?, status?, mirror?, - error?}``: whether the page is published (in the filter of the - workspace's mirror of the share host), its live share there, and the - mirror's raw status. ``can_publish`` / ``reason`` say whether the - Publish action would be refused before it is tried.""" + """``{published, can_publish, reason?, url?, public_url?, share?, + status?, mirror?, limit?, error?}``: whether the page is published (in + the filter of the workspace's mirror of the share host), its live share + there and addresses, the mirror's raw status, and the person's page cap + there (``limit: {used, max, plan}``, read whenever a publishing token + exists). ``can_publish`` / ``reason`` say whether the Publish action + would be refused before it is tried.""" _require_page(ws, page_id) out: dict = {"published": False, "can_publish": True} reason, host = "", "" @@ -310,6 +541,12 @@ def state(user: str, ws: str, page_id: str) -> dict: reason = e.message if reason: out.update(can_publish=False, reason=reason) + else: + # the cap there, through this workspace's publishing token or another of the account's + lender = mirror if mirror and mirror["page_filter"] is not None else next(iter(_publish_mirrors(user, host)), None) + limit = _read_limit(_remote(lender)) if lender else None + if limit: + out["limit"] = limit # only publishing makes a filtered mirror; a full one counts when it follows the share host if not mirror or (mirror["page_filter"] is None and not (host and _same(mirror["remote_url"], host))): return out @@ -327,5 +564,5 @@ def state(user: str, ws: str, page_id: str) -> dict: if mirror["page_filter"] is not None: out["published"] = True if share.get("token"): - out.update(published=True, share=share, url=f"{mirror['remote_url']}/?share={share['token']}") + out.update(published=True, share=share, **_addresses(mirror, ws, page_id, share["token"])) return out diff --git a/backend/gamma/routers/cloud_auth.py b/backend/gamma/routers/cloud_auth.py index 670a5350..c42fed32 100644 --- a/backend/gamma/routers/cloud_auth.py +++ b/backend/gamma/routers/cloud_auth.py @@ -1,7 +1,9 @@ """Sign in with Gamma Cloud — the wire around ``gamma/cloud_auth.py``: - ``GET /api/server-config`` (public): what the login page needs — whether - cloud sign-in is on and the account server's address; + cloud sign-in is on and the account server's address — and ``page_host``, + the per-account page hostname pattern (``GAMMA_PAGE_HOST``, "" = none), + by which the app knows it was opened on a page host (gamma/publish.py); - ``GET /api/auth/cloud/start?next=&link=1`` → redirect to the account server (``link=1`` with a session attaches the identity to that account); - ``GET /api/auth/cloud/callback?code=&state=`` → session cookie + redirect @@ -20,7 +22,7 @@ from fastapi import APIRouter, HTTPException, Request from fastapi.responses import RedirectResponse -from .. import cloud_auth, cloud_sync, ratelimit +from .. import cloud_auth, cloud_sync, config, ratelimit from ..auth import require_personal_user, require_user, set_session_cookie from ..cloud_auth import CloudAuthError from ..db import connect_users_db @@ -34,7 +36,8 @@ async def server_config(): cfg = cloud_auth.settings() return {"cloud": {"enabled": cfg["enabled"], "issuer": cfg["issuer"] if cfg["enabled"] else ""}, - "password_login": True, "registration": False, "guest": not cfg["share_host"]} + "password_login": True, "registration": False, "guest": not cfg["share_host"], + "page_host": config.page_host_pattern()} @router.get("/api/auth/cloud/start") diff --git a/backend/gamma/routers/pages.py b/backend/gamma/routers/pages.py index a53e4294..958f8c3e 100644 --- a/backend/gamma/routers/pages.py +++ b/backend/gamma/routers/pages.py @@ -16,7 +16,7 @@ from fastapi.responses import JSONResponse from pydantic import BaseModel -from .. import block_index +from .. import block_index, publish from ..auth import require_ws from ..blocks_store import ( BLOCK_COLUMNS, @@ -68,7 +68,10 @@ def _load_page(conn, page_id: str): async def create_page_endpoint(payload: PageCreate, request: Request): """A new text-only page: ``{title?, folder?}`` → the page's block dict. Title defaults to "Untitled"; ``folder`` (a path like ``a/b``) becomes - ``properties.folder``.""" + ``properties.folder``. On a share host, 402 with ``{detail, limit, + used, plan}`` when the owner's plan allows no more pages in the + workspace (the path a publishing mirror creates its pages by; + gamma/publish.py page_cap).""" ws = require_ws(request, write=True) props = dict(payload.properties or {}) folder = clean_path(payload.folder or "") @@ -79,6 +82,9 @@ async def create_page_endpoint(payload: PageCreate, request: Request): with connect_pages_db(ws) as conn: if payload.id and conn.execute("SELECT 1 FROM unified_blocks WHERE id = ?", (payload.id,)).fetchone(): raise HTTPException(status_code=409, detail="a block with that id exists") + refusal = publish.cap_refusal(ws) # only a new page counts + if refusal: + return JSONResponse(status_code=402, content=refusal) return create_page(conn, payload.title, props, block_id=payload.id) diff --git a/backend/gamma/routers/publish.py b/backend/gamma/routers/publish.py index 48d3231f..4d0cdc48 100644 --- a/backend/gamma/routers/publish.py +++ b/backend/gamma/routers/publish.py @@ -5,6 +5,10 @@ Bearer `` and ``{server?}`` → ``{token, workspace_id, username, url}``, a write token on the person's workspace there; +- on the share host, ``GET /api/publish/limit`` (the mirror's token) → + ``{used, max, plan}``, the plan's page cap on the person's workspace + there, and ``GET /api/pages/resolve-public?host=&path=`` (no auth) → + ``{share, page_id}`` for a page host's pretty address; - on the publishing server, ``POST / DELETE / GET /api/pages/{id}/publish`` for a page of the request's workspace. @@ -14,10 +18,11 @@ import socket from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import JSONResponse from pydantic import BaseModel, Field from .. import cloud_auth, publish, ratelimit -from ..auth import require_personal_user, require_ws +from ..auth import note_share_miss, require_personal_user, require_ws router = APIRouter(tags=["publish"]) @@ -31,8 +36,8 @@ class PublishBody(BaseModel): role: str | None = Field(default=None, pattern="^(view|edit)$") -def _refused(e: publish.PublishError) -> HTTPException: - return HTTPException(e.status, e.message) +def _refused(e: publish.PublishError) -> JSONResponse: + return JSONResponse({"detail": e.message, **e.extra}, status_code=e.status) @router.post("/api/auth/cloud/exchange") @@ -51,7 +56,7 @@ def cloud_exchange(request: Request, payload: ExchangeBody | None = None): try: return publish.exchange(token, (payload or ExchangeBody()).server, cloud_auth.callback_base(request)) except publish.PublishError as e: - raise _refused(e) + return _refused(e) def _caller(request: Request, write: bool) -> tuple[str, str]: @@ -69,14 +74,17 @@ def publish_page(page_id: str, request: Request, payload: PublishBody | None = N there; default anyone / view) → ``{url, share, mirror: {ws, status, page_filter, conflicts_open, pending_local}}``. 409 with a message when publishing is not possible here (no Gamma Cloud identity, no share host, - a workspace that is a copy of another server, a share host itself).""" + a workspace that is a copy of another server, a share host itself); + 409 with ``limit: {used, max, plan}`` too when the person's plan allows + no more published pages there. The answer also carries ``public_url``, + the pretty address when the share host has page hosts (else ``url``).""" user, ws = _caller(request, write=True) payload = payload or PublishBody() try: return publish.publish(user, ws, page_id, audience=payload.audience, role=payload.role, server_name=_server_name(request)) except publish.PublishError as e: - raise _refused(e) + return _refused(e) @router.delete("/api/pages/{page_id}/publish") @@ -87,15 +95,40 @@ def unpublish_page(page_id: str, request: Request): try: return publish.unpublish(user, ws, page_id) except publish.PublishError as e: - raise _refused(e) + return _refused(e) @router.get("/api/pages/{page_id}/publish") def publication(page_id: str, request: Request): - """``{published, can_publish, reason?, url?, share?, status?, mirror?, - error?}`` — any member of the workspace.""" + """``{published, can_publish, reason?, url?, public_url?, share?, + status?, mirror?, limit?, error?}`` — any member of the workspace.""" user, ws = _caller(request, write=False) try: return publish.state(user, ws, page_id) except publish.PublishError as e: - raise _refused(e) + return _refused(e) + + +@router.get("/api/publish/limit") +def publish_limit(request: Request): + """The share host's half: ``{used, max, plan}`` — the root pages of the + request's workspace (a publishing mirror's token names it) and the cap + its owner's plan puts on them (``max`` null = none).""" + if not publish.this_is_share_host(): + raise HTTPException(404, "This server does not accept published pages.") + return publish.page_cap(require_ws(request)) + + +@router.get("/api/pages/resolve-public") +def resolve_public(request: Request, host: str = "", path: str = ""): + """A page host's pretty address (``host`` the hostname the browser + shows, ``path`` its ``/-``) → ``{share, page_id}``: the share + token the share view then opens with, audience and role its own. 404 + for anything else. No auth; per IP, and misses count as unknown share + links do.""" + ratelimit.check(f"resolve-public:ip:{ratelimit.client_ip(request)}", 120, 300) + try: + return publish.resolve_public(host[:300], path[:300]) + except publish.PublishError as e: + note_share_miss(request) + return _refused(e) diff --git a/backend/tests/test_publish.py b/backend/tests/test_publish.py index 0351df28..570ec110 100644 --- a/backend/tests/test_publish.py +++ b/backend/tests/test_publish.py @@ -6,6 +6,8 @@ test_cloud_auth.py grown a /userinfo and a share host address.""" import io +import json +from pathlib import Path import pytest from fastapi.testclient import TestClient @@ -384,3 +386,188 @@ def test_publish_needs_a_cloud_identity_and_a_home_of_its_own(publishing, monkey insert(local, page["id"], "pb_blk", "x") monkeypatch.setattr(publish, "publishing_blocked", lambda: False) assert local.post("/api/pages/pb_blk/publish").status_code == 400 + + +# --- the plan's page cap (the share host) ---------------------------------------------- + +CAP_DETAIL = "Free plan: up to 5 published pages. Unpublish one, or upgrade your Gamma Cloud plan." + + +def test_the_share_host_caps_published_pages_by_plan(publishing, monkeypatch): + local, local_ws = linked(publishing, "pbcap") + host_ws = workspaces.default_workspace("pbcap") + host = bound(login("pbcap", "pw"), host_ws) + pages = [local.post("/api/pages", json={"title": f"Paper {n}"}).json() for n in range(7)] + for page in pages[:5]: + r = local.post(f"/api/pages/{page['id']}/publish") + assert r.status_code == 200, r.text + assert len(page_ids(host)) == 5 + # the local workspace is not the one the exchange publishes into: no cap there + assert len(page_ids(local)) == 7 + + # the state reads the count there + state = local.get(f"/api/pages/{pages[5]['id']}/publish").json() + assert state["limit"] == {"used": 5, "max": 5, "plan": "free"} and state["can_publish"] is True + assert anonymous().get("/api/publish/limit").status_code == 401 + assert host.get("/api/publish/limit").json() == {"used": 5, "max": 5, "plan": "free"} + + # the sixth: 409 here with the share host's words and the count; nothing moved, nothing left in the filter + r = local.post(f"/api/pages/{pages[5]['id']}/publish") + assert r.status_code == 409, r.text + assert r.json() == {"detail": CAP_DETAIL, "limit": {"used": 5, "max": 5, "plan": "free"}} + assert pages[5]["id"] not in sync_engine.get_mirror(local_ws)["page_filter"] + assert pages[5]["id"] not in page_ids(host) + # the count unreadable beforehand (a share host that fills up meanwhile): the round's 402 says the same + read_limit = publish._read_limit + monkeypatch.setattr(publish, "_read_limit", lambda remote: None) + r = local.post(f"/api/pages/{pages[5]['id']}/publish") + assert r.status_code == 409 and r.json()["detail"] == CAP_DETAIL + assert pages[5]["id"] not in sync_engine.get_mirror(local_ws)["page_filter"] + assert not sync_engine.get_mirror(local_ws)["status"]["last_error"] + monkeypatch.setattr(publish, "_read_limit", read_limit) + # ...and 402 on the share host itself, for any new page + r = host.post("/api/pages", json={"title": "One more"}) + assert r.status_code == 402 + assert r.json() == {"detail": CAP_DETAIL, "limit": 5, "used": 5, "plan": "free"} + # a page that is already there answers as before (a round re-creating it tolerates the 409) + assert host.post("/api/pages", json={"id": pages[0]["id"], "title": "x"}).status_code == 409 + # the cap never stops a round of a page already published + insert(local, pages[0]["id"], "pbcap_n1", "an edit at the cap") + sync(local_ws) + assert texts(host, pages[0]["id"]) == {"pbcap_n1": "an edit at the cap"} + + # unpublishing one frees a slot + assert local.delete(f"/api/pages/{pages[1]['id']}/publish").status_code == 200 + r = local.post(f"/api/pages/{pages[5]['id']}/publish") + assert r.status_code == 200, r.text + assert len(page_ids(host)) == 5 + + # an upgrade counts at the next publish: the full workspace is exchanged again, the plan read afresh + publishing.people["sub-pbcap"]["plan"] = "plus" + r = local.post(f"/api/pages/{pages[6]['id']}/publish") + assert r.status_code == 200, r.text + assert len(page_ids(host)) == 6 + assert host.get("/api/publish/limit").json() == {"used": 6, "max": None, "plan": "plus"} + assert host.post("/api/pages", json={"title": "Seventh"}).status_code == 200 + + # GAMMA_FREE_PAGE_LIMIT sets the free plan's number + publishing.people["sub-pbcap"]["plan"] = "free" + with connect_users_db() as conn: + cloud_auth.link(conn, "pbcap", publishing.people["sub-pbcap"]) + conn.commit() + monkeypatch.setenv("GAMMA_FREE_PAGE_LIMIT", "10") + assert host.get("/api/publish/limit").json() == {"used": 7, "max": 10, "plan": "free"} + monkeypatch.setenv("GAMMA_FREE_PAGE_LIMIT", "0") + assert host.get("/api/publish/limit").json()["max"] is None + monkeypatch.delenv("GAMMA_FREE_PAGE_LIMIT") + + # a server that is not a share host ignores the table + monkeypatch.setenv("GAMMA_CLOUD_SHARE_HOST", "0") + assert host.post("/api/pages", json={"title": "Not a share host"}).status_code == 200 + assert host.get("/api/publish/limit").status_code == 404 + + +# --- public addresses: slugs and page hosts ------------------------------------------------ + +SLUGS = json.loads((Path(__file__).resolve().parents[2] / "tests" / "shared" / "slug.json").read_text(encoding="utf-8")) + + +@pytest.mark.parametrize("case", SLUGS["slug"], ids=[c["note"] for c in SLUGS["slug"]]) +def test_slug(case): + assert publish.slug(case["input"]) == case["output"] + + +def test_the_page_host_pattern(monkeypatch): + pattern = "{username}-pages.gammapdf.com" + assert publish.page_host_user(pattern, "tim-pages.gammapdf.com") == "tim" + assert publish.page_host_user(pattern, "Tim-Pages.GammaPDF.com:8443") == "tim" + assert publish.page_host_user(pattern, "a-pages-pages.gammapdf.com") == "a-pages" + for other in ("api.gammapdf.com", "x.tim-pages.gammapdf.com", "-pages.gammapdf.com", "tim-pages.gammapdf.com.evil"): + assert publish.page_host_user(pattern, other) == "", other + assert publish.public_url("https://share.gammapdf.com", pattern, "Tim", "Élan vital", "p-1") \ + == "https://tim-pages.gammapdf.com/elan-vital-p-1" + assert publish.public_url("http://127.0.0.1:9001", pattern, "tim", "量子", "abc") \ + == "http://tim-pages.gammapdf.com:9001/abc" + assert publish.public_url("https://share.gammapdf.com", pattern, "tim_x", "t", "abc") == "" + assert publish.public_url("https://share.gammapdf.com", "", "tim", "t", "abc") == "" + # checked at startup + for good in ("", "{username}-pages.gammapdf.com", "{username}.pages.example.org"): + monkeypatch.setenv("GAMMA_PAGE_HOST", good) + publish.check_config() + for bad in ("pages.gammapdf.com", "{username}-{username}.x.org", "https://{username}.x.org", "{username}.x.org:8443", + "{username}_pages.x.org", "{username}"): + monkeypatch.setenv("GAMMA_PAGE_HOST", bad) + with pytest.raises(ValueError): + publish.check_config() + monkeypatch.delenv("GAMMA_PAGE_HOST") + monkeypatch.setenv("GAMMA_FREE_PAGE_LIMIT", "five") + with pytest.raises(ValueError): + publish.check_config() + + +def resolve(host, path): + return anonymous().get("/api/pages/resolve-public", params={"host": host, "path": path}) + + +def test_the_resolver_opens_a_shared_page_by_its_pretty_address(monkeypatch): + monkeypatch.setenv("GAMMA_PAGE_HOST", "{username}-pages.example.org") + ws = make_user("pbresolve", "pw") + make_user("pbresolve2", "pw") + owner = bound(login("pbresolve", "pw"), ws) + page = owner.post("/api/pages", json={"title": "My paper"}).json() + dashed = owner.post("/api/pages", json={"id": "ab-cd-ef", "title": "Dashed id"}).json() + unshared = owner.post("/api/pages", json={"title": "Private"}).json() + insert(owner, page["id"], "pbresolve_b1", "a block") + token = owner.post(f"/api/share/{page['id']}").json()["token"] + dashed_token = owner.post(f"/api/share/{dashed['id']}").json()["token"] + assert anonymous().get("/api/server-config").json()["page_host"] == "{username}-pages.example.org" + + want = {"share": token, "page_id": page["id"]} + host = "pbresolve-pages.example.org" + assert resolve(host, f"/my-paper-{page['id']}").json() == want + assert resolve(host, f"/{page['id']}").json() == want # no slug + assert resolve(host, f"/renamed-since-{page['id']}/").json() == want # the slug is decoration + assert resolve("PBResolve-Pages.example.org:8443", f"/x-{page['id']}").json() == want + assert resolve(host, "/dashed-id-ab-cd-ef").json() == {"share": dashed_token, "page_id": "ab-cd-ef"} + assert resolve(host, "/ab-cd-ef").json()["page_id"] == "ab-cd-ef" + for miss_host, path in ( + ("pbresolve2-pages.example.org", f"/my-paper-{page['id']}"), # another account's host + ("nobody-pages.example.org", f"/{page['id']}"), # no such account + ("pbresolve.example.org", f"/{page['id']}"), # not a page host + (host, f"/private-{unshared['id']}"), # not shared + (host, "/pbresolve_b1"), # not a page + (host, "/"), (host, f"/a/{page['id']}"), + ): + assert resolve(miss_host, path).status_code == 404, (miss_host, path) + # only the account's default personal workspace is served + other_ws = workspaces.create("Second", "pbresolve")["id"] + second = bound(login("pbresolve", "pw"), other_ws) + other = second.post("/api/pages", json={"title": "Elsewhere"}).json() + second.post(f"/api/share/{other['id']}") + assert resolve(host, f"/elsewhere-{other['id']}").status_code == 404 + + # the share's own audience still rules: signed-in only is the share view's 401 + owner.put(f"/api/share-settings/{page['id']}", json={"audience": "users"}) + assert resolve(host, f"/my-paper-{page['id']}").json() == want + assert anonymous().get(f"/api/share/{token}").status_code == 401 + # the page host off: nothing resolves + monkeypatch.delenv("GAMMA_PAGE_HOST") + assert resolve(host, f"/my-paper-{page['id']}").status_code == 404 + assert anonymous().get("/api/server-config").json()["page_host"] == "" + + +def test_publish_answers_carry_the_public_address(publishing, monkeypatch): + monkeypatch.setenv("GAMMA_PAGE_HOST", "{username}-pages.example.org") + local, local_ws = linked(publishing, "pbpretty") + page = local.post("/api/pages", json={"title": "Café Notes: Part 1"}).json() + out = local.post(f"/api/pages/{page['id']}/publish").json() + token = out["share"]["token"] + assert out["url"] == f"{HOST}/?share={token}" + assert out["public_url"] == f"http://pbpretty-pages.example.org/cafe-notes-part-1-{page['id']}" + state = local.get(f"/api/pages/{page['id']}/publish").json() + assert state["public_url"] == out["public_url"] and state["url"] == out["url"] + assert resolve("pbpretty-pages.example.org", f"/cafe-notes-part-1-{page['id']}").json() \ + == {"share": token, "page_id": page["id"]} + # without page hosts the public address is the token link + monkeypatch.delenv("GAMMA_PAGE_HOST") + assert local.get(f"/api/pages/{page['id']}/publish").json()["public_url"] == out["url"] diff --git a/docs/dev/api.md b/docs/dev/api.md index 284c4c15..e5916fb8 100644 --- a/docs/dev/api.md +++ b/docs/dev/api.md @@ -112,7 +112,7 @@ else; in dev, Vite proxies `/api` → `127.0.0.1:9001`. | Method | Path | Purpose | |---|---|---| | POST | `/login`, `/login-guest`, `/logout` | session management (`/login` refuses an account with an empty password hash — one only its cloud identity signs in; `/login-guest` is 403 on a share host) | -| GET | `/server-config` | public: what the login page offers besides a password — `{cloud: {enabled, issuer}, password_login, registration, guest}` (`guest` false on a share host) ([cloud_accounts.md](cloud_accounts.md)) | +| GET | `/server-config` | public: what the login page offers besides a password — `{cloud: {enabled, issuer}, password_login, registration, guest, page_host}` (`guest` false on a share host; `page_host` the per-account page hostname pattern from `GAMMA_PAGE_HOST`, e.g. `{username}-pages.gammapdf.com`, "" when unset — how the app knows it was opened on a page host) ([cloud_accounts.md](cloud_accounts.md)) | | POST | `/auth/cloud/exchange` | the share host's half of publishing ([mirror.md](mirror.md) "Publishing"): `Authorization: Bearer `, body `{server?}` (the calling server's name) → `{token, workspace_id, username, url}` — a write-scope integration token (365 days, named "Published pages from ", replacing the live one of that name) on the person's default personal workspace here, the account resolved under the sign-in policy like a first sign-in (provisioned under `provision`, pending invitations claimed), and this server's address. 403 unless the server accepts published pages (`cloud_share_host`), for an unconfirmed cloud e-mail, or when the policy refuses the account; 401 for a token the account server does not know; 503 when it cannot be asked; 429 past 20 per IP or 10 per cloud account in 10 minutes | | GET | `/auth/cloud/start?next=&link=1` | Sign in with Gamma Cloud: stores the pending PKCE sign-in and redirects to the account server; `link=1` needs a session and attaches the cloud identity to that account | | GET | `/auth/cloud/callback?code=&state=` | the account server's return: verifies the ID token, resolves or creates the local account per the policy (`gamma/cloud_auth.py`), pulls the preference profile, registers this server on the person's server list (`gamma/cloud_sync.py`), mints a session and redirects to `next`; a refusal goes back to `/?cloud_error=` | @@ -180,7 +180,7 @@ the tree reflects (the live session catches up from it). ### Pages (`pages.py`) — page first, PDF as an action on it | Method | Path | Purpose | |---|---|---| -| POST | `/pages` | create a text-only root page: body `{title?, folder?, id?, properties?}` (title defaults to `Untitled`, `folder` → `properties.folder`; `id` keeps a page's id when a mirror brings it over — 400 when malformed, 409 when taken; `properties` seeds the page's own) → the block dict | +| POST | `/pages` | create a text-only root page: body `{title?, folder?, id?, properties?}` (title defaults to `Untitled`, `folder` → `properties.folder`; `id` keeps a page's id when a mirror brings it over — 400 when malformed, 409 when taken; `properties` seeds the page's own) → the block dict. On a share host, 402 `{detail, limit, used, plan}` when the owner's plan allows no more pages in their default personal workspace ([mirror.md](mirror.md) "Publishing") | | POST | `/pages/by-docs` | which pages these stored files became: body `{doc_ids: [, ...]}` (≤500) → `{pages: {hash: {id, title}}}` — a hash matches the page carrying it as its PDF (`doc_id`) or the note page imported from it (a markdown upload's `markdown_import`); hashes with no page absent; any member. The file chips ask once per page render for their "open page" button and the menu's "Open page" / "Add to library" | | POST | `/pages/from-file` | "Add to library" on a markdown file chip: body `{filename: ".md", original?, folder?}` → `{page, created, imported?}` — the stored upload becomes a note page through the `/import/markdown` importer (title from front matter, else `original` minus its extension), filed in `folder`; idempotent (a page whose `markdown_import` is the hash is returned with `created: false`); the file is untouched, the page is a copy. 400 for anything but a stored markdown name, 404 when the file is not in the workspace; workspace editors | | POST | `/pages/{page_id}/attachment` | attach a PDF to a page that has none: body `{doc_id?, source_url?, original_filename?}` (at least one of `doc_id`/`source_url`; `doc_id` is shape-validated only — a URL-opened PDF's id is the URL hash and the proxy fetches it lazily, like `by-doc`; `source_url` defaults to `/api/uploads/.pdf`). While the title is still automatic (`Untitled`/empty) it becomes the file name / URL tail and is marked `auto_title`. → the updated block. 400 bad input / not a root page, 404 unknown page, 409 `{"detail": "page already has an attachment"}`, 409 `{"detail": "attachment belongs to another page", "page_id"}` | @@ -365,9 +365,11 @@ Session only, the mirror's owner, never a guest. | method | path | what | |---|---|---| -| POST | `/pages/{id}/publish` | publish the page to the share host Gamma Cloud names: body `{audience?: anyone \| users \| list, role?: view \| edit}` (optional; the share there, default anyone / view, applied to a new or an existing link) → `{url: "/?share=", share: {token, page_id, audience, role, users, created_by}, mirror: {ws, status, page_filter, conflicts_open, pending_local}}`. Adds the page to the workspace's filtered mirror of the share host (made on the first publication through `/auth/cloud/exchange` there), runs one round and makes the share. Workspace editors, session only, never a guest. 409 with a message when it cannot: no linked Gamma Cloud identity with a token ("Sign in with Gamma Cloud to publish."), no share host named, a workspace that is a copy of another server, a mirror owned by someone else, detached or receive-only, or this server is itself a share host; 400 for a block that is not a page; 502 when the share host refused or the page did not reach it; 503 when the account server cannot be read | +| POST | `/pages/{id}/publish` | publish the page to the share host Gamma Cloud names: body `{audience?: anyone \| users \| list, role?: view \| edit}` (optional; the share there, default anyone / view, applied to a new or an existing link) → `{url: "/?share=", public_url, share: {token, page_id, audience, role, users, created_by}, mirror: {ws, status, page_filter, conflicts_open, pending_local}}` — `public_url` the page's pretty address (`https://-pages.gammapdf.com/-`) when the share host reports a `page_host`, else the same as `url`. Adds the page to the workspace's filtered mirror of the share host (made on the first publication through `/auth/cloud/exchange` there), runs one round and makes the share. Workspace editors, session only, never a guest. 409 with a message when it cannot: no linked Gamma Cloud identity with a token ("Sign in with Gamma Cloud to publish."), no share host named, a workspace that is a copy of another server, a mirror owned by someone else, detached or receive-only, or this server is itself a share host, or the plan's cap there (the share host's words — "Free plan: up to 5 published pages. Unpublish one, or upgrade your Gamma Cloud plan." — plus `limit: {used, max, plan}`); 400 for a block that is not a page; 502 when the share host refused or the page did not reach it; 503 when the account server cannot be read | | DELETE | `/pages/{id}/publish` | unpublish: the share there stops, the copy there is deleted, the page leaves the filter; the page here is untouched → `{published: false, mirror}`. 409 when the page is not published; 502 (nothing changed) when the share host cannot be reached | -| GET | `/pages/{id}/publish` | `{published, can_publish, reason?, url?, share?, status?, mirror?, error?}` — `can_publish` / `reason` say whether publishing would be refused and why (the same messages as POST), `status` is the mirror's raw status (the pill's reading is the frontend's), `share` the live share settings there (`url` with them), `error` when the share host could not be read. Any member | +| GET | `/pages/{id}/publish` | `{published, can_publish, reason?, url?, public_url?, share?, status?, mirror?, limit?, error?}` — `can_publish` / `reason` say whether publishing would be refused and why (the same messages as POST), `status` is the mirror's raw status (the pill's reading is the frontend's), `share` the live share settings there (`url` and `public_url` with them), `limit` `{used, max, plan}` read from the share host whenever the account holds a publishing token (`max` null = no cap), `error` when the share host could not be read. Any member | +| GET | `/publish/limit` | the share host's half: `{used, max, plan}` — the root pages of the request's workspace (a publishing mirror's token names it) and the cap its owner's plan puts on them (`max` null = none). 404 on a server that is not a share host. Nothing cached | +| GET | `/pages/resolve-public?host=&path=` | no auth: a page host's pretty address → `{share, page_id}`, the share token the share view opens with (audience and role its own). `host` must match `GAMMA_PAGE_HOST` (the username read out of it), `path` is `/-` or `/`; only the trailing id counts, a root page with a share in that account's default personal workspace. 404 otherwise (counted like an unknown share token); 429 past 120 per IP in 5 minutes | | GET | `/integrations/oauth/request?request_id=` | the pending consent (client name, the account's workspaces) for the consent screen | | POST | `/integrations/oauth/consent` | approve or deny a pending sign-in for one workspace | | GET | `/.well-known/oauth-authorization-server`, `/.well-known/oauth-protected-resource` | OAuth discovery for MCP clients (no `/api` prefix) | diff --git a/docs/dev/cloud_accounts.md b/docs/dev/cloud_accounts.md index 5d52e48f..f17d7a82 100644 --- a/docs/dev/cloud_accounts.md +++ b/docs/dev/cloud_accounts.md @@ -688,6 +688,53 @@ does three things: (`AccountPicker`) take an empty directory as a hidden one and look the typed name up with `?q=`. +Two more settings shape what a share host serves, both environment only +([mirror.md](mirror.md) "Publishing" has the mechanics): + +- **The plan's cap.** How many pages each plan may publish is + `config.PLAN_PAGE_LIMITS` in `gamma/config.py`: + + | plan | published pages | + |---|---| + | `free` | 5 (`GAMMA_FREE_PAGE_LIMIT` overrides it; 0 lifts the cap) | + | `plus`, `pro`, anything else | unlimited | + + The plan is the `plan` claim the share host stored for the identity at + its last exchange or sign-in; a publish that finds the workspace full + exchanges once more, so an upgrade counts at once. +- **Page hosts.** `GAMMA_PAGE_HOST` is the hostname pattern of the + per-account page hosts, with one `{username}` placeholder: + `{username}-pages.gammapdf.com` gives every published page the address + `https://-pages.gammapdf.com/-`. Empty (the + default) means token links only. The server refuses to start on a pattern + without exactly one `{username}` or that is not a hostname otherwise. + Cloud usernames are single DNS labels (`[a-z0-9-]`, 3 to 32 characters), + and the `-pages` suffix keeps every page host apart from a service + hostname, so `accounts.RESERVED_USERNAMES` need not change; name no + service with the suffix. Deploying it takes a wildcard DNS record, + `*.gammapdf.com` pointing at the share host (named records such as + `account` keep precedence over the wildcard), and a wildcard site in + front of the container. With Caddy, whose wildcard certificate needs the + DNS-01 challenge (a build with the DNS provider's module): + + ``` + *.gammapdf.com { + tls { + dns cloudflare {env.CLOUDFLARE_API_TOKEN} + } + @pages header_regexp Host ^[a-z0-9-]+-pages\.gammapdf\.com$ + handle @pages { + reverse_proxy gamma-share:8000 + } + handle { + respond 404 + } + } + ``` + + The proxy passes the `Host` header through (Caddy does by default); the + app reads the username out of it. + The rest of the plan's cloud mode is configuration, not code: registration is already off on every Gamma (accounts come from the admin or the cloud), the default quota is the storage setting, and per-IP limits belong to the diff --git a/docs/dev/i18n.md b/docs/dev/i18n.md new file mode 100644 index 00000000..7818cd80 --- /dev/null +++ b/docs/dev/i18n.md @@ -0,0 +1,176 @@ +# Interface language + +Gamma's interface text (menus, settings, buttons, hints, messages) can be +shown in a language other than English. Notes, PDFs, the AI's replies and +the PDF translated view are not part of this: they have their own settings +(Reading → Translation, the chat's language) and their own machinery. + +Only Chinese exists so far. The pieces are small on purpose: a lookup +function, one JSON file per language, and a tool that keeps the files +complete. + +## How a string is translated + +The English sentence in the source is the key. A call site writes the +English text inside `t()` and nothing else changes: + +```jsx +import { t, tn, T } from "../shared/i18n/i18n.js"; + + +

{t("Open {name}", { name: page.title })}

+{tn("{n} page", "{n} pages", count)} +``` + +`t(text, args)` returns the catalog's translation of `text`, or `text` +itself when the catalog has none, with `{name}` placeholders filled from +`args`. A React element among the arguments is spliced in as an element, +so a sentence around a `` or a link needs no special component. +`tn(one, other, n)` is a count: the singular is the key; English picks a +form by `n`, a catalog entry is one string (Chinese has no plural) or an +object keyed by the Intl plural category for a language that inflects. + +Strings in a module-level table (a menu catalog, the settings sidebar, a +tour) are marked `T("…")` where they are declared and passed through +`t()` where they are rendered: `T` is the identity, there only so the tool +finds the string; the table stays English and readable, and the lookup +happens at render time under the active locale. + +The first argument is always one string literal. A variable or a template +string cannot be extracted and stays untranslated. The exception is a +finished sentence that arrives from elsewhere: the toast for a server +error may call `t(error.message)`, which translates when the catalog +knows the sentence and shows it as sent otherwise. + +Where a single English word would need two different translations, +rephrase the English rather than adding a context mechanism: the key is +the whole phrase, and phrases rarely collide. + +## Where the pieces live + +- `frontend/src/shared/i18n/i18n.js` — `t`, `tn`, `T`, the active locale + store, `fmtDate` / `fmtNumber` (Intl formatters bound to the interface + language rather than the browser's). +- `frontend/src/shared/i18n/locales.js` — the language list (`LANGUAGES`, + the Settings row's options), the locale codes, the BCP 47 tag each one + puts on ``, and `resolveLocale` (a preference value → a + locale). Pure, so `prefDefs.js` and node tests import it. +- `frontend/src/shared/i18n/locales/.json` — one catalog per + language, keys sorted, English sentence → translation. +- `frontend/tools/i18n.mjs` — the scan and the catalog check + (`npm run i18n`, `npm run i18n -- --sync`). +- `frontend/tests/i18n.test.mjs` — the same check as a test. + +The preference is `language` in `PREFS` (`gamma-language`, account scope, +so it syncs through the profile like the theme): `"system"` follows the +browser's languages, else a locale code. Settings → Appearance → Language +is the row. + +`main.jsx` reads the stored value before the first render, loads that +catalog (a lazy chunk; English needs none) and renders the app under +`key={locale}`. A change of language, from the Settings row or from the +profile sync, loads the new catalog and remounts the app, so every string +is re-read without any component subscribing; the Settings dialog closes +on the way, which is acceptable for a change made a few times per account. +`document.documentElement.lang` follows the locale, which matters for CJK +glyph selection. + +## Keeping the catalogs complete + +`node tools/i18n.mjs` scans `src/` for `t("…")`, `tn("…", "…", n)` and +`T("…")` calls (comment lines excluded) and compares each catalog with +the result. `--sync` rewrites each catalog with the missing keys added as +`""` and the orphans (keys no source uses) dropped, keys sorted. The test +fails while any catalog has a missing, empty or orphan key, or a +translation whose `{placeholders}` do not match the key's, so a new +string is translated in the same change that adds it. The workflow for a +new control: + +1. Write its text inside `t()`. +2. `npm run i18n -- --sync` adds the key to every catalog. +3. Fill the empty value in `zh.json`. +4. `node --test tests/i18n.test.mjs` passes. + +An English change is a key change: the old translation becomes an orphan +and the new sentence a missing key, so a stale translation never survives +an edit silently. + +## Accuracy + +What keeps a translation right, in the order that matters: + +- **The translator sees the real sentence.** With the English text as the + key there is no identifier to guess from, and the placeholders are in + it. The tool's `missing:` lines name the source file, so the context is + one click away, and hover hints (`title=`) travel next to their labels + in the same file. +- **Nothing goes stale.** See above: an edited sentence fails the test + until it is re-translated. +- **The glossary below fixes the terms.** Every translation pass, by hand + or with an AI, follows it, so "workspace" is the same word on every + screen. Add a term when a new concept appears. +- **Look at it.** `npm run e2e -- --only i18n` switches the interface to + Chinese in a real browser and checks the settings dialog; after + translating a new area, open it in the app with the language set and + read the screen. Chinese is shorter than English, so layout is rarely + the problem; a wrong register or a verb read as a noun is, and only + reading catches it. + +There is no automatic check that a value is "good Chinese"; the checks +above make sure every string is translated, current and complete, and the +glossary plus a read of the screen make it correct. + +### Glossary (Chinese) + +| English | 中文 | Note | +|---|---|---| +| workspace | 工作区 | | +| library | 文库 | the home page's collection | +| page | 页面 | a note page (a root block) | +| PDF page | 页 | a page of the document; `p. 3` stays `第 3 页` | +| block | 块 | | +| note, notes | 笔记 | | +| highlight | 高亮 | the annotation; the mark on the PDF | +| label | 标签 | the folder-like labels | +| folder | 文件夹 | | +| share | 分享 | | +| clone, mirror | 副本 | an offline copy of a workspace | +| backup | 备份 | | +| connection (AI) | 连接 | a provider entry | +| prompt | 提示词 | | +| integration | 集成 | | +| tour | 导览 | | +| settings | 设置 | | +| account | 账户 | | +| sign in / log out | 登录 / 退出登录 | | +| chat | 聊天 | | +| theme | 主题 | | +| System (follows the device) | 系统 | | + +Punctuation is Chinese full-width (,。:“”) inside a Chinese sentence; +product names (Gamma, PDF, LaTeX, Markdown, GitHub) stay as they are, with +a space between a Latin word and Chinese characters. + +## Adding a language + +Add `["", ""]` to `LANGUAGES` and the tag to `TAGS` in +`locales.js`, create `locales/.json` with `{}`, run +`npm run i18n -- --sync`, translate. Traditional Chinese would be a second +catalog seeded from `zh.json` through an OpenCC conversion, then reviewed. + +## Scope and what is left + +Done: the module, the preference, the Settings row, the tool and tests, +the settings dialog's frame (sidebar, search, title) and the Appearance +pane, the account menu. Everything else still reads English in Chinese +mode and is wrapped area by area, in the order a Chinese reader meets it: +login, topbar and home library, block tree menus, the remaining settings +panes, the share popover, chat, the PDF viewer. + +Not in scope: the server-rendered pages (the MCP consent screen), the +desktop shell and the browser extension (each would take its own small +catalog), the README and user guide, the welcome page's content, the +`index.html` splash. Backend error strings reach the toast in English +until the toast site routes them through `t()` and the catalog carries the +common ones; a notice with a value in it (the release notice) would need +to send a key and arguments rather than a finished sentence. diff --git a/docs/dev/mirror.md b/docs/dev/mirror.md index 3c11821c..c094dbd2 100644 --- a/docs/dev/mirror.md +++ b/docs/dev/mirror.md @@ -344,13 +344,19 @@ mirror's own answer apart. `GET /api/pages/{id}/publish` when the popover opens, then every 5 s while a round runs or a local edit waits (`pending_local`), else every 20 s. - Not published, allowed: "Keep this page reachable while this computer - is off." and a primary **Publish**. While it runs the button is - disabled and shows the spinning refresh glyph; a refusal shows its - `detail` under the row. + is off." and a primary **Publish**; where the plan caps publishing + (the answer's `limit` has a `max`) the hint counts instead, "3 of 5 + pages published". While it runs the button is disabled and shows the + spinning refresh glyph; a refusal shows its `detail` under the row, and + the cap's refusal (a 409 carrying `limit`) adds an *Open account* button + to the issuer's portal, the Settings Account row's target. - Not published, refused: the `reason` as the row's hint. When the reason is the sign-in one, *Link Gamma Cloud account* opens Settings → Account, where the existing link flow runs. - - Published: the cloud link as the row hint with *Copy link*, a danger + - Published: the cloud link as the row hint with *Copy link* — the + answer's `public_url`, the page's pretty address when the share host + has page hosts, with the token link in the row's hover title as the + fallback that also works — a danger icon button that asks inline before it unpublishes, the state line (`mirrorState` of the answer's `mirror`, the pill's icon and words) with a *Sync now* icon button (`POST /api/mirrors/{ws}/sync?wait=1`), and @@ -472,8 +478,9 @@ workspace there: 4. `POST /api/share/{id}` on the share host under the mirror's token makes the share (default anyone / view; the request's `audience` / `role` set it, on a new link or an existing one through `PUT /api/share-settings`). - The answer is the link `/?share=`, the share and the - mirror's status. + The answer is the link `/?share=` (`url`), the + page's public address (`public_url`, below), the share and the mirror's + status. From then on the page is an ordinary mirrored page: edits here go there at the next round, edits made through an edit share come back, conflicts are @@ -486,6 +493,62 @@ nothing changes (502). An empty filter leaves the mirror row in place. share there and the mirror's raw status, plus `can_publish` / `reason` for the popover. +**The plan's cap.** The share host limits how many pages a Gamma Cloud +plan may publish: `config.PLAN_PAGE_LIMITS` (`{"free": 5}`; the env var +`GAMMA_FREE_PAGE_LIMIT` overrides the free plan's number, 0 lifts it; +other plans are unlimited). A person's workspace there holds only +published pages, so the count is its root pages. The one place a +publishing mirror makes a page there, `POST /api/pages`, answers 402 with +"Free plan: up to 5 published pages. Unpublish one, or upgrade your Gamma +Cloud plan." and `{limit, used, plan}` for an account's default personal +workspace once it holds that many (`publish.cap_refusal`), after the "id +taken" check, so a round re-creating a page that is already there, and +every round of a page already published, is never refused. The plan is the +identity's last `plan` claim, which the share host stores at every exchange +and sign-in. It applies only while the server is a share host; a +self-hosted server never counts. On the publishing side, `publish` reads +`GET /api/publish/limit` on the share host before a page's first round +there; a full workspace is exchanged once more first, so an upgrade counts +at once. Still full, the page leaves the filter again and the answer is 409 +with the share host's words and `limit: {used, max, plan}`; a 402 the round +itself met (the workspace filled up meanwhile) ends the same way. +Unpublishing deletes the copy there, which frees a slot. `GET +/api/pages/{id}/publish` carries the same `limit` whenever the account +holds a publishing token, read fresh on every call. + +**Public addresses.** With `GAMMA_PAGE_HOST` set on the share host (a +pattern such as `{username}-pages.gammapdf.com`, checked at startup: one +`{username}`, a hostname otherwise), every published page also has a pretty +address on a hostname per account, +`https://-pages.gammapdf.com/-`. The suffix keeps +page hosts apart from service hostnames (services are never named with it). +The slug (`publish.slug`, mirrored in `frontend/src/shared/lib/slug.js`, +pinned by `tests/shared/slug.json`) is the title ASCII-folded (NFKD, marks +dropped), lowercased, runs of anything but `[a-z0-9]` turned into one `-`, +trimmed, at most 60 characters; a title with nothing left (a CJK one) gives +none and the path is just `/`. It is decoration: routing uses only the +trailing id, so a renamed page keeps its links. The publishing server +builds the address (`public_url` in the publish answers) from the share +host's `page_host` in its `/api/server-config`, the account's username +there (the mirror's `remote_user`), the page's title and the share host's +scheme and port; without a pattern it is the token link. + +A page host serves the same SPA (asset URLs are root-relative, so any host +loads them). At boot (`PageHostGate` in `App.jsx`) the app reads +`/api/server-config`; when `page_host` is set and the hostname matches it, +it calls `GET /api/pages/resolve-public?host=&path=` and enters the share +view with the token it returns, as if `?share=` were in the URL +(`utils.setShareView`); the address bar keeps the pretty address, its slug +brought in line with the current title. The resolver reads the username out +of the host, takes the page with the trailing id (a page id may hold a `-`, +so every tail after a `-` is tried, the longest shared page winning) from +that account's default personal workspace, and answers its share; the +share's audience and role apply as for the token link. Any other path on a +page host, the home included, is the share view's "not found". Cookies are +per host, so on a page host nobody is signed in: a page shared only with +signed-in users or invited people shows the sign-in gate there, and its +token link is the way in. + Limitation: a workspace that is already a copy of another server (a clone of the lab's NAS) cannot publish: one remote per copy, and the page's home is that other server. Publish from there (its admin can turn it into a share diff --git a/docs/dev/settings.md b/docs/dev/settings.md index 7ebc660a..8d11921d 100644 --- a/docs/dev/settings.md +++ b/docs/dev/settings.md @@ -7,7 +7,7 @@ Where every setting lives, and how the Settings dialog is built. | Layer | Storage | Examples | |---|---|---| | Per browser | `localStorage`, one `gamma-*` key per preference, all declared in `PREFS` ([frontend/src/app/prefDefs.js](../../frontend/src/app/prefDefs.js)) with scope `browser` — except `gamma-link-name`, the share view's display name for a visitor without an account, owned by `src/collaboration/linkName.js` because the fetch wrapper reads it outside React | what describes this device: the interface size (`gamma-ui-scale`, applied pre-paint by `index.html`), the status bar, the handwriting input rules and the tool strip's presets, eraser and lasso choices (`gamma-ink-*`), the metadata and translation model picks and dictation (they name this server's provider entries, like the chat model `gamma-chat-model`); outside `PREFS`, diagnostics tracing (`gamma-debug-log`) | -| Per account, profile | the account-wide `profile` prefs key (`/api/prefs/profile`, one JSON object keyed by preference name), every `PREFS` entry with scope `account`; each also keeps its `gamma-*` localStorage key as the instant-paint cache | appearance (theme — the pre-paint script still reads `gamma-theme` — and flip page colors), reading and editing (imported annotations, translation button and language, Enter key, how search opens), library display and PDF fetching, chat behaviour (tools switch, per-kind tool permissions, reasoning effort, the login connection check, tool limits, snapshot clearing), translation effort and parallel requests, context budgets, prompts | +| Per account, profile | the account-wide `profile` prefs key (`/api/prefs/profile`, one JSON object keyed by preference name), every `PREFS` entry with scope `account`; each also keeps its `gamma-*` localStorage key as the instant-paint cache | appearance (theme — the pre-paint script still reads `gamma-theme` — and flip page colors), the interface language (`gamma-language`, read by `main.jsx` before the first render, [i18n.md](i18n.md)), reading and editing (imported annotations, translation button and language, Enter key, how search opens), library display and PDF fetching, chat behaviour (tools switch, per-kind tool permissions, reasoning effort, the login connection check, tool limits, snapshot clearing), translation effort and parallel requests, context budgets, prompts | | Session only | React state, nothing stored | the Ctrl+scroll text size of the notes list and the chat transcript (`useTextScale` in [Widgets.jsx](../../frontend/src/shared/ui/Widgets.jsx)) — resets on reload | | Per account, synced | `/api/prefs/{key}` (small JSON KV, `user_prefs` in `users.db`) | per account AND workspace: open tabs (`open-tabs`), the recently-viewed queue (`recent-views`), pinned folders (`pinned-folders`; pinned pages are a page property), reading positions (`read-pos`) — they name one workspace's pages; account-wide: active AI key (`ai-provider`) and the preference profile (`profile`, previous row). Server wins on load, localStorage (keyed `user@workspace`) is the instant-paint cache. The recents-card cover thumbnails are workspace data, through their own `/api/page-snaps` store (`page_snaps` in the workspace's `data.db` — over the prefs size cap) | | Per account, seen notices | the account-wide `notices-seen` prefs key (`db.NOTICES_SEEN_PREF_KEY`), `{notice id: fingerprint}`, written only by `POST /api/notices/{id}/seen` (below, "Notices") | which release and which log error the account has already looked at | @@ -169,7 +169,8 @@ short hint what it does, and the hover `title` the rest. Preferences: -- **Appearance**: the eight theme cards (`PictureChoices`), the dark-page +- **Appearance**: the eight theme cards (`PictureChoices`), the interface + language (a `MenuSelect`: System / English / 中文, [i18n.md](i18n.md)), the dark-page switch with its live PDF sample, interface size and the status bar. [SettingsAppearance.jsx](../../frontend/src/settings/SettingsAppearance.jsx). - **Reading & editing**: imported annotations (a Keep / Remove segmented @@ -281,6 +282,9 @@ Under Provision a fourth row, the Toggle *Accept published pages* (`cloud_share_host`), makes this server the share host people publish pages to; it also turns the guest account off and limits the account list to exact names ([cloud_accounts.md](cloud_accounts.md) "The share host"). +A share host's page cap per plan (`GAMMA_FREE_PAGE_LIMIT`) and its +per-account page hosts (`GAMMA_PAGE_HOST`) are environment only, with no +row here. Editing shows one Save button as the section's action. Values are stored in the server `settings` table (`cloud_*`), read-only when `GAMMA_CLOUD_ISSUER` manages them. The login page reads `GET /api/server-config` and shows "Sign diff --git a/frontend/package.json b/frontend/package.json index 64c9406c..d7203d0b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,8 @@ "preview": "vite preview --host 127.0.0.1 --port 4173", "test": "node --test tests/*.test.mjs", "e2e": "node tests/e2e/run.mjs", - "e2e:latex": "node tests/e2e/latexEditor.mjs" + "e2e:latex": "node tests/e2e/latexEditor.mjs", + "i18n": "node tools/i18n.mjs" }, "dependencies": { "@codemirror/commands": "^6.11.0", diff --git a/frontend/src/README.md b/frontend/src/README.md index 72afe769..f69db60a 100644 --- a/frontend/src/README.md +++ b/frontend/src/README.md @@ -21,7 +21,8 @@ through barrel files. `main.jsx` remains the Vite entry point. | `support/` | Report a problem: the dialog (`ReportProblem.jsx`) and the pure report builder it and the tests share (`problemReport.js`) | | `transfers/` | Import/export dialogs (`ImportExport.jsx`), the import review (`ImportReviewDialog.jsx`, `ImportTree.jsx`, `importApi.js`, `importReview.js`), format rules, and upload/file chips (`FileChip.jsx`) | | `shared/model/` | Block tree helpers (`blockModel.js`), block operations (`blockOps.js`), and highlight colors | -| `shared/lib/` | API transport and helpers (`utils.js`), the multipart upload (`xhrUpload.js`), search text normalization, and canvas sizing | +| `shared/i18n/` | Interface language: `i18n.js` (`t`, `tn`, `T`, the locale store, Intl formatters), `locales.js` (the language list, pure), `locales/.json` (one catalog per language) — [docs/dev/i18n.md](../../docs/dev/i18n.md); `tools/i18n.mjs` keeps the catalogs complete | +| `shared/lib/` | API transport and helpers (`utils.js`), the multipart upload (`xhrUpload.js`), search text normalization, canvas sizing, and published pages' slugs and page hosts (`slug.js`) | | `shared/ui/` | Reused widgets, menus, icons, and menu hover intent | | `shared/illustrations/` | Decorative settings/import previews and their local image assets | | `shared/styles/` | `app.css`: theme, base controls, and cross-application styles | diff --git a/frontend/src/app/App.jsx b/frontend/src/app/App.jsx index cec4f30d..3c9bd4ef 100644 --- a/frontend/src/app/App.jsx +++ b/frontend/src/app/App.jsx @@ -3,10 +3,12 @@ import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; import PdfViewer, { clampZoom } from "../pdf/PdfViewer"; import { highlightSpot, rangeSpot } from "../pdf/pdfSelectionSpot"; import { COLORS } from "../shared/model/highlightColors.js"; +import { applyLanguage, t } from "../shared/i18n/i18n.js"; import { ExportDialog, ImportDialog } from "../transfers/ImportExport"; import ImportReviewDialog from "../transfers/ImportReviewDialog"; import { parseGammaLink } from "../shared/model/gammaLinks.js"; -import { API, apiJson, withShare, withWorkspace, setCurrentWorkspace, getCurrentWorkspace, setLinkName, makeId, fmtBytes, getDocIdForUrl, isPdfFile, isMarkdownFile, metaSourceInfo, resolvePdfUrl, pdfProxyUrl, probePdfUrl, setExpectedUser, getExpectedUser, usePersistedState, usePersistedFlag, copyText, copyRich, readNdjson } from "../shared/lib/utils"; +import { pageHostUser, publicPath } from "../shared/lib/slug.js"; +import { API, apiJson, setShareView, withShare, withWorkspace, setCurrentWorkspace, getCurrentWorkspace, setLinkName, makeId, fmtBytes, getDocIdForUrl, isPdfFile, isMarkdownFile, metaSourceInfo, resolvePdfUrl, pdfProxyUrl, probePdfUrl, setExpectedUser, getExpectedUser, usePersistedState, usePersistedFlag, copyText, copyRich, readNdjson } from "../shared/lib/utils"; import { BlockDropIndicator, ChatMarkdown, @@ -332,24 +334,61 @@ export default function App() { // Authorization must never mount library effects (saved-page restore, // autosave, navigation hotkeys). They can otherwise replace its URL. const requestId = new URLSearchParams(window.location.search).get("gamma_oauth"); - return requestId ? : ; + return requestId ? : ; } -function LibraryApp() { - const params = new URLSearchParams(window.location.search); +// A page host (the share host's hostname per account, server-config's +// `page_host`; docs/dev/mirror.md "Publishing") serves published pages +// only: its path, /-, names the page, which opens in the share +// view as if its ?share= token were in the URL while the pretty address +// stays in the address bar. Any other path there is the share view's "not +// found". Everywhere else the app boots as before, with the server config it +// read here (a ?share= link knows it is a share view without asking). +function PageHostGate() { + const [boot, setBoot] = useState(() => (new URLSearchParams(window.location.search).get("share") ? {} : null)); + useEffect(() => { + if (boot) return undefined; + let active = true; + (async () => { + let config = null; + try { config = await apiJson(`${API}/server-config`); } catch {} + if (!active) return; + if (!pageHostUser(config?.page_host, window.location.hostname)) { setBoot({ serverConfig: config }); return; } + let found = null; + try { + const q = new URLSearchParams({ host: window.location.host, path: window.location.pathname }); + const r = await fetch(`${API}/pages/resolve-public?${q}`); + if (r.ok) found = await r.json(); + } catch {} + if (!active) return; + setShareView(found?.share || ""); + setBoot({ publicPage: found?.share ? found : { missing: true } }); + })(); + return () => { active = false; }; + }, [boot]); + if (!boot) return
Loading Gamma…
; + return ; +} + +// `publicPage`: opened on a page host — {share, page_id} (resolved), or +// {missing: true}. `initialServerConfig`: GET /api/server-config, already read. +function LibraryApp({ publicPage = null, initialServerConfig = null }) { + const params = new URLSearchParams(publicPage ? "" : window.location.search); const initialUrl = params.get("src") || params.get("url") || ""; - const initialShare = params.get("share") || ""; + const initialShare = publicPage ? (publicPage.share || "") : (params.get("share") || ""); const initialBlockId = params.get("block") || params.get("page") || ""; const initialCategory = params.get("unlabelled") ? NO_LABEL : (params.get("category") || ""); const initialFolder = params.get("folder") || ""; - // shareMode: this tab shows a page through a ?share= link — no account of - // its own, no library, no chat, no prefs sync. readOnly: the block tree - // can't be edited; every share view starts read-only and stays so unless - // the link resolves with edit rights (Share → "They can: Edit notes"). - const shareMode = Boolean(initialShare); + // shareMode: this tab shows a page through a ?share= link (or a page + // host's pretty address) — no account of its own, no library, no chat, no + // prefs sync. readOnly: the block tree can't be edited; every share view + // starts read-only and stays so unless the link resolves with edit rights + // (Share → "They can: Edit notes"). + const shareMode = Boolean(initialShare) || Boolean(publicPage); const [readOnly, setReadOnly] = useState(shareMode); const [shareInfo, setShareInfo] = useState(null); // resolved share: {owner, role, canEdit, audience, viewer} - const [shareGate, setShareGate] = useState(null); // "login" | "forbidden" | "missing" while the share can't open + // "login" | "forbidden" | "missing" while the share can't open + const [shareGate, setShareGate] = useState(publicPage?.missing ? "missing" : null); const [linkName, setLinkNameState] = useState(""); // the share view's display name when the viewer has no account const [renamingLink, setRenamingLink] = useState(false); @@ -466,9 +505,9 @@ function LibraryApp() { } // What the login page offers besides a password: read once, unauthenticated. - const [serverConfig, setServerConfig] = useState(null); + const [serverConfig, setServerConfig] = useState(initialServerConfig); useEffect(() => { - if (shareMode) return; + if (shareMode || initialServerConfig) return; let active = true; apiJson(`${API}/server-config`).then((c) => { if (active) setServerConfig(c); }).catch(() => {}); return () => { active = false; }; @@ -2267,7 +2306,7 @@ function LibraryApp() { && !!authUser?.user && !authUser?.is_guest; const [publishState, setPublishState] = useState(null); const [publishBusy, setPublishBusy] = useState(""); - const [publishError, setPublishError] = useState(""); + const [publishError, setPublishError] = useState(""); // a refusal's detail, or {message, limit} for the plan's cap const [publishCopied, flashPublishCopied, resetPublishCopied] = useCopied(); // The publication's state while the share popover is open: every 5 s while // a round runs or a local edit waits to be synced, else every 20 s. @@ -2401,6 +2440,7 @@ function LibraryApp() { const profileSync = useProfileSync(appPrefs, authUser?.user && !authUser.is_guest && !shareMode ? authUser.user : ""); const { theme, setTheme, pdfDarkPage, setPdfDarkPage, uiScale, setUiScale, recentThumbs, setRecentThumbs, + language, setLanguage, fileLabels, setFileLabels, oaFallback, setOaFallback, metaAutoFetch, setMetaAutoFetch, pdfSaveLocal, setPdfSaveLocal, embAnnots, setEmbAnnots, @@ -3926,6 +3966,7 @@ function LibraryApp() { if (!wsReady || bootedRef.current) return; bootedRef.current = true; if (initialShare) resolveShare(initialShare); + else if (shareMode) return; // a page host's address that names no shared page: "not found" is up else if (initialBlockId) { (async () => { try { @@ -3991,6 +4032,10 @@ function LibraryApp() { return () => mq.removeEventListener("change", apply); }, [theme]); + // Interface language: loads the catalog, then main.jsx remounts the app + // under the new locale (shared/i18n/i18n.js). + useEffect(() => { applyLanguage(language); }, [language]); + // Record a "recently viewed" entry whenever a page is opened. sessionUser // in the deps re-fires it once login resolves — a page opened by direct URL // shows before the session check finishes, and the first run skips. @@ -4679,6 +4724,10 @@ function LibraryApp() { // API call in a share view (utils.withShare) — never a bare ?user=. let block = null; try { block = await apiJson(`${API}/blocks/${encodeURIComponent(data.page_id)}`); } catch {} + if (publicPage && block) { + // the address bar keeps the page host's pretty address, its slug following the title + window.history.replaceState(window.history.state, "", publicPath(block.content, data.page_id) + window.location.hash); + } let childBlocks = []; if (block) { @@ -5256,11 +5305,12 @@ function LibraryApp() { }); setPublishState((prev) => ({ ...(prev || {}), page: pageId, published: true, can_publish: true, reason: undefined, error: undefined, - url: out.url, share: out.share, mirror: out.mirror, status: out.mirror?.status, + url: out.url, public_url: out.public_url, share: out.share, mirror: out.mirror, status: out.mirror?.status, })); markPublishing(); } catch (err) { - setPublishError(err.message); + // the plan's cap carries its count: the section offers the account page with it + setPublishError(err.data?.limit ? { message: err.message, limit: err.data.limit } : err.message); if (patch) loadPublishState({ quiet: true }); // the tiles go back to what the share host holds } finally { setPublishBusy(""); @@ -5299,7 +5349,8 @@ function LibraryApp() { window.dispatchEvent(new CustomEvent("gamma:mirror")); } async function copyPublishLink() { - if (publishState?.url && await copyText(publishState.url)) { flashPublishCopied(); return; } + const link = publishState?.public_url || publishState?.url; + if (link && await copyText(link)) { flashPublishCopied(); return; } setStatus("Copy failed — select the link in the popover instead."); } @@ -8129,6 +8180,7 @@ function LibraryApp() { onUnpublish: unpublishPage, onSync: syncPublication, onLink: () => { setOpenPopover(null); setSettingsOpen("account"); }, + accountUrl: serverConfig?.cloud?.issuer ? `${serverConfig.cloud.issuer}/` : "", } : null} citation={(pageMeta || pageBibtex) ? (
{notices.tone ?