Repository navigation
Add Kroki renderer - #1900
Merged
Merged
Add Kroki renderer#1900
Conversation
NGPixel
approved these changes
May 17, 2020
NGPixel
added a commit
that referenced
this pull request
May 17, 2020
* feat: Kroki integration see https://kroki.io/ * fix: markdown-kroki def updates Co-authored-by: Nicolas Giard <[email protected]>
Member
|
Thanks! Just make sure your PRs are against the master branch next time, not latest. |
|
I'm excited to see so much support for text generated diagrams! Will it be possible to to configure WikiJS so that one can use a self managed Kroki docker-compose server as described here? |
Member
|
@kbfifi Yes, you can define the server URL you want to use. |
|
@kbfifi would you update Mermaid from 8.8.2 to the latest 10.7 or close to? |
jionggyu
pushed a commit
to jionggyu/wiki-2.5.302-patch
that referenced
this pull request
Jul 9, 2024
* feat: Kroki integration see https://kroki.io/ * fix: markdown-kroki def updates Co-authored-by: Nicolas Giard <[email protected]>
dylan-hart
added a commit
to dylan-hart/wiki
that referenced
this pull request
Aug 31, 2026
* Format winput-autofocus-mechanism.md per oxfmt (asterisk->underscore italics) Co-Authored-By: Claude Sonnet 5 <[email protected]> * Stub contentSync.forgetContent in deleteAsset hook-ordering test Merging wp-1673's contentSync cleanup into deleteAsset left the existing hooks/storage ordering test's WIKI stub without a contentSync model, throwing on the newly-added forgetContent call. Co-Authored-By: Claude Sonnet 5 <[email protected]> * Merge wp-1684-userkeys-token-index-purge: resolve cron seed conflicts, oxfmt fix Combines purgeContentSyncState and purgeUserKeys as separate seeded cron entries (offset to 40/45 past midnight to avoid a same-minute clash), keeps both branches' JOB_SCHEDULE_SEED tests, and merges the drizzle-orm import lists in models/users.ts. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Fix double glossary-cache invalidation from merging wp-1688 The merge conflict resolution for wp-1688-extract-move-side-effects-helper initially kept HEAD's direct WIKI.models.glossary.invalidateCache() call inside recordPageMoveSideEffects, alongside the helper's own batched glossaryInvalidate-flag return value that the caller (movePage) already ORs across the whole move batch and invalidates once. That double-counted the cache invalidation for a single-page move. Removed the direct call so only the flag-based, once-per-batch invalidation remains, matching the helper's own docstring and the WP's regression test. * Give adminStore's fetch actions an error path (#1732) None of adminStore's four fetch actions (fetchLocales, fetchInfo, fetchSites, fetchClassificationLevels) had a try/catch, so a rejection propagated out unhandled -- most visibly, AdminLayout.vue's onMounted awaits fetchSites() bare, so a 401 on an expired session or a 5xx skipped the following fetchInfo() call entirely, leaving the dashboard silently half-initialised with no notification explaining why. Wrap each action's body in a try/catch that notifies and leaves the slice at its state() default. This also fixes AdminLayout.vue's mount sequence for free: fetchSites() can no longer reject, so execution reaches fetchInfo() regardless, and the two un-awaited calls (fetchLocales, fetchClassificationLevels) can no longer produce an unhandled promise rejection either -- no AdminLayout.vue change needed. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Flip boot/api.js and AuthLoginPanel's ky clients to throwHttpErrors: true boot/api.js's shared ky client used `throwHttpErrors: (statusNumber) => statusNumber > 400`, so a 400 resolved instead of rejecting and callers had to remember to check `resp.ok`. Flip it to plain `true` so a 400 is an error like any other -- every catch already routes through apiErrorMessage(), which reads err.data.message off the same envelope, and ky's HTTPError natively populates .data with the parsed body before throwing. AuthLoginPanel.vue's login/register/changePwd/resetPassword each repeated the same ad-hoc override on their own API_CLIENT calls; remove all four so they inherit the client's new default rather than keeping the old non-throwing behaviour. Adds boot/api.test.js (new file) driving the real ky client against a stubbed fetch to assert a 400 rejects with an HTTPError carrying data.message, and a 2xx/500 sanity pair. Updates two AuthLoginPanel.test.js assertions that expected the now-removed per-call throwHttpErrors option. WP #1758, part of epic #1754. This is the enabling change; it lands before the sibling WPs that convert unwrap() and the sites that still branch on a resolved { ok: false } (#1762, #1767, #1772, #1776) -- until those land, a handful of other call sites (PageHistoryOverlay's branchFrom, AdminPagesDeleted's pageInvalidLocale, SiteActivateDialog/SiteDeleteDialog) will throw on a 400 instead of resolving with ok:false, which is expected per the epic's breakdown. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Rename the five delegating local date formatters to shared humanizeDate Search.vue, TagsBrowse.vue, AdminUsers.vue, AdminComments.vue and AdminPagesDeleted.vue each declared a local `humanizeDate`/`formattedDate` wrapper that just delegated to `userStore.formatDateTime`. Replace all five with the shared `humanizeDate` import from helpers/datetime.js so one grep finds every absolute-timestamp formatter in the app. No behavior change; drops now-unused `useUserStore` imports where nothing else in the file referenced it. WP #1759 * Fix blockAllowances() doc: setBlocksState does not queue a re-render (#1738) The doc comment claimed disabling a block "take[s] effect on the pages that already embed it, since each is re-rendered through here" -- false. setBlocksState() only flips isEnabled/config; nothing queues a re-render of pages carrying the tag, so a page saved before the toggle keeps <block-x> in its stored render until it is next saved or explicitly re-rendered. The reader-facing exposure this implied (a disabled block's component still loading for readers) is already closed by #1729's blocksIndex guard, landed separately -- so this is a pure doc/behavior reconciliation, not a new gap. Building a bounded bulk re-render queue was considered and deliberately left out of scope: there's no cheap way today to find "pages whose stored render embeds tag X", and setBlocksState has no PageActor to compute per-page render permissions from. Corrects the doc on both sides (blockAllowances() and setBlocksState) and adds a DB-backed regression test locking in the actual behavior -- disabling a block leaves pageRenderQueue empty and a page's stored render untouched -- so neither doc can silently drift out of sync again. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Delete stores/page.js#unwrap() and convert callers to try/catch (#1762) unwrap() existed only to turn boot/api.js's non-throwing 400 contract back into a rejection. pageMove/pageRename/pageSave now let the real API rejection propagate and convert it via apiErrorMessage() so the server's message still lands on the thrown error's .message, which is what callers such as PageHeader.vue and PageActionsCol.vue already read directly. pageSave's outer catch distinguishes a genuine ky HTTPError (via .response) from its own ERR_*/ERR_SAVE_CONFLICT errors, which continue to pass through unconverted. Adds refusal-path coverage for pageMove() and pageRename() (neither had any) plus a non-409 refusal case for pageSave()'s create and update paths. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Hoist the saveConflict watcher into PageHeader.vue's save handler (#1747) editorStore.saveConflict's watcher and resolveSaveConflict() lived only in EditorMarkdown.vue, so PageSaveConflictDialog.vue was reachable from the Markdown editor alone -- EditorWysiwyg, EditorCode, EditorAsciidoc and EditorRedirect all fell through to saveChangesCommit()'s generic negative toast on a 409 instead. Move both into PageHeader.vue, the one save path every editor already routes through, and suppress that generic toast when the failure is ERR_SAVE_CONFLICT since the dialog is already on its way up. The hoisted resolveSaveConflict() drops the Markdown-specific editor.setValue() /processContent() calls its original "discard" branch made directly against EditorMarkdown's local Monaco instance -- PageHeader has no reference to whichever editor is mounted, so it patches pageStore/editorStore state only. The page's stored content is corrected either way; that one editor's own on-screen copy can lag a beat behind it until the next edit or a remount. Add PageHeader.test.js coverage: the watcher raising the dialog with a non-Markdown editor active, discard adopting the server snapshot, and the generic toast being suppressed for ERR_SAVE_CONFLICT but not other failures. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Declare w-checkbox's dense prop, drop dead size attributes WCheckbox.vue bound no size prop, so the 2 `size="..."` attributes at its call sites rendered as no-op DOM attributes. Declares `dense` (matching the boolean convention already used by WToggle and WInput), shrinking the box and glyph, and updates the 2 size call sites to agree: AdminAuth.vue's `size="sm"` becomes `dense` (its checkbox sits in an already-dense w-select option row), and PageDataDialog.vue's redundant `size="lg"` (contradicting its own `dense`) is dropped. OpenProject #1806 Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Serve content-hashed /_assets/ files as immutable (OpenProject #1821) Add isHashedAssetFilename() to helpers/common.ts, a pure predicate for vite's `[name]-[hash].[ext]` build output naming, and wire it into the /_assets/ fastifyStatic registration's setHeaders callback so hashed files get a far-future immutable Cache-Control header (matching thumb.ts's THUMB_CACHE precedent) while the 8 unhashed entries keep the existing 7-day maxAge. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Remove the three call-site rerender workarounds and MCP blank-render caveats (OpenProject #1723) models/pages.ts's createPage()/updatePage() already own the ensureCanRender-before-write / queueRerender-after-write pairing for a content-without-render write (#1716), so the callers that used to compensate for its absence now just duplicate it: - models/approvals.ts's approveSubmission() dropped its own `if (!render) queueRerender(...)` after updatePage(). - modules/storage/disk/storage.ts's importPage() dropped its try/catch queueRerender() after createPage() -- a missing Puppeteer now refuses the create up front instead of landing a blank page, surfacing as an `unrecognized` entry via importLocaleDir's existing per-entry try/catch. - migration/page-import.ts's 'queue' renderBootstrap mode already left `input.render` undefined; its separate post-create queueRerender() call (and the now-dead MappedPage.queueRerender/PagesWriteModel. queueRerender plumbing) is gone too -- createPage() alone does the whole job. Also found and removed a fourth, unlisted instance of the same bug: models/pageHistory.ts's recoverDeletedPage(), which landed on 2026-08-30 (after this WP was filed) with an identical compensating call. Left in, it would have failed this WP's own Done-when grep ("no compensating call outside models/pages.ts and the scheduler/route paths"). Deleted the now-false "rendered view is blank until re-saved" caveats from mcp/tools/createPage.ts and updatePage.ts. Updated backend/migration/page-import.test.ts's FakePagesModel and backend/modules/storage/disk/storage.test.ts's fakeImportDeps to simulate createPage()'s internal auto-queue behavior (they stand in for the real model), and rewrote backend/models/pageHistory.test.ts's DB-backed coverage to assert the real pageRenderQueue row instead of a mocked queueRerender call, and the up-front-refusal behavior instead of the old best-effort one. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Standardise W* library on `disabled`, delete Quasar-era `disable` alias WBtn, WBtnToggle, WCheckbox, WInput, WRadio, WSelect, WTab and WToggle each accepted both `disable` and `disabled` for the same concept, with the redundant `props.disable || props.disabled` OR baked into every one of them. WRange and WRating only ever had `disable`. Standardise all ten on `disabled` -- the native HTML spelling, and the one WItem already used -- and delete the alias outright per CLAUDE.md's no-shim policy. Renames every `:disable="…"` call site (~75 across components/ and pages/) plus two bare shorthand `disable` attributes to `disabled`, and rewrites the WCheckbox/WInput/WSelect/WRange/ModuleConfigForm tests that asserted the alias. WSelect's separate `optionDisable`/per-option `opt.disable` data field is an unrelated concept and is left untouched. Part of OpenProject #1799 (epic #1784). Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * De-correlate updateNavigation's cascade NOT EXISTS into a CTE anti-join The correlated NOT EXISTS in updateNavigation()'s cascade UPDATE re-evaluated the concatenated-ltree ancestor expression once per candidate row. Collect the override/hide boundary paths once into a `boundaries` CTE and anti-join against it instead, so the expression is evaluated per boundary rather than per row. Semantics are unchanged; added a test for a boundary nested directly under another boundary (OpenProject #1827). Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Re-check native Temporal browser support (WP #1828) Safari still lacks native Temporal support as of 2026-08-31 (only behind a flag in Safari Technology Preview), while Chrome 144+, Firefox 139+ and Edge 144+ now ship it natively. Updates boot/temporal.js's header comment with the current dated position, replacing the stale "mid-2026" claim. Go/no-go decision (GO) recorded on parent WP #1824: siblings #1833 and #1838 proceed. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Add GiST expression index for navigation ancestor lookup (OpenProject #1823) ancestorNavId's ltree containment query filters on ("folderPath" || "fileName"), which neither existing folderPath index (bare-column btree/GiST) can match — Postgres was falling back to a row-by-row filter over every override/hide candidate. EXPLAIN (ANALYZE, BUFFERS) against a 280k-row seeded tree measured a ~10x execution-time drop (1.71ms -> 0.18ms) and ~228x fewer buffer reads (1602 -> 7) once the expression index lets the planner Bitmap-AND it with tree_navigationMode_idx instead. Full before/after EXPLAIN output is recorded on the work package. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Add a defineProps-vs-call-site attribute drift check for W* components (#1805) Adds frontend/src/components/shared/wComponentAttributeDrift.test.js: parses each shared W* component's defineProps (object or array form, including components declaring none, like WScrollArea) into a per-tag prop registry, scans every <w-*> call site's template region across frontend/src, and fails on any bound or static attribute naming no declared prop -- allowing the fall-through channel (class, style, key, ref, v-* directives, @/# handlers, data-*/aria-*). Includes fixture-level unit tests of the parser mechanism plus the real-tree gate itself. Running it against the current tree (already rebased onto scarlett, so #1789/#1796/#1803's landed sweeps are in) surfaced 20 residual violations the earlier sweep didn't reach. Fixed each: - Deleted genuinely dead attributes: NavSidebar.vue's w-scroll-area still bound :thumb-style/:bar-style to variables that no longer exist anywhere (the store getter they once read was already removed); a stray w-banner rounded in two more files (WBanner is unconditionally rounded already); w-checkbox dense/size on two more sites (WCheckbox has neither prop); w-input standout in GroupEditOverlay.vue (only WSelect declares it); a dead :tabindex on a w-chip inside a #selected-item slot template WSelect never actually renders. - Added four small declared props for attributes that are functional today via implicit $attrs fallthrough, rather than deleting live behavior: WBtn gains title (native tooltip, HeaderSearch.vue/ProfileAuth.vue) and tabindex (AdminGeneral.vue's inert logo-preview button pairs tabindex="-1" with aria-hidden="true" so it isn't reachable by tab); WBadge gains title (ProfileAuth.vue's 2FA-active badge tooltip); WCardSection gains id (PagePropertiesDialog.vue scrolls each section into view by id). Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Cache the generated navigation tree per site/menu/locale (#1825) Cache generateFromTree's raw, pre-filter output under WIKI.cache (nav:${siteId}:${navId}:${locale}) so a warm auto/mixed menu costs one cache read instead of one query per folder level. Caching happens before getNav's userGroups/unfiltered visibility pass, since the walk itself is actor-blind (pageIsVisible(..., true) always) -- caching anything after the merge/filter would leak a visibilityGroups item between viewers. Invalidation is site-wide rather than per-navId, since one tree write can change what any ancestor menu's walk returns. Wired from navigation.ts's own writes (setNavItems, updateNavigation, deleteNavForEntries -- now siteId-scoped), tree.ts's structural writes (createFolder, renameFolder, addPage, deleteEntry), and pages.ts's updatePage when publishState/icon/title change. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Convert ok:false envelope guards to catch blocks (#1767) Across ~65 files, remove `if (!resp?.ok) { throw ... }` / `resp?.ok === false` checks that compensated for boot/api.js's pre-flip non-throwing 400 -- these become dead code once the api client throws on every non-2xx status, and were already silently discarding server error messages wherever the surrounding catch fell back to `err.message` instead of `apiErrorMessage(err)`. Every genuine HTTP-error-envelope guard around an API_CLIENT mutation call is converted; catches that already read `apiErrorMessage(err)` just lose the now- redundant guard, catches that read `err.message` gain `apiErrorMessage(err)`, and per-error-code translations (`t('ns.' + resp.error, resp.message)`) move to `err.data?.error`. AdminPagesDeleted.vue's pageInvalidLocale branch and several `localizeError()` call sites keep their exact existing behavior. Left untouched: AuthLoginPanel.vue's four ad-hoc ky clients (#1758's scope), stores/page.js#unwrap() and its callers (#1762's scope), and genuine business-logic `ok` fields unrelated to HTTP status (webhook connectivity test results, ImportBatchPageDialog's client-tracked per-row status, BlockUploadDialog's local file validation). Updates the co-located tests whose mocks resolved `{ ok: false }` to instead reject with an `err.data`-carrying Error, matching the established apiErrorMessage() test convention, and adds regression coverage for the three sites the work package named explicitly. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Fix trailing blank line left by merge conflict resolution (oxfmt) * Lazy-load and size the two per-row avatar images (#1855) Add loading="lazy" plus explicit width/height to the UserSearchDialog and CollabPresence row avatar <img> elements, matching each avatar's rendered box (32px / 30px). The three viewport-chrome avatars (AccountMenu, HeaderActionsMenu, ProfileAvatar) are left unchanged so lazy-loading doesn't delay them. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Feature-detect Temporal in index.html, preload polyfill chunk (WP #1838) Adds an inline, non-module script to frontend/index.html that checks typeof globalThis.Temporal === 'undefined' and, only then, injects a <link rel="modulepreload"> for the temporal-polyfill chunk. This lets an affected browser (Safari) start fetching the chunk in parallel with the eager bundle instead of only discovering it after main.js's top-level await initializeTemporal() runs, once the whole eager module graph has already loaded. The chunk's hashed URL is only known post-build, so the injected href is the placeholder __TEMPORAL_POLYFILL_HREF__, documented as the contract sibling WP #1833's Vite plugin substitutes at build time. main.js and boot/temporal.js are unchanged -- this only warms the cache the existing dynamic import then hits. Also extends vitest.config.js's include glob so index.test.js (which executes the real extracted script text in both browser conditions) is discoverable outside src/. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Add Vite plugin to resolve the hashed temporal-polyfill chunk URL frontend/src/build/temporalPolyfillChunk.js exports a pure findTemporalPolyfillChunkFileName(bundle) that locates the built chunk carrying temporal-polyfill's global.esm module (matched against the package's exports["./global"] mapping, including the pnpm-nested and /full variants), plus temporalPolyfillChunkPlugin() which substitutes the chunk's real hashed URL into a new <!--temporal-polyfill-chunk-url--> placeholder in index.html as an inline window.__wikiTemporalPolyfillUrl assignment -- never an unconditional <link rel="modulepreload">, so browsers with native Temporal pay nothing extra. The lookup throws loudly rather than emitting an empty URL when no polyfill chunk is present. WP #1833, part of epic #1824 (Preload the Temporal polyfill in parallel with the entry bundle for browsers that need it). Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Answer conditional avatar/site-asset requests from the hash column (#1852) controllers/user.ts and controllers/site.ts read only the sha1 hash column first, build the ETag from it, and return 304 without ever loading the blob when If-None-Match matches; the blob is read only on a miss. Adds the hash-only readers (users.getAvatarHash, sites.getAssetHash) these routes need -- #1849's remaining scope, added here since #1852 can't be tested/implemented without them (only the hash column + write path from #1846 had landed on origin/scarlett). backend/controllers/user.test.ts (new) and backend/controllers/site.test.ts assert a matching conditional request never calls the blob-loading model method (getAvatar/getAsset), via mocked WIKI.models. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Add hash-only readers getAvatarHash/getAssetHash (#1849) setAvatar/setAsset (#1846) already write the sha1 hash on every upload and clearAvatar/clearAsset already delete the row outright, so the remaining scope here is the two hash-only readers: getAvatarHash next to getAvatar (models/users.ts) and getAssetHash next to getAsset (models/sites.ts), each selecting only the hash column so a conditional request never pulls the blob. Tests: DB-backed round trips asserting the stored hash equals a sha1 of the bytes the blob reader returns, that a re-upload with different bytes changes it, and that clearing leaves the hash reader returning null again -- plus a pure-unit selection-shape test (WIKI.db.select spy, following models/pages.test.ts's precedent) asserting each reader's emitted selection has only a hash key, never data. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Memoise the pooled group-rule array in models/groups.ts (OpenProject #1858) rulesForGroups() flatMapped rulesCache fresh on every call, and checkAccess/ checkSiteAccess/mayHoldPermissionSomewhere each call it at least once per request. Memoise the pooled array in rulesPoolCache, keyed on the sorted group-id set (rule order carries no meaning per helpers/pageRules.ts), and clear it in reloadCache() -- which both broadcastReload() and the inbound reloadGroups event handler call exclusively, so a rule change is visible on the next checkAccess either way. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Cap listPagesForSitemap and paginate /sitemap.xml past 50,000 URLs listPagesForSitemap selected every published, browsable page for a site with no query limit, and the flat single-file sitemap it fed had no cap either -- sitemaps.org rejects anything over 50,000 URLs. The model query now carries a hard, generous ceiling (SITEMAP_QUERY_CAP) so the read itself can never be unbounded, and /sitemap.xml switches to a sitemap index over SITEMAP_URL_LIMIT-sized child sitemaps (addressed by ?page=N on the same route) once a site's page count exceeds the sitemaps.org per-file cap, while staying byte-for-byte the same flat output for sites under it. OpenProject #1857 Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Cache locale strings in WIKI.cache, serve ETag on GET /:code/strings (#1839) getStrings() previously read the strings JSONB column from postgres on every call, and the route set neither ETag nor Cache-Control despite en.json alone being 2,807 keys / 180KB serialized on every response. Caches the parsed strings under localeStrings:${code} in WIKI.cache, mirroring the existing locales key pattern, invalidated from refreshFromDisk, sideloadFromDataPath and reloadCache. The route now sets an ETag derived from the locale row's updatedAt and returns 304 with no body for a matching If-None-Match, removing the response serialization cost in addition to the query. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Cache compiled REGEX page rules instead of recompiling per row (#1861) ruleMatchesPage's REGEX branch recompiled a rule's RegExp on every row it was tested against -- a hot path shared by every rulesAllow caller (the graph, visibleTreeItems(), the sitemap build, the admin comment path), and compilation output depends only on the pattern text. Adds a module-level Map<string, RegExp | null> cache in helpers/pageRules.ts keyed by the normalized pattern text (null marking a pattern that failed to compile, so an invalid pattern keeps failing closed without re-throwing/catching per row), and wires models/groups.ts#reloadCache() to clear it on every reload (boot, a local group edit, and every cluster instance's reloadGroups event) so an edited pattern is recompiled promptly instead of the cache growing unbounded. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Hoist per-request actor out of graph and recoverable-deletions filters Both the knowledge-graph route and the recoverable-deletions list were calling mayOnPage(req, ...) per row, which rebuilds the actor via actorForRequest(req) on every call. Follows tree.ts's visibleTreeItems() shape: build the actor once per request, then call WIKI.models.groups.checkAccess(actor, ...) per row directly. The graph's page-row input is unbounded, so this was one throwaway actor object literal per page on the wiki. OpenProject #1864 Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Gate graph node contributor/pageview counts behind ?sizing= (OP #1863) GET /sites/:siteId/graph's per-node contributor and pageview count objects dominated the payload and most readers never look at them. assembleGraph now omits both objects entirely (as keys, not just zeroed) unless a new `sizing` querystring is present; the route casts its presence to a boolean and passes it through. Graph.vue sends its active "Size by" mode as the value on every (re)load, but the backend gates on presence alone -- both objects always come back together -- since the sizing-mode toggle switches client-side with no refetch. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Cap the graph node set server-side, report truncated/totalNodes (#1866) assembleGraph() previously returned every readable page with no bound -- multi-megabyte JSON and a multi-second client-side force layout on a wiki of a few thousand pages, with no degradation path. Adds GRAPH_NODE_CAP (2000), a deterministic path-sorted cap on the retained node set, and extends the response shape to { nodes, edges, truncated, totalNodes }. Edges are rebuilt from the capped set so no returned edge references a dropped node. Graph.vue's loadGraph() reads the two new fields into refs (the truncation-notice UI itself is OpenProject #1875). Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Paginate pageHistory.list with a versionDate keyset cursor, drop authorEmail GET /sites/:siteId/pages/:pageId/history no longer returns every version of a page in one unbounded query -- pageHistory.list() now keyset-paginates on (versionDate, id) descending, returning { items, nextCursor } instead of a bare array, with an opaque base64url cursor and a 50-row default / 200-row cap. OFFSET was deliberately avoided: it degrades exactly on the deep histories this exists to protect, while the existing pageHistory_pageId_idx (pageId, versionDate) index serves the keyset seek directly. list()'s projection also drops authorEmail -- confirmed no frontend consumer reads version.author.email (PageHistoryOverlay.vue only reads .author.name) -- via a new PageHistoryListEntry/PageHistoryListAuthor type and matching PageHistoryListEntry/PageHistoryList API schemas, distinct from the unchanged PageHistoryEntry/PageHistoryVersion used by getVersion()/listRecoverable(). PageHistoryOverlay.vue now reads { items, nextCursor } and appends further pages via a "Load older versions" control rather than assuming the whole history arrives in one response. OpenProject #1859 Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Batch refreshDescendantPaths write-back into chunked VALUES joins (#1865) refreshDescendantPaths used to issue two sequential UPDATE statements per descendant row (one on tree, one on pages for page-type rows) inside the transaction renameFolder opens -- a folder with a couple thousand descendants meant thousands of round trips with row locks held throughout. Keep the per-row hash/path computation in JS (genuinely row-by-row, per the existing doc comment) but batch the write-back: split the computed updates into chunks of TREE_UPDATE_CHUNK_SIZE (200) and issue one UPDATE ... FROM (VALUES ...) per chunk instead of one UPDATE per row. tree.hash has not been dropped from the schema yet, so both the tree and pages write-backs are still needed here. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Memoize templated app shell per (lang, isRTL), keyed on shell mtime setNotFoundHandler's SPA-shell fallback read assets/index.html and called WIKI.models.locales.getLocales() on every single request, then re-ran templateAppShell's regex substitution -- all for output that only varies across a handful of (lang, isRTL) pairs. getTemplatedAppShell (backend/helpers/appShell.ts) now memoises that templated output per lang, gated on the shell file's mtimeMs so a live `npm run build` still invalidates it. resolveIsRTL (which wraps getLocales()) and the file read only run on a cache miss -- a new lang, or the first request after a rebuild -- taking getLocales() off the hot path for the common case of an already-seen lang. Cache-Control: no-store on the response is unchanged; this is server-side work avoidance only. Ref: OpenProject WP #1869 Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Paginate the site-wide listRecoverable deleted-pages query (#1862) listRecoverable scanned every recoverable deletion for a site in one unbounded DISTINCT ON query, with the read:history permission filter running in JS afterwards at the route -- the worse of the two unpaginated history reads per the 2026-08-24 performance audit. Wrap the DISTINCT ON collapse in a derived subquery and keyset-paginate the outer query on (versionDate, id) descending, the same cursor shape sibling WP #1859 is adding to pageHistory.list. Since the permission filter still has to run in JS after the DB page comes back, nextCursor is computed from the raw DB page boundary before that filter runs, so a page shortened by filtering is never mistaken for the end of the list -- the route just forwards the model's cursor through unchanged. Updates the three existing bare-array callers (two backend test files, one admin Vue page) to the new { items, nextCursor } shape; the admin deleted-pages view now assembles its full list from bounded pages via a client-side cursor loop instead of one unbounded call. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Show remaining TFA recovery-code count in ProfileAuth.vue (#1874) GET /users/profile/tfa/recovery-codes was a finished, tested backend route with no caller. Fetch it for every local-strategy auth method with 2FA active once the profile auth list loads, and render an "N of M recovery codes remaining" line with a visible nudge once the remaining ratio drops to 20% or below. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Show a truncation notice in Graph.vue when the response is truncated (#1875) Graph.vue's loadGraph() now captures the graph endpoint's truncated/ totalNodes fields into refs, and a persistent, non-dismissable overlay notice tells the reader when the view is showing a subset of pages and that filters apply only to that subset -- placed as a plain sibling of the canvas so it stays visible while panning/zooming. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Set explicit pool.max/connectionTimeoutMillis defaults, document pool: (#1883) base.yml's pool default was just min: 1, so node-postgres' own max (10) and connectionTimeoutMillis (0, no timeout) applied silently, and config.sample.yml had no pool: section for an operator to find. Adds explicit max: 20 (headroom above the 3 permanently-held LISTEN connections: event bus, scheduler, collab) and connectionTimeoutMillis: 5000, documents the section in config.sample.yml, and extracts the Pool-options construction in core/db.ts into a pure buildPoolOptions() so the defaults, an operator override, and worker mode's forced { min: 0, max: 1 } are unit-testable without a real DB connection. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Add tag management mode (rename/merge/delete) to TagsBrowse.vue Gate a management toggle on manage:pages (userStore.pagePermissions), listing every tag with inline rename and delete controls. Rename onto an existing tag's name is treated as a merge. Both mutations confirm first, naming the affected-page count and noting that pages the actor cannot manage are left untouched, then refresh the tag list and current search results on success. Calls the PATCH/DELETE sites/:siteId/tags/:tag routes from sibling work package #1873 (PATCH body { newTag }, response { affected }) -- that backend work is not yet implemented in this checkout, so the feature is inert until it lands, but the frontend is built and tested to that contract. OpenProject #1877 Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Add /_admin/:siteid/pages inventory (#1880) Builds a server-paged admin page inventory on the existing GET sites/:siteId/pages/search route (path/locale/tag/editor/publishState filters, totalHits) rather than a new backend endpoint -- the replacement for what the deleted GraphQL AdminPages.vue/AdminTags.vue used to cover, since /_search caps out at 100 rows with no per-row action. Wires it into the site nav beside the existing pages/deleted (recovery) entry, registers the :siteid/pages route, and points the dashboard's pagesTotal tile at it instead of the site root. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Add PATCH/DELETE /sites/:siteId/tags/:tag with per-page manage:pages enforcement Rename (and, via the same collapse-on-collision mechanism, merge) and delete a tag across every page that carries it. manage:pages is a page rule permission, so it's checked per affected page in the handler rather than declared as a route permission; a page the caller can't manage is left untouched instead of failing the whole call. tree.tags is kept in sync alongside pages.tags, and every touched page is handed to WIKI.models.search.updated so a rename shows up in search immediately. OpenProject #1873 Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Load PageNewMenu's import dialogs asynchronously (OpenProject #1884) ImportBatchPageDialog.vue statically imports the full markdown authoring pipeline (markdown-it + plugins, katex, highlight.js), which was pulled into every reader's static bundle via PageNewMenu.vue's top-level import even though almost nobody clicks the import-page menu items. Both dialogs now load through defineAsyncComponent(() => import(...)), matching the pattern PageActionsCol.vue already uses for its own dialog() call sites. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Push canRead filtering into listAllForGraph's SQL WHERE (OpenProject #1872) listAllForGraph used to fetch every page on a site and let assembleGraph's canRead predicate discard what the caller may not read after the fact, so a low-privilege reader paid the same DB cost as an admin. helpers/pageRules.ts gains deriveReadScope(), which reduces an actor's pooled non-DENY rules for a permission into a safe superset condition (exact/prefix/suffix path, locale-scoped, or classification-id clauses, OR'd together) -- a page that fails every clause can never be granted, so it is safe to exclude from the fetch. Rule shapes that cannot be reduced without risking under-fetching (REGEX, TAG/TAGALL's case-folded matching, or a rule that already addresses the whole site) collapse the whole scope to "all", which is exactly today's unrestricted fetch. listAllForGraph(siteId, actor) now builds its WHERE from that scope: a manage:system actor and a 'none'/'all' scope keep today's behavior exactly, a scoped API key missing read:pages from its own scope returns [] before ever querying, and a 'clauses' scope pushes the OR of LIKE/eq/inArray conditions into SQL. assembleGraph's own canRead resolution is unchanged and still runs in full over whatever rows come back, so the narrowing only ever has to be a safe superset, never the exact answer. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Add offline locale-pack sideload control to AdminLocale.vue (#1886) POST /_api/locales/sideload already existed (manage:system-gated, rescans <dataPath>/locales/ for operator-placed JSON packs) but had no caller in the UI. Wires the existing admin.locale.sideload / sideloadHelp strings to a trigger button, gated separately on manage:system since it's stricter than this page's own site:locale access, and surfaces loaded/skipped/error results via notify() before refreshing the locale list. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Move the three permanently-held LISTEN clients onto their own pool (#1887) The event bus, scheduler, and collaborative editing each check out a PoolClient via pubsub.ts's connectListener and hold it for the process lifetime, previously from the main query pool -- silently reducing the effective application-query ceiling by 3. None of them ever runs an application query, so give them a small dedicated pool of their own (helpers/pubsub.ts's createListenerPool, min:0/max:3), built once in core/db.ts#init() and shared as WIKI.dbManager.listenerPool. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Register v-network-graph and its stylesheet locally in AdminStorage.vue (#1888) v-network-graph was registered globally in boot/components.js and its stylesheet globally in css/app.scss, putting a 46 kB gzipped chunk and 103 .v-ng-* CSS selectors into the entry bundle for every visitor, even though AdminStorage.vue's delivery-path diagram is the sole consumer. Register the component locally off AdminStorage.vue's existing `import * as VNG from 'v-network-graph'` namespace import, and move the stylesheet import into the page's own <style> block (kept unscoped, since the library renders deeply-nested elements a scoped selector rewrite would not reach). Extends AdminStorage.test.js with a mount-based test proving the diagram still renders. Verified via `npm run build`: the v-network-graph chunk and its modulepreload link are gone from assets/index.html's entry graph, and all 103 .v-ng-* selectors moved from main-*.css into AdminStorage-*.css. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Decide OUT for page ratings and Page Data / Page Data Templates (WP #1890) Both are fully-toggled but entirely inert: ratings have no wired handlers, no backend route, and a mistyped ratingCount column; Page Data's two dialogs have no persistence and an unreachable entry point. Neither is worth completing over cutting -- ratings duplicate the existing comments system with no confirmed demand, and Page Data's only real product backing (WIKI3_ASSESSMENT.md idea #1) describes a different design than what exists. Records both decisions in docs/variances.md; the carry-out children (#1907, #1903, #1911) are updated separately in OpenProject to name the OUT branch. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Version glossary per-term CRUD writes (OpenProject #1891) createTerm/updateTerm/deleteTerm now record a glossary_versions snapshot in the same transaction as the write, whenever an actor is given -- closing the gap where an API-key client's direct per-term edit was invisible to a later "restore previous version" and would be silently reverted. The four per-term REST routes stay as a legitimate programmatic surface (decision recorded on the work package): they have no in-repo caller, but are fully permissioned, documented, and cheaper for an external client to use for a single term than round-tripping the whole export/import payload. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * feat: admin page inventory with row selection and bulk delete/render/retag (#1882) Adds POST /sites/:siteId/pages/bulk (delete/render/retag), permission-checked per page so a denied page is reported as skipped rather than failing the whole batch -- unlike the classification-conflicts-resolve route's all-or-nothing precedent. Retag is add/remove-relative against each page's own existing tags. Also builds the /_admin/:siteid/pages inventory itself (#1880's own scope), since that dependency had not landed yet: server-side paged/filtered list with per-row and select-all-on-page selection driving the new bulk endpoint, wired into AdminLayout's nav and AdminDashboard's pagesTotal tile. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Add batched parent-classification lookup to models/pages.ts (#1897) parentClassification() issued one query per call, and the classification-conflicts resolve route (api/pages.ts) calls it once per submitted page id. Add parentClassifications(), resolving the immediate-parent floor for a set of (locale, path) pairs in a single query over the distinct ancestor paths, returned as a map keyed by input path. The single-page method now delegates to it so its three existing callers (resolveCreateClassification, updatePage, movePage) keep their exact behavior, including the optional tx parameter movePage threads through. Covered by two new DB-backed cases in models/pages.test.ts: a mixed set of paths whose ancestors differ (including one with no classified ancestor at all) matching the per-call result, and a same-named parent path in two different locales proving the query is scoped per locale rather than matching locale/path independently. Wiring this into api/pages.ts's resolve loop is out of scope here -- that is OpenProject #1902, a sibling child of #1894. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Wire the two undecided caller-less routes: watch PATCH and storage setup POST (#1895) Both `PATCH .../pages/:pageId/watch` and `POST .../storage/targets/:targetId/setup` already had working, tested backend implementations with no frontend caller. Rather than delete them as dead code, this adds the missing UI for each: - InboxWatching.vue gains a per-page notification-preferences menu (delivery mode + edited/moved/deleted toggles) that PATCHes the existing watch route. - AdminStorage.vue's Setup card now also renders while a target's setup is not yet configured, with a "Start/Continue Setup" button that POSTs the next step via a new nextSetupStepName() helper. Unlike the previously-removed GitHub App flow (task 509), this stays module-agnostic -- no module-specific behavior is added, only the generic step-driving mechanism the backend contract already supports, and its DELETE twin (Uninstall) already had a real caller that this keeps reachable. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Fix post-merge lint/format: drop unused AccessActor import, reformat pageRules.test.ts * Fix post-merge ReferenceError: userStore undefined in TagsBrowse.vue wp-1877-tag-management-mode's canManageTags computed referenced userStore without importing/instantiating useUserStore(), which broke every TagsBrowse test (18/18 failing) after merge. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Fix duplicate apiErrorMessage import after merging wp-1886-sideload-locale-upload The merge left two identical import statements for apiErrorMessage in AdminLocale.vue, tripping oxlint's no-duplicate-declaration check. Co-Authored-By: Claude Sonnet 5 <[email protected]> * Drop ratingScore/ratingCount columns from pages table Ratings were decided OUT (OpenProject #1890, Epic #1885): every consuming surface is dead code with no backend route, so carry that decision through the schema rather than fixing ratingCount's mistaken timestamp type. Drops both columns with a generated migration, and unpicks the propagated wrong-type fallout: pageHistory.ts's version- diffing exclusion sets no longer name either column, and the azure-search test fixture no longer fakes a Date to satisfy the old ratingCount type. OpenProject #1907 Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Import highlight.js/lib/common at both hljs call sites (#1901) frontend/src/renderers/markdown.js and EditorCodeBlockMenu.vue both imported the highlight.js package root, which registers every one of the ~190 grammars the package ships into a module-singleton registry shared by both. Switch both to highlight.js/lib/common (~36 languages) together, so neither call site leaves the full set bundled/registered for the other. Re-document EditorCodeBlockMenu.vue's header comment to describe the actual (now-trimmed) registered set, and add coverage in markdown.test.js for a retained language still highlighting and a trimmed-but-real language falling through to escaped plain text. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Document backup/restore scope and container mounts in docs/operations.md Adds docs/operations.md stating the honest two-source recovery scope (a pg_dump of the Postgres database plus the dataPath filesystem volume and config.yml), the restore order, and the container mounts the image expects at /wiki/data, covering the five writer subdirectories (locales/, cache/icons, cache/files, exports/, imports/) rather than just content/. Links the doc from README.md and adds a structural test asserting the document exists, is linked, and names every subdirectory the models actually write, so it cannot silently drift from the code. Resolves OpenProject #1900 (part of Epic #1892). Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Add a multi-row record path to models/auditLog.ts recordMany() writes N audit log entries in one INSERT instead of N sequential record() calls, for callers that would otherwise loop record() once per item (starting with the classification-conflict resolve route, OpenProject #1894/#1902). An empty array is a no-op that issues no statement. OpenProject #1899 Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Remove Page Data / Page Data Templates (OpenProject #1911) Branch: OUT, per #1890 -- the two dialogs, the store slot and the disabled rail entry point are deleted rather than built out. - Delete PageDataDialog.vue and PageDataTemplateDialog.vue - Drop the pageDataTemplates state slot from stores/site.js - Remove the disabled "Page Data" rail button and togglePageData() from PageActionsCol.vue - Drop the dead async component registration from SideDialog.vue - Reword stale doc comments in SideDialog.vue and WTabs.vue that referenced the deleted PageDataDialog - Strip the 27 editor.pageData.* keys from every backend/locales/*.json file (Localazy propagates the same key set to all locales) - Add regression coverage: the store no longer exposes pageDataTemplates, and the rail never renders a Page Data button even with the experimental flag on Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Decide multi-arch publishing: amd64-only, both workflows agree (#1916) build.yml carried a commented-out linux/amd64,linux/arm64 platforms line inherited from upstream with no fork decision behind it. Deletes it, records the amd64-only decision and QEMU-cost reasoning in docs/variances.md, and adds a structural test asserting build.yml and release.yml's platforms: values match and neither carries a commented-out platform line. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Cache locale strings in WIKI.cache, invalidate in reloadCache() getStrings() ran a fresh SELECT on every call, unlike getLocales() immediately above it — a ~190 KB JSONB read paid on every cold page load. Cache per code under localeStrings:<code>, mirroring the locale:<code> shape getLocales() already uses, and drop every known code's entry in reloadCache() (the existing single invalidation point, called from sideloadFromDataPath) so a sideloaded pack is visible on the next call. OpenProject #1915 Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Add GET /_api/sites/:siteId/pages/:pageId/backlinks (OpenProject #1914) Returns pages whose extracted internal links (models/rendering.ts's extractInternalLinks, stored in the pages.links jsonb column) target the requested page, via a single jsonb `@>` containment query -- the same pattern models/classificationLevels.ts already uses. Each candidate row is filtered through mayOnPage(req, 'read:pages', siteId, row), the same per-row permission check api/graph.ts uses for graph edges, so a linking page the caller may not read is silently dropped rather than counted. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Answer GET /locales/:code/strings with an ETag and a 304 (#1920) Computes a sha1 ETag over the strings payload, honors if-none-match with an empty 304, and sends Cache-Control: public, no-cache so browsers revalidate rather than caching stale strings past a locale sideload. Documents the response headers on the route schema. Co-Authored-By: Claude Sonnet 5 <[email protected]> * Make docsBase a server-provided setting from base.yml (#1922) siteStore.docsBase was a hardcoded literal (https://docs.js.wiki) that nothing ever assigned, and didn't match README.md's own documentation link. Add it to base.yml, surface it on buildSitePayload() alongside pdfExportAvailable (so it reaches both sites/:siteIdorHostname and bootstrap for free), and delete the frontend literal -- the value now always arrives from the server, so no fallback default is wanted. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Add optional structured context to core/logger.ts JSON mode logger.ts formatted exactly four fields in JSON mode (timestamp, instance, level, message), with message an already-built string - opaque to an aggregator. Level methods now accept an optional second context object, merged into the JSON payload as siblings of message. Context is spread before the four fixed fields so it can never clobber them, and is ignored entirely in text mode. A context-free call's JSON output stays byte- identical to before. OpenProject #1934 Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Remove page ratings UI/config entirely (OpenProject #1903) #1890 decided page ratings are out of scope. Carries that decision through the admin, author and reader surfaces: drops the ratingsMode control from AdminGeneral.vue, the allowRatings toggle from PagePropertiesDialog.vue, the stars/thumbs reader block and dead currentRating state from Index.vue/SideDialog.vue, and the matching allowRatings/ratingsMode fields from the page and site stores. Backend: removes allowRatings from the page config shape (models, schema, history restore) and ratingsMode from the site config shape (seeds, schema), along with the now-orphaned locale strings across all 56 locale files. No db migration needed -- both fields live in JSONB config blobs, not real columns. Also cleans up a features.ratings leftover in AdminGeneral.vue's default config that #1893 missed on the frontend side. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Build the backlinks side dialog and enable View Backlinks (OpenProject #1917) Adds PageBacklinksDialog.vue, a side-dialog panel fetching GET sites/:siteId/pages/:pageId/backlinks (OpenProject #1914, sibling WP not yet landed) and listing each source page as a link to its path, with an empty state when none exist. Registers it on SideDialog's sideDialogComponent map and wires PageActionsCol's "View Backlinks" menu item to open it, dropping its disabled attribute and experimental-flag gate. hasPageActions is updated to reflect that the "..." Page Actions menu can no longer come up empty now that Backlinks is unconditional -- previously it gated the whole menu on the experimental flag or Rerender Page's own availability, which would have made the newly-unconditional entry unreachable for an ordinary reader. Convert Page's own gate, and deleting hasPageActions once Convert Page itself goes, is left to OpenProject #1921. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Declare and emit approval:* webhook events (OpenProject #1932) Adds approval:submitted, approval:approved and approval:rejected to HOOK_EVENTS/EMITTED_EVENTS and wires the emit() calls into models/approvals.ts's saveSubmission/approveSubmission/rejectSubmission, beside their primary writes, following the page:*/comment:* convention. rejectSubmission now takes an actor param so it can record who declined a suggestion and carry that into the event payload. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Emit page:classification-changed, guarded on the level actually changing (OpenProject #1935) Declares the event in HOOK_EVENTS/EMITTED_EVENTS and emits it from updatePage() alongside page:edit, but only when the incoming classification differs from the page's current one -- a patch that merely restates the current level (the editor sends every field on every save) must fire nothing, since a webhook on a no-op change is worse than no webhook for the compliance integrations this event exists for. Payload carries id/path/locale/siteId plus previousClassification/classification, matching page:rename's previousX/x convention. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Delete help buttons pointing at fork-only docsBase concepts (OpenProject #1929) Sweeps the 12 docsBase-based help buttons whose deep path names a concept that exists only in this fork (classification, glossary, approvals, feature flags, multi-site admin, cluster monitoring, in-browser terminal, metrics, scheduler, custom blocks, table editor, dev API) -- no docs site, upstream or this fork's own eventual one, can describe a concept this fork invented, so the dead buttons are removed rather than left to 404. The remaining ~30 docsBase usages describe concepts inherited from upstream Wiki.js and are left in place. GroupEditOverlay.vue's three links are WP #1925's separate scope and are untouched here. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Fix pre-existing selector bug in AdminGeneral hostname-rename tests wrapper.get('[aria-label="Site Hostname"] input') looked for a descendant input inside an element carrying that aria-label, but WInput.vue puts the aria-label directly on the <input> itself. Drop the trailing " input" so the selector matches the element it's actually on. Found while merging wp-1903-remove-ratings-surfaces -- unrelated to that change, but in a file the merge touched. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Decide comment providers: unavailable, not codeTemplate embed (#1953) Records the product decision in docs/variances.md: reverse the codeTemplate/isSelectable() porting for Disqus/Commento/Artalk rather than build the vendor-embed render path. Nothing consumes the stored choice today, and standing up third-party script embeds on every page view is a bigger trust-boundary commitment than restoring a picker option nobody currently depends on. Carry-out (isAvailable:false on the three definition.yml files, dropping codeTemplate from isSelectable()) is #1958; the stored-provider dead-end guard is #1962. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Record decision: ship diagram rendering as an MCP tool (WP #1944) Decides the ship-or-retire question from Epic #1941 for backend/api/diagrams.ts and backend/models/diagramRender.ts: ship a render_diagram MCP tool (carried out in #1946) rather than retire the published, Swagger-documented endpoint. Auth, rate limiting, size caps and timeouts already exist in the model, so the tool wrapper is a thin adapter; no docs/variances.md entry is needed since nothing is removed. No application code changes — this WP is decision-only. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Add failed-job and db-pool gauges to /metrics (WP #1939) Revisits the /metrics scope decision (task 594): the answer is to extend, not hold. Adds wikijs_jobs_failed_total (jobHistory rows currently in the failed state -- not a lifetime total, since cleanJobHistory prunes on a retention window) and three wikijs_db_pool_* gauges read off WIKI.dbManager.pool. Everything stays a plain gauge in the existing hand-rolled writer, so the original no-prom-client rationale still holds; only the gauge count grows from six to ten. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Add dev/setup.sh clone-to-running script for non-devcontainer path A contributor on the non-devcontainer path had to run four npm installs, a config copy and two builds by hand -- the same sequence .devcontainer/app-init.sh already automates for the container path. dev/setup.sh does the same for everyone else: installs backend/frontend/ blocks/e2e, creates config.yml from config.sample.yml only if absent, and builds frontend and blocks. Idempotent and safe to re-run. README's Generic Setup now points at it instead of duplicating (and drifting from -- it still referenced the removed ux/ workspace and node server) the command list. OpenProject #1966 Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Ship render_diagram as an MCP tool (WP #1946) Decision (see #1946's implementation-plan comment): ship rather than retire — #1941's own framing treats shipping as the default path, and no deliberate retirement decision was ever recorded on the decision child (#1944). Adds backend/mcp/tools/renderDiagram.ts, delegating to the existing WIKI.models.diagramRender so its size caps and CustomError throws (missing Puppeteer, offline PlantUML, oversized source) carry through unchanged, mapped onto McpToolError the same way createPage/updatePage already do. Applies the same RENDER_LIMIT rate-limit policy the REST route's limitRenders preHandler uses (now exported from helpers/rateLimit.ts), keyed by the caller's userId where available so a personal access token shares its budget with the web UI. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Document config precedence, $(ENV:default), and pool/cache tunables (#1976) config.sample.yml never mentioned the $(VAR:default) substitution parseConfigValue applies to it, the three-source precedence between base.yml, config.yml and the DB settings table, or the pool/ files.cacheMaxSize tunables. Adds a header block covering all three, naming the DB-owned key groups from models/settings.ts and the effective defaults (pg's own max of 10, cacheMaxSize's 512MiB). Also fixes docs/tls-termination.md's step 3, which told operators to set trustProxy in config.yml — the seeded security settings row overwrites that on every boot, so the admin Security page is named as the way that actually sticks. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017B1i5Z6Bq7ZwwCAmjibZob * Mark Disqus/Commento/Artalk unavailable, drop codeTemplate from isSelectable (#1958) Carries out the decision left unresolved on sibling WP #1953: no page-view code has ever rendered a codeTemplate provider's embed, and building that render path turned out to need a new public per-page-permission-gated API plus vendor-specific glue for three SDKs -- far more than the config flip it looked like for a low-severity product-honesty wart. isSelectable() now gates on hasImplementation alone; the three definition.yml files declare isAvailable: false, which AdminComments.vue already renders as a disabled, unselectable row. docs/variances.md records the reversal of the 2026-08-18 decision that ported codeTemplate in the first place. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Sessio…
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This pull request adds support for https://kroki.io/ that can be used to generate diagrams from many different kinds of formats. It can be used as a replacement of the already existing PlantUML renderer and many other formats like GraphViz, Nomnoml, Mermaid and others.
The implementation is based on the PlantUML renderer. There's an important difference in the way it can be used: in the first line of the diagram definition the selected format must be entered.
Markdown Example 1:
Markdown Example 2: