The #285 gate dropped every remapped (wrong-layout) candidate shorter than 3
chars, which broke the legitimate short prefix '/сщ' -> 'co' -> Code while '/co'
still worked. Replace the blanket length filter with a match-TYPE gate: the
original query and remaps >= 3 chars match fully (title/description/searchTerms);
a short (1-2 char) remap is restricted to a TITLE fuzzy-match. So '/сщ' -> 'co'
matches the 'Code' title again, while '/cy' -> 'сн' and '/b' -> 'и' still do not
surface Footnote (they only ever leaked in via the 'сноска'/'примечание'
searchTerm substrings, not the title).
Adds positive tests for /сщ and /co; keeps the /cy and /b negatives.
closes#283
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
F4: menu-items.layout.test.ts imports from './menu-items' (relative, no extension),
matching the sibling test files (was still the aliased '@/.../menu-items.ts').
F5: remove the dead 'candidate !== originalCandidate' clause from the remapped-candidate
filter — buildLayoutCandidates dedupes remaps against the original via Set, so the tail
after destructuring can never equal the original; the length gate is the only real
condition. Comment updated to state the dedup invariant instead.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This actually lands F1+F2 (round 1 pushed only the test rename by mistake).
F1: only the ORIGINAL query matches without length limits; remapped (wrong-layout)
candidates must be >= 3 chars before they can match, via a shared candidateMatchesItem
helper applied to both the item filter and the tie-break sort. Stops a 1-2 char ASCII
query from spuriously substring-matching Cyrillic searchTerms (/cy->сн no longer hits
'сноска', /b->и no longer hits 'примечание'), while keeping real wrong-layout commands
(/сщву->Code, /cyjcrf->Footnote), genuine short queries (/p, /h1) and Cyrillic terms
(/сноска->Footnote) working.
F2: reword the buildLayoutCandidates JSDoc (an ASCII query yields multiple candidates;
dedup only collapses when nothing is remappable).
Adds negative tests for /cy and /b.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
F1: only the ORIGINAL query does full matching; remapped (wrong-layout) candidates
must be >= 3 chars and differ from the original before they can match (via a shared
candidateMatchesItem helper, applied to both the filter and the tie-break sort). This
stops a short remapped candidate from substring-matching the only cyrillic searchTerms
(/cy->сн, /b->и no longer surface Footnote) while keeping real wrong-layout commands
(/сщву->Code, /cyjcrf->Footnote) and genuine cyrillic terms (/сноска->Footnote) working.
F2: fix the buildLayoutCandidates JSDoc (an ascii query yields multiple candidates,
not a single-element set).
F3: rename the test to menu-items.layout.test.ts + relative import, per sibling convention.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Typing a command with the wrong layout (e.g. Russian ЙЦУКЕН -> /сщву for 'code')
matched nothing and collapsed the popup. Add ЙЦУКЕН<->QWERTY layout maps and a
buildLayoutCandidates(query) = [original, RU->EN, EN->RU]; getSuggestionItems now
matches an item if ANY candidate hits (fuzzy title / description / searchTerms),
and the tie-break sort is candidate-aware. Keeping the original among candidates
preserves genuine Cyrillic search terms (сноска -> Footnote). One-function change;
slash-command.ts allow() reuses it, so the popup-collapse is fixed transitively.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a new value "inline" to the image align attribute (alongside
left/center/right/floatLeft/floatRight). Inline images render as
inline-block containers, so consecutive ones form a row that wraps
naturally on narrow viewports; unlike the float modes, text does not
wrap around them.
- applyAlignment: reset-then-apply extended to display/vertical-align;
the reset restores the constructor's inline display:flex so non-inline
modes keep byte-identical styles and editor-ext stays independent of
the client CSS class
- image bubble menu: new "Inline (side by side)" button (IconLayoutColumns)
with active state, mirroring the float buttons
- i18n: key registered in en-US and ru-RU ("В ряд"), like the float labels
- tests: 3 new applyAlignment specs (apply, reset on switch-away, float->inline)
- no schema/MCP/markdown changes needed: align round-trips as data-align
Icons are rendered only as <item.icon style={...} stroke={2} />, so type the
field as ComponentType<{ style?; stroke? }> instead of typeof IconBold. stroke is
string|number to match Tabler's own prop type, so Tabler icons and the local
IconStress both satisfy it without the 'as unknown as' cast.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Unit-test the focus-restore guard: an external <input> active -> editor.view.focus
NOT called (deliberate move respected); a non-focusable element active -> focus
called once. Fake editor + fake timers (rAF via setTimeout stub); view.focus is a
spy. Regression lock for the guard that keeps focus out of the page-title input.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
F1: add code-block-view.test.tsx (mirrors the footnote structure harness) asserting
the language selector renders only when editor.isEditable, and the copy button is
present in both modes.
F2: remove the now-dead justify=flex-end on the absolutely-positioned menu Group.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Select a vowel and one click places a combining acute accent over it; clicking
again removes it (toggle). Inserts the literal Unicode char U+0301 right after
the letter — plain text, not a custom TipTap mark — so it survives HTML/Markdown
export, full-text search and public share with zero server/converter changes.
Insert/remove is a single transaction (one Ctrl+Z), inherits the letter's marks
(bold/italic/color), and restores the original selection so the active state
toggles correctly. Editable bubble menu only. New pure helper stress-accent.ts
(+ 5 unit tests). i18n: en 'Stress' / ru 'Ударение'.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The row/column grip and cell-chevron menus are Mantine <Menu>s with
returnFocus:true whose targets live outside the editor's contenteditable. After
a menu action focus returns to that outside target, so ProseMirror's undo keymap
never sees Ctrl+Z until the user clicks back into a cell. Add
refocusEditorAfterMenuClose(editor): on the next frame (after Mantine's
returnFocus) restore editor focus via view.focus(), unless the user intentionally
moved to another input/editable. Wired into both onClose paths (the shared
row/column lifecycle hook + cell-chevron).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The code-block control panel (language selector + copy) took a full row above
the code. Move both to an absolute overlay in the top-right corner and hide the
language selector until the block is hovered/focused; the copy button stays
always visible. In read-only the language selector isn't rendered at all. The
<pre> (editable contentDOM) stays FIRST in the DOM so click hit-testing (#146)
is not regressed; the panel leaves the flow via position:absolute.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds CommentHoverPreview, mounted in page-editor next to <EditorContent>:
hovering a `.comment-mark[data-comment-id]` span shows a small floating card
(createPortal, position:fixed, pointer-events:none so it never intercepts the
mark's click) with the parent comment's plain text. Uses useCommentsQuery
(shares the ["comments", pageId] cache with the side panel — no extra
request). Skips unknown/not-yet-loaded, resolved (data-resolved attr or
resolvedAt/resolvedById), and empty-text comments. A ~120ms open delay avoids
flicker; hides on mouseout / mousedown / scroll(capture) / resize / page
change. commentContentToText flattens the comment's ProseMirror doc
(stringified or parsed) to plain text, preserving hardBreaks as newlines and
never throwing. Main editor only (read-only / shares / history out of scope).
closes#268
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review round 1 on the scroll-position feature:
- F1: add two tests for the hook's subtlest invariants — (a2) the restore
target is captured synchronously at mount and survives a fresh scroll@0
overwriting storage on load (a regression moving the capture into an effect
would now fail); (a3) restore runs at most once per mount even when called
again (the wiring effect can re-run).
- F2: log instead of silently swallowing sessionStorage errors in
readStorage/writeStorage (AGENTS.md "errors must never be swallowed" rule);
no user notification since a missed scroll restore is not actionable.
- F3: document the hard dependency on PageEditor remounting per page
(key={page.id}) at the refs declaration — the per-mount refs are not reset
on an in-place pageId change, so removing that key would break restore on
the 2nd page.
vitest 9/9, tsc 0, eslint 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds useScrollPosition(pageId): saves window.scrollY to sessionStorage
(key gitmost:scroll-position:<pageId>) on throttled scroll / pagehide /
visibilitychange / cleanup, capturing the previously-saved value
synchronously at mount before any handler can overwrite it with the fresh 0.
restoreScrollPosition() (wired in page-editor.tsx to fire once the live
content is laid out, !showStatic && editor) yields to a #hash anchor, then
polls the document height and scrolls to the saved Y once the content is
tall enough, with a 5s timeout clamped to the max reachable position. All
storage access is try/caught so a disabled/quota'd Storage never breaks the
page. The in-flight restore poll is held in a ref and cancelled on unmount,
so a fast SPA navigation can't scroll the next page. closes#266
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Backfill the two genuinely-uncovered infra-free units from the #244 Part B
test backlog (the rest was already covered by #248/#257):
- use-streaming-dictation: the in-order transcription emitter. Drives the
real hook via renderHook with mocked VAD + deferred transcribeAudio so the
test controls response order. Asserts out-of-order HTTP responses still
emit text in segment order; whitespace trimmed and empty results dropped
while the sequence advances; a failed segment shows one notification and is
skipped so later segments still flush; a response resolving after cancel()
is dropped (stale-epoch guard).
- internal-link-paste (handleInternalLink / createMentionAction): validateFn
reject → no resolve/dispatch; resolve → mention node with the resolved page
+ anchor dispatched via replaceWith at pos; "Untitled" fallback; reject →
raw url inserted as text under a link mark; createMentionAction wiring to
getPageById on success + failure.
Test-only; no production code changed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The floating bubble menu had no way to clear formatting, so in the
default configuration (fixed toolbar disabled) users could not reset
inline formatting at all. Mirror the fixed-toolbar action into the
bubble menu: a new "Clear formatting" item running unsetAllMarks().
- bubble-menu.tsx: import IconClearFormatting; append a non-toggle
"Clear formatting" item (isActive: () => false) to the items array.
- No i18n changes — the "Clear formatting" key already exists in all
locales.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an inline spoiler (Telegram/Discord-style hidden text): a TipTap mark
`spoiler` rendered as <span data-spoiler="true" class="spoiler">, blurred via
CSS and revealed on click (UI-only is-revealed class, never persisted).
- packages/editor-ext: the Spoiler mark (inclusive:false, set/toggle/unset
commands, ||text|| input rule), exported; a lossless turndown rule emitting
raw inline HTML; round-trip test.
- apps/client: SpoilerView mark-view (ReactMarkViewRenderer, Link pattern),
registration in extensions, bubble-menu toggle button (editable only), CSS
(blur + @media print reveal), en/ru i18n.
- apps/server: register Spoiler in collaboration.util tiptapExtensions so the
mark survives HTML<->JSON export/index/import/Yjs; a test proving the public
share keeps the spoiler (it isn't stripped with comments).
No keyboard shortcut: the proposed Mod-Shift-s collides with Strike (and
Mod-Shift-h with Highlight); the ||text|| input rule + the bubble-menu button
cover ergonomics.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
F1: add a test that empties a non-empty doc via a change-origin transaction
(ySyncPluginKey meta, the shape y-tiptap sets for remote/merge updates) and
asserts the intentional-clear signal is NOT emitted — pinning the
isChangeOrigin early-return that keeps remote emptiness from punching through
the #248 server guard. The 4 existing tests use local transactions and never
exercised that true-path (verified: removing the guard fails only this test).
F2: record the #248 empty-overwrite guard and the #251 intentional-clear in the
CHANGELOG [Unreleased] Fixed section.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The #248 store-side empty-guard (onStoreDocument) unconditionally refuses to
overwrite non-empty persisted content with an empty document, because a
momentarily-empty live Y.Doc is indistinguishable from a real clear at the
store layer. That correctly blocks glitches/bad-merges, but also blocks a user
who genuinely wants to empty a page. This re-introduces a WORKING, narrow,
non-spoofable exception (the dead context.intentionalClear hatch #248 removed
never had a real channel).
Definition of an intentional clear (client, IntentionalClear editor extension):
a LOCAL user transaction (docChanged, NOT a remote y-sync change — filtered via
isChangeOrigin) that reduces a non-empty doc to the empty single-paragraph
shape. This is exactly the select-all + Delete/Backspace keystroke path.
Transport (option b — hocuspocus stateless message): on that transition the
client sends a `{type:'intentional-clear'}` stateless message. The server
(PersistenceExtension.onStateless) records a short-lived (TTL 60s > 45s
maxDebounce), single-use "pending clear" flag keyed by the connection's
document. The next debounced onStoreDocument consumes it on the empty-guard
branch to let that one empty write through.
Why this is the right channel and non-spoofable:
- Yjs transaction origin/metadata does not survive to the server store; awareness
is per-connection and racy. A stateless message ties the signal to a specific
clear, survives the debounce, and rides the authenticated connection.
- The document is taken from the connection, never the payload, so a client
cannot target another page.
- The flag is read ONLY on the empty-over-non-empty branch, so the worst a forged
signal can do is clear a page the connection may already edit; it can never
force or alter a non-empty write. Read-only connections cannot arm it. Every
non-empty store drops a pending flag, so "cleared then retyped" leaves nothing
usable; the flag is single-use and TTL-bounded.
NOTE: #248 is not yet on develop, so the empty-guard block is included here as
the foundation this exception extends. If #248 lands first this rebases cleanly
(the guard logic is identical; the #251-unique additions are the exception,
onStateless, the pending-flag state, and the client extension).
Tests:
- Server (real transport path, not a hand-poke): onStateless sets the flag with
the exact client payload, then the debounced onStoreDocument persists the empty
doc; plus single-use consumption, read-only rejection, non-empty-store drops
the flag, and the unchanged #248 guard tests (empty-over-non-empty blocked,
empty-over-empty allowed).
- Client: a real Editor + the actual selectAll+deleteSelection command emits the
signal; typing / non-emptying edits / already-empty docs do not.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extract the ~110 duplicated lines into one parameterized
useImageTextFieldControl and make useAltTextControl/useCaptionControl
thin wrappers. Behavior identical; t("...") literals stay in the
wrappers so i18n extraction keeps working. sanitizeCaption still
exported for its unit test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The comment claimed 250 groups -> 499 chars -> slice past 500; the
input is 120 "a b " groups collapsing to 479 chars, under the cap
with no slice. Correct the comment and assert the 479 length.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- docs: add CHANGELOG Unreleased/Added entry for editable image captions
- test: export sanitizeCaption and add vitest unit coverage
(whitespace collapse, trim, 500-char boundary)
- refactor: drop duplicate .imageCaption CSS module class, keep the
global .image-caption as the single source
- docs: fix turndown image-caption comment (video rule emits a markdown
link, not a <div>)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a visible caption (<figcaption>) under images, editable from the
image bubble-menu and persisted across all formats: native Yjs/JSON,
HTML export, and Markdown.
- image node: new plain-text `caption` attribute (parse/render
`data-caption` on <img>, emitted only when set) + `setImageCaption`
command. The node stays an atom; the schema shape is unchanged, so the
server's generateHTML/generateJSON path round-trips it for free.
- resize node-view: re-parent the resizable wrapper into a <figure> and
render the caption in a <figcaption> BELOW it, outside nodeView.wrapper
(so onCommit's offsetHeight measurement and the left/right resize
handles still cover the image only). This path also drives read-only /
share rendering. React placeholder view renders the caption too.
- bubble-menu: new useCaptionControl panel modeled on useAltTextControl
(own icon, Caption strings, softer sanitizer, ~500 char limit).
- markdown lossless round-trip: a captioned image is emitted as a raw
<img data-caption> wrapped in a block <div> (same trick as <video>) in
both the editor-ext turndown rule and the MCP converter; caption-less
images stay clean . Import restores the caption via the
shared markdownToHtml + parseHTML.
- styles + i18n keys; tests for the schema attr round-trip, markdown
round-trip (editor-ext) and the MCP converter.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review #6 (approve-with-comments) follow-ups:
1. canonicalize step 7 now strips bare footnoteDefinitions at ANY depth
(stripFootnoteDefinitionsDeep), not just footnotesList, in BOTH copies. A
definition hand-authored outside a list (e.g. nested in a callout via a
raw-JSON write path) was left in place while a copy was also added to the
rebuilt list -> duplicate, idempotent, self-perpetuating. Runs only in the
rebuild path (after the lists are stripped); the fast-path / placement-keep
branch is untouched. Added a shared-corpus case (bare def nested in a callout)
to pin it in both mirrors.
2. markdown-clipboard: removed the dead top-level footnoteReference check in
canonicalizePastedFootnotes (an inline atom is never a top-level slice child;
only the descendants scan can find it).
Test coverage:
4. New MCP binding tests (full-doc-write-canonicalize.test.mjs): update_page_json
and copy_page_content canonicalize the persisted full doc, asserted via a new
`replacePage` seam (symmetric to the existing `mutatePage` seam) so no live
collab socket is needed. Routed both writers through the seam.
5. New server spec (file-import-task.service.footnote-canonicalize.spec.ts): the
zip-import path (processGenericImport) canonicalizes footnotes — real
markdown->HTML->JSON via a real ImportService over a temp-dir .md file, DB trx
stubbed to capture the persisted page content. FileImportTaskService had no
spec before.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Must-fix:
- insertInlineFootnote could glue a footnoteReference inside an EXISTING
definition (nested footnotesList, or a bare footnoteDefinition with no list
wrapper), which canonicalize then dropped as an orphan — silently losing the
definition's prose. Now: (a) the body/notes boundary is computed from the first
top-level block that IS or CONTAINS (recursively) a footnotesList/
footnoteDefinition, not just a top-level list; and (b) the insertNodesAfterAnchor
core skips footnotesList/footnoteDefinition subtrees entirely (skipSubtreeTypes),
so an anchor whose only match is inside a definition -> inserted:false (clean
abort, no write). Added tests: nested-definition, bare-definition, and
body-before-nested-list-still-inserts.
- editor-ext footnote-canonicalize header listed `markdownToProseMirror` among the
canonicalizing MCP paths; it is the NON-canonicalizing primitive. Replaced with
`markdownToProseMirrorCanonical` (+ note that the plain primitive is for comment
bodies) and added copy_page_content.
- Client paste: canonicalizePastedFootnotes now skips a definitions-ONLY paste
(no footnoteReference anywhere) — canonicalizing it would strip the
reference-less list and yield an EMPTY paste. Added a test.
Suggestions:
- docmost_transform now runs validateDocStructure/validateDocUrls on the RAW
transform output BEFORE canonicalizeFootnotes (mirrors updatePageJson), so a
too-deep doc gives the intended max-depth error instead of a stack overflow.
- docmost_transform tool description now states the RESULT is footnote-canonical
(dryRun diff may show tidy-ups; idempotent after first run).
- insertFootnote: dropped the dead `result ? … : undefined` ternaries and the
`as any` casts (result is always set by the time we return; the not-found path
throws and aborts mutatePage). `const r = result!;`.
Tests / architecture:
- Added a LIVE-plugin golden case: the real footnoteSyncPlugin leaves a list with
non-empty content after it in place, and canonicalize agrees (placement parity
is now a driven property, not a hand-set expected).
- Added generateFootnoteId uuidv7 shape + uniqueness test.
- Item 9: added the ENFORCEMENT-RULE comments at the server parseProsemirrorContent
and the MCP canonicalizer header (any NEW full-doc persist path MUST canonicalize;
fragments/append/prepend and comment bodies MUST NOT). Kept per-call-site over a
brittle grep CI test (the replace-vs-fragment + comment-vs-page nuance makes a
single wrapper unsafe).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The footnote canonicalizer was wired into the MCP and editor-ext write paths
but NOT into the server's user-facing markdown/HTML import paths, so importing
or pasting markdown with out-of-order, reused, or orphan footnotes did not
canonicalize -- the exact trigger bug #228 fixes was still reproduced on
import. markdownToHtml -> htmlToJson builds ProseMirror JSON directly and never
runs the editor's footnoteSyncPlugin, and that plugin does not reorder an
existing list, so the stored footnotes kept the source's physical definition
order, retained orphans, and did not collapse reused references.
Wire canonicalizeFootnotes (already exported from @docmost/editor-ext) into
every server markdown/HTML -> page-JSON seam, before persisting:
- ImportService.importPage (REST single-file .md/.html import)
- FileImportTaskService (zip import worker)
- PageService.parseProsemirrorContent (API createPage / updatePageContent)
Also hook the client markdown paste: handlePaste applies a manual transaction
(returns true), bypassing transformPasted/footnoteSyncPlugin, so a pasted
out-of-order markdown footnote block would persist out of order.
canonicalizePastedFootnotes reorders a self-contained pasted block (one that
carries its own footnotesList) to reference order, deduped and orphan-free; it
is deliberately scoped to whole-block pastes so a reference-only paste that
reuses a footnote already defined in the target doc is left untouched.
canonicalizeFootnotes is pure, idempotent and shape-safe (a doc with no
footnotes is unchanged), so it is safe on every write path.
Residual: when a pasted block merges into a doc that already has footnotes,
ordering relative to the pre-existing footnotes is still governed by the live
sync plugin (which does not reorder across the boundary).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Additive test coverage across server, editor-ext, client and mcp.
#192 — AiChatService.stream integration (Section 3, against real Postgres):
- new apps/server/test/integration/ai-chat-stream.int-spec.ts drives the real
streamText through a seeded ai/test MockLanguageModelV3 and a real Node
ServerResponse, covering: onError persists an assistant error record
(status 'error' + partial answer + provider cause in metadata); external MCP
client closed exactly once on BOTH onFinish and onError; anti-tamper —
history is rebuilt from the DB transcript, not from body.messages.
#206 — red-team findings (most already fixed+tested in #212):
- mdrt-2 (UNFIXED, data loss): turndown.dataloss.test.ts documents that
pageBreak / transclusionReference / mention are silently dropped on Markdown
export (characterization + it.fails for the desired survive-export contract).
- persist-6 (UNFIXED, data loss): persistence-store.spec.ts adds an it.failing
documenting that a momentarily-empty live doc overwrites non-empty content
(left unfixed — a store-side empty-guard is a behaviour change).
#204 — test-strategy plan, highest-priority subset:
- Phase 1: mcp-clients.lease.spec.ts covers the external MCP client
lease/refcount/eviction lifecycle (leak / premature-close / double-close).
- Phase 2 data-integrity pure functions: editor-ext table-utils
(transpose/moveRow/convert round-trip) and math tokenizer false-positive
guard; client emoji-menu (+ it.fails for the unguarded localStorage
JSON.parse bug), sort-cells, normalizeTableColumnWidths; mcp htmlEmbed/
pageBreak markdown data-loss + footnote-diff; server export
getInternalLinkPageName extensionless-path bug — FIXED (small/clear) + tested.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Public sharing (#218):
- Bind public-share content to the requested shareId. getSharedPage now
enforces dto.shareId (forwarded from /share/:shareId/p/:slug): the page must
be reachable THROUGH that exact share (its own share, or an includeSubPages
ancestor that contains it). A forged/mismatched shareId 404s instead of
rendering off the slug alone and no longer leaks the real canonical key via
redirect. A request with no shareId keeps the legacy slug-capability path.
- Trim /shares/page-info: drop internal metadata (creatorId, spaceId,
workspaceId, contributorIds, lastUpdated*, parent/position, lock/template
flags, timestamps) from the anonymous payload.
- Default share-to-web includeSubPages to false (opt-in), so enabling a share
no longer silently exposes the whole sub-tree (#216).
Editor (#218):
- Harden the new-page pre-sync window: the body editor is kept read-only until
the collab provider is Connected and synced, so early keystrokes can't land
only in local ProseMirror and then be clobbered by the server's empty doc.
- Surface a "Connecting… (read-only)" affordance during the static phase so
input isn't silently swallowed.
Other:
- Breadcrumb: resolve from the page's own ancestor data (/pages/breadcrumbs)
instead of waiting for the lazily-built sidebar tree, so deep pages don't
render a blank breadcrumb for seconds.
- Pasting GitHub `> [!type]` callouts now converts to a callout node instead of
a literal blockquote (new marked extension wired into markdownToHtml).
Tests: editor-sync-state gate (client), getSharedPage share-binding (server),
github-callout markdown conversion (editor-ext).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove the separate, un-toggleable `settings.ai.generative` workspace flag
(and its write-side alias `generativeAi`) along with the dead "Ask AI"
generative editor menu, and re-gate the AI page-title generation on the
general AI chat flag (`settings.ai.chat`) — the same toggle that enables
the chat agent and the chat stream endpoint.
Why: the `generative` flag had no UI toggle (its switch was already removed,
leaving orphaned i18n strings), so the title-generation button was
unreachable on self-hosted. The "Ask AI" menu was dead — its atom was never
rendered. Consolidating onto the AI chat flag makes the title button follow
the one AI switch users actually have.
Changes:
- server: title-gen endpoint gate generative -> chat (ai-chat.controller.ts);
remove generativeAi from update DTO and workspace service (update block,
delete line, cloud default now { ai: { chat: true } }); fix repo comment;
migrate generate-page-title spec assertions generative -> chat.
- client: title-gen gate -> settings.ai.chat (full-editor.tsx); remove the
dead Ask AI button + showAiMenu wiring from bubble-menu; remove AskAiGroup
usage/import and commented block from fixed-toolbar; delete ask-ai-group.tsx;
remove showAiMenuAtom; drop generative/generativeAi from workspace types.
- i18n: remove 3 orphaned generative-AI keys from all 12 locales.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add coverage for the two untested branches in useGeneratePageTitle's
post-generation write: suppressing setContent when the live title editor
is focused (DB write + broadcast still happen, only the visible field
write is skipped), and the early return when the page editor is
destroyed (model never called).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- CHANGELOG: add an [Unreleased]/Added bullet documenting the
"generate title from content" byline button (reads live editor
content, generates via the workspace AI provider, applies through
/pages/update, gated by settings.ai.generative, throttled per user).
- use-generate-page-title: guard the visible title write against page
navigation during generation. The mutation awaits the model for 1-3s;
its closure captures the editors from the starting render, but the
global page/title atoms re-point on navigation. We now keep a live ref
to the current editors and skip setContent unless the live page editor
still belongs to the page the title was generated for
(editor.storage.pageId === pageId, mirroring TitleEditor's
activePageId guard). The DB write stays correct (keyed by the captured
pageId) and the websocket broadcast is unchanged, so only the wrong-page
field write is suppressed.
- Add a vitest suite for the hook: empty content, empty model response,
happy path, the navigation guard, and 403/503/429/other onError mapping.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add an AI button in the page byline that generates a note's title from the
live editor content (including unsaved edits) and applies it immediately.
Server: one-shot, non-streaming POST /ai-chat/generate-page-title mirroring
the chat generateTitle path — gated by settings.ai.generative, throttled via
AI_CHAT_THROTTLER, resolves the workspace chat model and returns { title }.
The endpoint never touches the page; the client applies the title through the
existing /pages/update route (which enforces edit permission).
Client: ai-chat-service.generatePageTitle, a useGeneratePageTitle hook that
converts the editor HTML to markdown, calls the endpoint, applies the title
via updateTitle + updatePageData, reflects it in the unfocused title editor,
and broadcasts the UpdateEvent (mirroring TitleEditor.saveTitle). A sparkles
button (GenerateTitleGroup) renders next to dictation, edit-mode + flag gated.
Tests: pure cleanGeneratedTitle helper + controller gate/delegation/error-map.
i18n: en-US + ru-RU strings.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
"Temporary notes" with a death timer: created via a dedicated hourglass button
in the space-tree header, a note auto-moves to Trash after a configurable X
hours (default 24) unless explicitly made permanent ("structure or die").
Reuses existing mechanisms, mirroring is_template and the trash-cleanup job:
- New nullable column pages.temporary_expires_at (NULL = permanent; non-NULL =
frozen deadline) + partial index for the sweep; workspace column
temporary_note_hours (default via DEFAULT_TEMPORARY_NOTE_HOURS = 24).
- create-page DTO `temporary` flag; the deadline is frozen at creation so later
setting changes never reschedule existing notes.
- POST /pages/toggle-temporary (mirror of toggle-template): arm/clear the timer,
CASL-guarded via validateCanEdit, cross-workspace NotFound defense-in-depth.
- TemporaryNoteCleanupService: hourly @Interval sweep that soft-deletes expired
notes through the exact PageRepo.removePage path (recursive over children,
emits PAGE_SOFT_DELETED), attributed to the creator; idempotent via
deletedAt IS NULL filters.
- restorePage clears temporary_expires_at so a restored note can't be re-trashed.
- Workspace setting temporary_note_hours (audit-tracked) + a hours editor in
workspace General settings.
- Client: second create button, orange tree icon, tree + page-header menu toggle
("Make temporary"/"Make permanent"), an open-note banner with a rescue action,
and en/ru i18n.
Tests (unit): toggle-temporary controller (toggle/explicit/permission/cross-ws +
DTO validation), cleanup-job sweep (selection filters, per-note removePage,
error isolation), and a migration up/down sanity. Server tsc, client tsc -b,
and the page+workspace jest suites are green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove top margin from the first paragraph in footnote definitions so the
marker aligns with the first line of text. Adjust the task‑list label to use
the editor line‑height variable for its height and center the checkbox
vertically, keeping it in line with the item’s first text line.
The re-review's blocking/structural points (lease leak, dup-id guard test,
body-before-title test, CHANGELOG, pg18, shared jsonb decoder) were already
addressed in commit 24264ef; this adds the 4 genuinely-new coverage requests:
- pt 6: `scrollToReference(id, index?)` exercised against a live editor DOM —
selects the index-th `sup[data-footnote-ref][data-id]` occurrence, falls back
to the first for out-of-range, returns false for an empty id (scrollIntoView
stubbed). (#168)
- pt 7: export `backlinkLabel` and pin the base-26 carry boundary
(25->z, 26->aa, 27->ab, 51->az, 52->ba). (#168)
- pt 8: integration fail-open — a PRESENT-but-corrupt tool_allowlist (jsonb
string scalar holding non-array JSON) reads back as null ("no restriction"),
covering normalizeRow's degrade branch. (#159 #172/#173)
- pt 9: getFootnoteRefCount cache invalidation — adding a `[^a]` reference bumps
the cached count 2 -> 3. (#168)
Verified: editor-ext footnote 23; client structure 7 + tsc; server int 8.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
After #166 a repeated `[^a]` is one footnote (reuse): one number, one
definition, N forward links. But the definition's ↩ only returned to the
FIRST reference. Now a definition with N references shows ↩ a b c …, each
backlink scrolling to its own occurrence (Pandoc/Wikipedia convention); a
single-reference footnote keeps the plain ↩ unchanged.
- editor-ext: `computeFootnoteRefCounts(doc)` (id -> occurrence count) cached
alongside the number map in the numbering plugin state; `getFootnoteRefCount`
getter (O(1), no per-render doc walk). `scrollToReference(id, index?)` picks
the index-th `sup[data-footnote-ref][data-id]` occurrence (document order),
falling back to the first.
- client: FootnoteDefinitionView renders one lettered link (a, b, c, … aa …)
per occurrence when refCount > 1; the chrome stays after the contentDOM so
the #146 caret invariant holds. i18n keys (ru) added.
Tests: computeFootnoteRefCounts + getFootnoteRefCount (reuse counts, unknown
id => 0); structure test gains 3 cases (N lettered links render, click jumps
to the n-th occorrence, single ref => one ↩). NOTE: the visual layout of the
backlink row needs a real browser to verify (jsdom can't); the structural and
behavioral contract is covered headless.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Extract buildSubtree/mapSharedNodes/countNodes/SubpageNode into
subpages-view.utils.ts with a unit test (subpages-view.utils.test.ts)
covering nesting, position order, missing/unreachable parent, self-parent
guard, empty input, countNodes and mapSharedNodes remap.
- Replace the manual useState + editor.on("transaction") subscription in
subpages-menu.tsx with useEditorState (the idiom the sibling bubble menus
use), so the mode icon/tooltip track the live recursive attribute without
re-rendering on every keystroke.
- i18n: add the 6 menu/tree strings and a pluralized
"Showing {{count}} subpages" key to en-US and ru-RU.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The `subpages` node showed only one level of direct children. Add a `recursive`
attribute that renders the FULL descendant tree of the current page — fully
expanded, unlimited depth. Default `false`, so every previously-inserted node
stays flat (backward compatible). No backend changes: `POST /pages/tree` (via the
`getSpaceTree` wrapper) already returns the whole subtree as a flat `IPage[]`
(recursive CTE, permission-filtered); the nested tree is built on the client by
`parentPageId`.
- editor-ext `subpages.ts`: `recursive` attribute (parse/render `data-recursive`),
shared by client + server so the collab ProseMirror schema keeps the attribute.
- `getSpaceTree`: arg loosened to `{ spaceId?; pageId? }` (the endpoint accepts
either); new `useGetPageTreeQuery(pageId)` react-query hook.
- `subpages-view.tsx`: split into `FlatSubpages` (unchanged) and
`RecursiveSubpages`; `buildSubtree` assembles the nested tree (cycle/self-parent
guard, `sortPositionKeys` per level, root excluded) and a recursive `TreeNode`
renders it (16px indent per depth, soft "showing N" note past 300 — data never
capped). Shared/public context reads the already-nested shared tree, no
`/pages/tree` request.
- toggles: bubble-menu flat⇄tree button + a second slash-menu item "Page tree".
Review follow-ups folded in: invalidate `["page-tree"]` from the create / update /
move / delete cache helpers so an open recursive tree refreshes (no stale data);
mode icon made reactive on editor transactions; `t` threaded into `TreeNode`
(no per-node useTranslation); shared-subtree hook deduped to a thin alias.
editor-ext build + client `tsc --noEmit` both clean. Backend untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per review: the file's other :global is locally scoped (.container:global(...)),
but the new float-reset media rule was fully global in a *.module.css. Scope it to
.container — the image node-view container carries BOTH the .container class and
the data-image-align attribute (same element), so behavior is unchanged while the
selector no longer leaks globally.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Review of #157 (Request changes) caught two blockers:
1. DEAD responsive CSS: the `@media (max-width:600px)` float-reset was added to
`image-resize.module.css`, which is imported NOWHERE — the image container's
classes come from `common/node-resize.module.css` (via buildResizeClasses).
So on mobile a floated image kept its px width + float and crushed the text,
exactly the failure the rule promised to prevent. Moved the rule to
`common/node-resize.module.css` (the module actually imported by the resize
node views); its `:global([data-image-align=...])` selectors are data-attr
based, so they work unchanged. Reverted the dead addition from the (pre-existing,
orphaned) image-resize.module.css.
2. `applyAlignment` was untested. Exported it and added `image.spec.ts` (vitest/
jsdom) covering all five align values, the data-image-align mirror, and the
floatLeft -> left reset-then-apply (the guard against a leaked float).
Switched the float writes to the canonical CSSOM `cssFloat` property (portable:
browsers + jsdom; behavior identical to the `.float` alias).
editor-ext build + client tsc clean; 6 image.spec tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>