Compare commits

...

40 Commits

Author SHA1 Message Date
Tom Moor 517b0fb3ec Use CSS highlights instead of editor decorations when available 2026-04-01 20:35:35 -04:00
Tom Moor c3c5f148b7 Add Node LTS auto-update script (#11927)
* Add Node LTS auto-update script

* fix: Validate LTS version and update CI step name in Node update workflow

Add semver validation for the fetched LTS version to prevent creating
PRs with invalid node versions (e.g. null) if the upstream API changes.
Also update the human-readable step name in ci.yml during major bumps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 19:47:40 -04:00
Copilot 0d0f5cb5c7 Fix Tab key not indenting list items inside toggle blocks (#11914)
* Initial plan

* fix: allow Tab to indent list items inside toggle blocks

When the cursor is inside a list within a toggle block, the indentBlock
command was consuming the Tab key event before the list's sinkListItem
handler could run. This happened because indentBlock found a previous
container_toggle sibling at the ancestor level and returned true.

Fix: return false early in indentBlock when the cursor is inside a list,
allowing the list's Tab handler to handle indentation correctly.

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-04-01 18:51:35 -04:00
Tom Moor af22ed4d06 fix: Search highlight lag on shared documents (#11926)
Re-highlight result context client-side using the current search query
instead of relying on server-generated <b> tags. This prevents stale
highlights when results from a previous query are still displayed while
a newer search is in-flight. Also guards against setting stale results
by checking the query ref before updating state.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 18:47:49 -04:00
Copilot 864ec3e24b Fix @mention trigger not firing after CJK characters (#11919)
* Initial plan

* Fix mention trigger to work after CJK characters without preceding space

Agent-Logs-Url: https://github.com/outline/outline/sessions/b34bba3f-fe94-408c-bf09-794f8e3d05ff

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-01 22:35:03 +00:00
Tom Moor db953c8b2f fix: Update Docker GitHub Actions to support Node.js 24 (#11925)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 18:22:21 -04:00
Copilot c4479e257e chore: upgrade Node.js to 24.14.1 (LTS) (#11918)
* Initial plan

* chore: upgrade Node.js base image from 22.21.0 to 24.14.1 (LTS)

* chore: include node version in node_modules cache keys

* Add canary build for docker changes

* fix: Try docker driver

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2026-04-01 17:47:01 -04:00
Tom Moor 222de9ef01 fix: Unconnected integrations appearing in settings sidebar (#11913)
* fix: Integrations list missing when language is not English

The group filter on the Integrations settings page compared against the
hardcoded string "Integrations" instead of the translated value from
t("Integrations"), causing no integrations to appear in non-English locales.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: Sidebar shows unconnected integrations in non-English locales

Same hardcoded "Integrations" string comparison issue as the main
integrations page — the sidebar filter skipped the connected-check
when the translated group name didn't match the English literal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 08:12:05 -04:00
Tom Moor 6e95aa441b feat: Add context menus to document breadcrumb items (#11910)
Wrap collection and document names in the header breadcrumb with
ContextMenu components, enabling right-click menus with relevant
actions. Each breadcrumb item type has its own component to scope
hooks. Breadcrumb links prevent navigation when a context menu is open.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 19:44:44 -04:00
Tom Moor b70950627e Preload share popover data on hover (#11909)
* Preload share popover data on hover with useShareDataLoader hook

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: Route programmatic closes through handleOpenChange and fix race conditions

- closePopover now calls handleOpenChange(false) so reset() fires on all
  close paths, including programmatic closes via onRequestClose
- Reset requestedRef when entity id changes so preload fires for new targets
- Use request counter to prevent stale loading state when reset() is called
  during an in-flight request

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 19:44:37 -04:00
Tom Moor e354db8164 feat: Add support for Docker Swarm style secrets (#11906)
* feat: Add support for Docker Swarm style secrets

* fix: Handle empty-string env values and bare _FILE key in resolveFileSecrets

Use undefined check instead of truthiness so empty-string values are
treated as "already set" and not overridden by _FILE variants. Skip
processing when the key is exactly "_FILE" to avoid creating an
empty-key entry.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 19:42:20 -04:00
Tom Moor 7f6ec4ae31 fix: Integrations list missing when language is not English (#11908)
The group filter on the Integrations settings page compared against the
hardcoded string "Integrations" instead of the translated value from
t("Integrations"), causing no integrations to appear in non-English locales.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 18:58:32 -04:00
Tom Moor 701d4bb6ee fix: Present mode slide content not vertically centered (#11901)
* fix: Present mode slide content not vertically centered
2026-03-29 16:42:30 -04:00
Tom Moor 032d5c6b95 fix: Remove archived document from sidebar immediately (#11900)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 16:41:11 -04:00
Tom Moor 33b9a52dfe fix: Empty drafts are not correctly cleared on tab quit (#11899)
dquote> fix: Existing drafts should not focus the editor
2026-03-29 10:36:43 -04:00
Tom Moor 4b16545b10 Fix Comment.toPlainText using wrong schema for mention nodes (#11889)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 15:51:05 -04:00
Copilot 27dc02aad1 Add anchor text to MCP comment tool responses (#11886)
* Initial plan

* Add comment anchor text to MCP comment tool responses

Agent-Logs-Url: https://github.com/outline/outline/sessions/294b6510-996f-4a86-a7d6-7ed1c336fc19

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Address PR review: fix auth gap, cache marks, add anchorText tests

- Always authorize read access in update_comment before exposing anchor text
- Cache comment marks per document in list_comments to avoid O(n * docSize)
- Add 4 MCP tests verifying anchorText presence/absence in responses

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 15:08:34 -04:00
Copilot df5dd0b98d Fix custom team logo not appearing in link previews for public shares (#11872)
* Initial plan

* fix: resolve team avatar to signed URL for public share link previews

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Agent-Logs-Url: https://github.com/outline/outline/sessions/3632734e-1bb5-4705-bdcd-a2ccbb211af8

* refactor: move avatar URL resolution to Team.publicAvatarUrl()

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Agent-Logs-Url: https://github.com/outline/outline/sessions/a2191be3-0533-459a-8366-602bb798a60e

* test: add Team.publicAvatarUrl model tests; update JSDoc

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Agent-Logs-Url: https://github.com/outline/outline/sessions/7609501c-a4d1-44ea-a7bf-fa6fd8e7c999

* test: fix Team.publicAvatarUrl tests to use actual attachment URLs

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Agent-Logs-Url: https://github.com/outline/outline/sessions/0a768f8b-0dd8-4e7a-a50d-873af58aab28

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-03-27 19:55:15 -04:00
Copilot 3cc85f1cdf Fix DocumentMove dialog hiding siblings and nieces/nephews as move targets (#11885)
* Initial plan

* fix: show siblings and descendants in DocumentMove dialog

The filterSourceDocument function was incorrectly removing the document's
parent node from the navigation tree, which also hid all siblings (children
of the same parent) and their descendants.

Instead, only the document itself and its own descendants are now excluded
(to prevent circular references). The parent is kept in the tree so siblings
remain visible as valid move targets.

Agent-Logs-Url: https://github.com/outline/outline/sessions/12574f1c-7a7c-45a0-8444-19e24aa10782

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-03-27 17:05:21 -04:00
Tom Moor 0b213bd6b8 feat: Map document creator to existing users during JSON import (#11879)
* feat: Map creator/updater IDs to existing users during JSON import

When importing documents from JSON, resolve the original document author
to an internal user by matching on user ID first, then email, falling
back to the importing user. Results are cached to avoid redundant queries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: Add negative caching for user resolution during import

Cache misses (not just hits) in resolveUserId so that repeated lookups
for users that don't exist in the target team are served from cache
instead of hitting the database for every document.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: Fix resolveUserId JSDoc to match actual behavior

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 08:42:32 -04:00
Tom Moor c91b839d22 fix: Unable to resize imported image from docx (#11878)
* fix: Mammoth converts docx images to <img> tags with base64 data URIs but no width/height attributes

* fix: Bound memory usage and prevent infinite loop in image dimension parsing

Decode only a 64 KB prefix of base64 data URIs instead of the full payload,
cap the JPEG marker scan at 64 KB, and bail on malformed segment lengths
(< 2 or overflowing the buffer) to prevent an infinite loop on truncated data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 08:42:24 -04:00
Tom Moor 45b2f6e222 fix: read-only scoped API keys cannot access MCP (#11875) 2026-03-26 00:15:28 -04:00
Tom Moor b91d9e9a72 feat: Extract search into pluggable provider system (#11448)
* feat: Extract search into pluggable provider system

Refactors the monolithic SearchHelper into a pluggable search provider
architecture, enabling alternative search backends (Elasticsearch,
Turbopuffer, etc.) while preserving PostgreSQL full-text search as the
default. The SEARCH_PROVIDER env var selects the active provider.

- Add BaseSearchProvider abstract class and SearchProviderManager
- Add Hook.SearchProvider to the plugin system
- Move PostgreSQL search logic into plugins/postgres-search/
- Add SearchIndexProcessor for event-driven index sync
- Update all callers to use the provider manager directly
- Keep SearchHelper as a deprecated thin wrapper for backwards compat

Closes #11347

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: Remove deprecated SearchHelper wrapper

All callers now use SearchProviderManager directly, so the thin
delegation wrapper is no longer needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: Rename postgres-search plugin to search-postgres

Renames the plugin folder and id so that future search provider plugins
(e.g. search-elasticsearch, search-turbopuffer) will be colocated
alphabetically.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: Remove special-case plugin import from SearchProviderManager

Make PluginManager.loadPlugins resilient to individual plugin load
failures so SearchProviderManager can use the standard getHooks path
without needing to directly import the search-postgres plugin.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: Add missing search provider tests for full coverage parity

Adds all tests that existed in the old SearchHelper.test.ts but were missing
from PostgresSearchProvider.test.ts, including searchTitlesForUser status
filters, collection filtering, group memberships, and sorting tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feedback

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 23:01:26 -04:00
Tom Moor 979d9a412d Mermaid improvements (#11874)
* fix: Upgrade mermaid to 11.13.0

Includes a fix for incorrect viewBox casing in Radar and Packet diagram
renderers (mermaid-js/mermaid#7076) and other improvements.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: Use visibility:hidden for mermaid rendering element

Instead of positioning the temporary render element offscreen at
-9999px, use visibility:hidden with position:fixed so the browser
computes correct bounding boxes for SVG elements. Offscreen elements
can produce incorrect getBBox() results, leading to wrong viewBox
dimensions and diagrams rendering too big or too small.

Fixes #11782

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add session storage for generated diagrams to reduce relayout

* fix: Use LRU eviction for mermaid sessionStorage cache

Track access order via a dedicated LRU index key so the cache evicts
least-recently-used entries rather than arbitrary ones.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 22:59:57 -04:00
Tom Moor c2ccdb6fd4 fix: Prevent registration of duplicate passkeys on the same device (#11870)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 22:54:43 -04:00
Tom Moor 793804cd0d feat: Strip comments from presentation mode (#11860)
* feat: Strip comment marks from documents in presentation mode

Move removeMarks to shared ProsemirrorHelper and use it to strip comment
marks before rendering slides. Make server ProsemirrorHelper extend the
shared class to eliminate duplication and remove SharedProsemirrorHelper
imports from server code.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: Use Set for mark lookup and cloneDeep for browser compat

Use a Set for O(1) mark lookups in removeMarks traversal. Replace
structuredClone with lodash/cloneDeep to support older browsers
that lack the native API.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: Add tests for ProsemirrorHelper.removeMarks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 20:21:43 -04:00
Copilot f1e5a7cfa7 Fix passkey login 400 error when authenticatorAttachment is undefined (#11856)
* Initial plan

* Fix passkey login 400 error when authenticatorAttachment is undefined

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Agent-Logs-Url: https://github.com/outline/outline/sessions/b7ea5777-cd06-41e7-a796-70ea083dfc34

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
2026-03-23 18:54:13 -04:00
Tom Moor 84aed78ee2 fix: Improve performance when editing titles in large open document trees (#11858) 2026-03-23 18:53:37 -04:00
Tom Moor 33d8e41e41 fix: Sub-table header sticky behavior (#11857) 2026-03-23 18:53:34 -04:00
Tom Moor 7dc1d12d3b feat: Support simplified mention syntax in markdown for MCP (#11851)
* feat: Support simplified mention syntax in markdown for MCP clients

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Restore translations

* PR feedback

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 08:08:24 -04:00
Apoorv Mishra 0e978e1e34 feat: highlight commented images (#11808) 2026-03-22 22:19:48 -04:00
Tom Moor 0390f30e1d Restore enterprise translations 2026-03-22 21:56:11 -04:00
Tom Moor 4a40712dcc fix: Improve shared command bar search results and add recent docs (#11849)
Show all search results by passing keywords to Fuse.js, display search
context as subtitle, track recently viewed documents for empty state,
and move SharedSearchActions outside KBarPortal to prevent mount flicker.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 18:49:43 -04:00
Tom Moor 0ba310e027 Remove unused files and dependencies (#11850)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 18:44:51 -04:00
Tom Moor eda59b1450 feat: Show group members popover in share search suggestions (#11848) 2026-03-22 13:42:58 -04:00
Tom Moor ac1f68a447 Escape key clears search highlights in documents (#11847)
When navigating to a document from search results, the search term is
highlighted via FindAndReplace but the popover is not open, so there was
no way to dismiss the highlights. This adds an Escape key binding to the
FindAndReplace extension that clears highlights when active.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 12:21:34 -04:00
Tom Moor 5691ea5ae3 fix: Prevent comment sidebar from opening unexpectedly (#11845)
* fix: Prevent comment sidebar from opening unexpectedly

Guard against stale cross-document focused comments opening the sidebar
by checking the comment's documentId matches the current document. Also
stop restoring rightSidebar state from localStorage on app load so the
sidebar always starts closed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: Restore rightSidebar persistence on page reload

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 12:10:15 -04:00
Tom Moor 8f541eb321 feat: Add command bar search to public shares (#11846)
Replace the SearchPopover in the shared sidebar with a command bar that
opens via Cmd+K or a search button. Search results are scoped to the
share and navigate to shared document paths with highlight support.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 12:09:58 -04:00
Tom Moor c0a6bc911c Add create_attachment tool to get a presigned POST for file upload (#11823)
* fix: Data always included in list_documents response

* Remove resources, add fetch tool
Fix pagination arguments do not accept string

* type -> resource

* Add URL resolving

* Add create_attachment tool to get a presigned POST for file upload bypassing context
2026-03-22 10:49:51 -04:00
Tom Moor fddf630e49 Add configurable MCP workspace guidance (#11839)
* Add configurable MCP workspace guidance

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix Instructions passing, tweak UI

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 10:45:09 -04:00
123 changed files with 3944 additions and 1822 deletions
+16
View File
@@ -1,5 +1,21 @@
NODE_ENV=production
# –––––––––––––––––––––––––––––––––––––––––
# ––––––––––– FILE-BASED SECRETS ––––––––
# –––––––––––––––––––––––––––––––––––––––––
#
# Any environment variable can be loaded from a file by appending _FILE to the
# variable name and setting the value to the path of the file. This is useful
# for Docker secrets and other file-based secret management systems.
#
# For example, instead of:
# SECRET_KEY=your_secret_key
# You can use:
# SECRET_KEY_FILE=/run/secrets/outline_secret_key
#
# The file contents will be trimmed of leading/trailing whitespace. If both the
# variable and the _FILE variant are set, the direct variable takes precedence.
# This URL should point to the fully qualified, publicly accessible, URL. If using a
# proxy this will be the proxy's URL.
URL=
+13 -13
View File
@@ -24,17 +24,17 @@ jobs:
- uses: actions/checkout@v5
- name: Enable Corepack
run: corepack enable
- name: Use Node.js 22.x
- name: Use Node.js 24.x
uses: actions/setup-node@v5
with:
node-version: 22.x
node-version: 24.x
cache: "yarn"
- name: Cache node_modules
id: cache-node-modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
key: ${{ runner.os }}-node-modules-24.x-${{ hashFiles('yarn.lock') }}
- name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: yarn install --immutable
@@ -48,13 +48,13 @@ jobs:
run: corepack enable
- uses: actions/setup-node@v5
with:
node-version: 22.x
node-version: 24.x
cache: "yarn"
- name: Restore node_modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
key: ${{ runner.os }}-node-modules-24.x-${{ hashFiles('yarn.lock') }}
- run: yarn lint --quiet
types:
@@ -66,13 +66,13 @@ jobs:
run: corepack enable
- uses: actions/setup-node@v5
with:
node-version: 22.x
node-version: 24.x
cache: "yarn"
- name: Restore node_modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
key: ${{ runner.os }}-node-modules-24.x-${{ hashFiles('yarn.lock') }}
- run: yarn tsc
changes:
@@ -114,13 +114,13 @@ jobs:
run: corepack enable
- uses: actions/setup-node@v5
with:
node-version: 22.x
node-version: 24.x
cache: "yarn"
- name: Restore node_modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
key: ${{ runner.os }}-node-modules-24.x-${{ hashFiles('yarn.lock') }}
- run: yarn test:${{ matrix.test-group }}
test-server:
@@ -152,13 +152,13 @@ jobs:
run: corepack enable
- uses: actions/setup-node@v5
with:
node-version: 22.x
node-version: 24.x
cache: "yarn"
- name: Restore node_modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
key: ${{ runner.os }}-node-modules-24.x-${{ hashFiles('yarn.lock') }}
- run: yarn sequelize db:migrate
- name: Run server tests
run: |
@@ -175,13 +175,13 @@ jobs:
run: corepack enable
- uses: actions/setup-node@v5
with:
node-version: 22.x
node-version: 24.x
cache: "yarn"
- name: Restore node_modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
key: ${{ runner.os }}-node-modules-24.x-${{ hashFiles('yarn.lock') }}
- name: Set environment to production
run: echo "NODE_ENV=production" >> $GITHUB_ENV
- run: yarn vite:build
+43
View File
@@ -0,0 +1,43 @@
name: Docker Build Check
on:
push:
paths:
- "Dockerfile"
- "Dockerfile.base"
pull_request:
paths:
- "Dockerfile"
- "Dockerfile.base"
env:
BASE_IMAGE_NAME: outline-base
jobs:
build:
runs-on: ubicloud-standard-8
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: docker
- name: Build base image
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.base
tags: ${{ env.BASE_IMAGE_NAME }}:latest
push: false
- name: Build main image
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
push: false
build-args: |
BASE_IMAGE=${{ env.BASE_IMAGE_NAME }}:latest
+15 -15
View File
@@ -17,11 +17,11 @@ jobs:
uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Docker base meta
id: base_meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
${{ env.BASE_IMAGE_NAME }}
@@ -30,14 +30,14 @@ jobs:
type=semver,pattern={{major}}.{{minor}}
- name: Login to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push base image
id: base_build
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.base
@@ -51,7 +51,7 @@ jobs:
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
${{ env.IMAGE_NAME }}
@@ -61,7 +61,7 @@ jobs:
- name: Build and push
id: build
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile
@@ -96,11 +96,11 @@ jobs:
uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Docker base meta
id: base_meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
${{ env.BASE_IMAGE_NAME }}
@@ -109,14 +109,14 @@ jobs:
type=semver,pattern={{major}}.{{minor}}
- name: Login to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push base image
id: base_build
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.base
@@ -130,7 +130,7 @@ jobs:
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
${{ env.IMAGE_NAME }}
@@ -140,7 +140,7 @@ jobs:
- name: Build and push
id: build
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile
@@ -182,17 +182,17 @@ jobs:
merge-multiple: true
- name: Login to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: ${{ env.IMAGE_NAME }}
tags: |
+94
View File
@@ -0,0 +1,94 @@
name: Update Node.js LTS
on:
schedule:
# Run every Monday at 9:00 UTC
- cron: "0 9 * * 1"
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
update-node:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Check for Node.js LTS update
id: check
run: |
# Get current Node version from Dockerfile
CURRENT_VERSION=$(grep -oP 'FROM node:\K[0-9]+\.[0-9]+\.[0-9]+' Dockerfile.base)
echo "current=$CURRENT_VERSION" >> "$GITHUB_OUTPUT"
echo "Current Node.js version: $CURRENT_VERSION"
# Fetch the latest LTS release (any major version) from nodejs.org
LATEST_VERSION=$(curl -s https://nodejs.org/dist/index.json | \
jq -r '[.[] | select(.lts != false)][0].version' | \
sed 's/^v//')
if ! [[ "$LATEST_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::Failed to fetch a valid LTS version (got '$LATEST_VERSION')"
exit 1
fi
echo "latest=$LATEST_VERSION" >> "$GITHUB_OUTPUT"
echo "Latest Node.js LTS version: $LATEST_VERSION"
if [ "$CURRENT_VERSION" = "$LATEST_VERSION" ]; then
echo "updated=false" >> "$GITHUB_OUTPUT"
echo "Already up to date."
else
echo "updated=true" >> "$GITHUB_OUTPUT"
echo "Update available: $CURRENT_VERSION -> $LATEST_VERSION"
fi
- name: Update Node.js version references
if: steps.check.outputs.updated == 'true'
env:
CURRENT: ${{ steps.check.outputs.current }}
LATEST: ${{ steps.check.outputs.latest }}
run: |
CURRENT_MAJOR=$(echo "$CURRENT" | cut -d. -f1)
LATEST_MAJOR=$(echo "$LATEST" | cut -d. -f1)
# Update Dockerfiles
sed -i "s/node:${CURRENT}-slim/node:${LATEST}-slim/g" Dockerfile
sed -i "s/node:${CURRENT} /node:${LATEST} /g" Dockerfile.base
# Update references that depend on major version
if [ "$CURRENT_MAJOR" != "$LATEST_MAJOR" ]; then
# .nvmrc
echo "$LATEST_MAJOR" > .nvmrc
# CI workflow: step name, node-version, and cache keys
sed -i "s/Use Node.js ${CURRENT_MAJOR}.x/Use Node.js ${LATEST_MAJOR}.x/g" .github/workflows/ci.yml
sed -i "s/node-version: ${CURRENT_MAJOR}.x/node-version: ${LATEST_MAJOR}.x/g" .github/workflows/ci.yml
# Update cache keys: replace node-modules-[optional old version] with new version
sed -i -E "s/node-modules-([0-9]+\.x-)?/node-modules-${LATEST_MAJOR}.x-/g" .github/workflows/ci.yml
# package.json engines field: append new major version
sed -i "s/\"node\": \"\(.*\)\"/\"node\": \"\1 || ${LATEST_MAJOR}\"/" package.json
fi
echo "Updated Node.js from $CURRENT to $LATEST"
- name: Create pull request
if: steps.check.outputs.updated == 'true'
uses: peter-evans/create-pull-request@v7
with:
commit-message: "fix: Update Node.js to ${{ steps.check.outputs.latest }}"
title: "fix: Update Node.js to ${{ steps.check.outputs.latest }}"
body: |
Automated update of Node.js in Docker images.
- **Previous version:** ${{ steps.check.outputs.current }}
- **New version:** ${{ steps.check.outputs.latest }}
[Release notes](https://nodejs.org/en/blog/release/v${{ steps.check.outputs.latest }})
branch: automated/update-node-lts
delete-branch: true
labels: dependencies
+1 -1
View File
@@ -1 +1 @@
22
24
+1 -1
View File
@@ -6,7 +6,7 @@ ARG APP_PATH
WORKDIR $APP_PATH
# ---
FROM node:22.21.0-slim AS runner
FROM node:24.14.1-slim AS runner
LABEL org.opencontainers.image.source="https://github.com/outline/outline"
+1 -1
View File
@@ -1,5 +1,5 @@
ARG APP_PATH=/opt/outline
FROM node:22.21.0 AS deps
FROM node:24.14.1 AS deps
ARG APP_PATH
WORKDIR $APP_PATH
+3
View File
@@ -210,6 +210,7 @@ export function actionToKBar(
const name = resolve<string>(action.name, context);
const icon = resolve<React.ReactElement>(action.icon, context);
const section = resolve<string>(action.section, context);
const subtitle = resolve<string>(action.description, context);
const sectionPriority =
typeof action.section !== "string" && "priority" in action.section
@@ -229,6 +230,7 @@ export function actionToKBar(
section,
keywords: action.keywords,
shortcut: action.shortcut,
subtitle,
icon,
priority,
perform: () => performAction(action, context),
@@ -254,6 +256,7 @@ export function actionToKBar(
keywords: action.keywords,
shortcut: action.shortcut,
icon,
subtitle,
priority,
},
...children.map((child) => ({
+4 -1
View File
@@ -15,6 +15,9 @@ export const DeveloperSection = ({ t }: ActionContext) => t("Debug");
export const DocumentSection = ({ t }: ActionContext) => t("Document");
export const SearchResultsSection = ({ t }: ActionContext) =>
t("Search results");
export const DocumentsSection = ({ t }: ActionContext) => t("Documents");
export const ActiveDocumentSection = ({ t, stores }: ActionContext) => {
@@ -58,7 +61,7 @@ export const ShareSection = ({ t }: ActionContext) => t("Share");
export const TeamSection = ({ t }: ActionContext) => t("Workspace");
export const RecentSearchesSection = ({ t }: ActionContext) =>
t("Recent searches");
t("Recently viewed");
RecentSearchesSection.priority = -0.1;
-14
View File
@@ -1,14 +0,0 @@
export default function Arrow() {
return (
<svg
width="13"
height="30"
viewBox="0 0 13 30"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M7.40242 1.48635C8.23085 0.0650039 10.0656 -0.421985 11.5005 0.39863C12.9354 1.21924 13.427 3.03671 12.5986 4.45806L5.59858 16.4681C4.77015 17.8894 2.93538 18.3764 1.5005 17.5558C0.065623 16.7352 -0.426002 14.9177 0.402425 13.4964L7.40242 1.48635Z" />
<path d="M12.5986 25.5419C13.427 26.9633 12.9354 28.7808 11.5005 29.6014C10.0656 30.422 8.23087 29.935 7.40244 28.5136L0.402438 16.5036C-0.425989 15.0823 0.0656365 13.2648 1.50051 12.4442C2.93539 11.6236 4.77016 12.1106 5.59859 13.5319L12.5986 25.5419Z" />
</svg>
);
}
+11 -1
View File
@@ -55,6 +55,15 @@ function Breadcrumb(
});
}
const handleClick = React.useCallback(
(event: React.MouseEvent<HTMLAnchorElement>) => {
if (event.currentTarget.querySelector('[data-state="open"]')) {
event.preventDefault();
}
},
[]
);
const toBreadcrumb = React.useCallback(
(action: TopLevelAction, index: number) => {
if (action.type === "menu") {
@@ -68,6 +77,7 @@ function Breadcrumb(
{item.icon}
<Item
to={item.to}
onClick={handleClick}
$withIcon={!!item.icon}
$highlight={!!highlightFirstItem && index === 0}
>
@@ -76,7 +86,7 @@ function Breadcrumb(
</>
);
},
[actionContext, highlightFirstItem]
[actionContext, handleClick, highlightFirstItem]
);
return (
@@ -4,6 +4,7 @@ import * as React from "react";
import styled, { css, useTheme } from "styled-components";
import { s, ellipsis } from "@shared/styles";
import { normalizeKeyDisplay } from "@shared/utils/keyboard";
import Highlight from "~/components/Highlight";
import Flex from "~/components/Flex";
import Key from "~/components/Key";
import Text from "~/components/Text";
@@ -15,6 +16,14 @@ type Props = {
currentRootActionId: string | null | undefined;
};
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
function replaceResultMarks(tag: string) {
// don't use SEARCH_RESULT_REGEX here as it causes
// an infinite loop to trigger a regex inside it's own callback
return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
}
function CommandBarItem(
{ action, active, currentRootActionId }: Props,
ref: React.RefObject<HTMLDivElement>
@@ -56,6 +65,16 @@ function CommandBarItem(
))}
{action.name}
{action.children?.length ? "…" : ""}
{action.subtitle && (
<Text type="secondary" ellipsis>
&nbsp;&nbsp;
<Highlight
text={action.subtitle}
highlight={SEARCH_RESULT_REGEX}
processResult={replaceResultMarks}
/>
</Text>
)}
</Content>
{action.shortcut?.length ? (
<Shortcut>
@@ -0,0 +1,94 @@
import { useKBar, KBarPositioner, KBarAnimator, KBarSearch } from "kbar";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Portal } from "react-portal";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths, s } from "@shared/styles";
import CommandBarResults from "./CommandBarResults";
import SharedSearchActions from "./SharedSearchActions";
/**
* A simplified command bar for public shares that only provides search.
*/
function SharedCommandBar() {
const { t } = useTranslation();
return (
<>
<SharedSearchActions />
<KBarPortal>
<Positioner>
<Animator>
<SearchInput defaultPlaceholder={`${t("Search")}`} />
<CommandBarResults />
</Animator>
</Positioner>
</KBarPortal>
</>
);
}
type Props = {
children?: React.ReactNode;
};
const KBarPortal: React.FC = ({ children }: Props) => {
const { showing } = useKBar((state) => ({
showing: state.visualState !== "hidden",
}));
if (!showing) {
return null;
}
return <Portal>{children}</Portal>;
};
const Positioner = styled(KBarPositioner)`
z-index: ${depths.commandBar};
`;
const SearchInput = styled(KBarSearch)`
position: relative;
padding: 16px 12px;
margin: 0 8px;
width: calc(100% - 16px);
outline: none;
border: none;
background: ${s("menuBackground")};
color: ${s("text")};
&:not(:last-child) {
border-bottom: 1px solid ${s("inputBorder")};
}
&:disabled,
&::placeholder {
color: ${s("placeholder")};
opacity: 1;
}
`;
const Animator = styled(KBarAnimator)`
max-width: 600px;
max-height: 75vh;
width: 90vw;
background: ${s("menuBackground")};
color: ${s("text")};
border-radius: 8px;
overflow: hidden;
box-shadow: rgb(0 0 0 / 40%) 0px 16px 60px;
transition: max-width 0.2s ease-in-out;
${breakpoint("desktopLarge")`
max-width: 740px;
`};
@media print {
display: none;
}
`;
export default observer(SharedCommandBar);
@@ -0,0 +1,187 @@
import { useKBar } from "kbar";
import escapeRegExp from "lodash/escapeRegExp";
import { observer } from "mobx-react";
import { DocumentIcon } from "outline-icons";
import * as React from "react";
import Icon from "@shared/components/Icon";
import useShare from "@shared/hooks/useShare";
import { Minute } from "@shared/utils/time";
import { createAction } from "~/actions";
import {
RecentSearchesSection,
SearchResultsSection,
} from "~/actions/sections";
import useCommandBarActions from "~/hooks/useCommandBarActions";
import useStores from "~/hooks/useStores";
import type Document from "~/models/Document";
import history from "~/utils/history";
import { sharedModelPath } from "~/utils/routeHelpers";
import type { SearchResult } from "~/types";
interface CacheEntry {
timestamp: number;
results: SearchResult[];
}
const cacheTTL = Minute.ms * 5;
const maxRecentDocs = 5;
/**
* Strip server-generated `<b>` highlight tags from context and re-apply them
* using the current search query. This prevents stale highlights when the
* displayed results are from a previous (in-flight) query.
*
* @param context the server-generated context string with `<b>` tags.
* @param query the current search query to highlight.
* @returns the context string with highlights matching the current query.
*/
function rehighlightContext(
context: string | undefined,
query: string
): string | undefined {
if (!context) {
return context;
}
const plain = context.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
const trimmed = query.trim();
if (!trimmed) {
return plain;
}
const terms = trimmed.split(/\s+/).filter(Boolean);
const patterns = [escapeRegExp(trimmed)];
if (terms.length > 1) {
patterns.push(...terms.map((t) => `\\b${escapeRegExp(t)}\\b`));
}
const regex = new RegExp(patterns.join("|"), "gi");
return plain.replace(regex, "<b>$&</b>");
}
/**
* Registers search result actions in the command bar scoped to a public share.
*/
function SharedSearchActions() {
const { documents } = useStores();
const { shareId } = useShare();
const searchCache = React.useRef<Map<string, CacheEntry>>(new Map());
const [results, setResults] = React.useState<SearchResult[]>([]);
const recentDocsRef = React.useRef<Document[]>([]);
const [recentDocs, setRecentDocs] = React.useState<Document[]>([]);
const { searchQuery } = useKBar((state) => ({
searchQuery: state.searchQuery,
}));
const searchQueryRef = React.useRef(searchQuery);
searchQueryRef.current = searchQuery;
React.useEffect(() => {
if (!searchQuery || !shareId) {
setResults([]);
return;
}
const now = Date.now();
const cachedEntry = searchCache.current.get(searchQuery);
const isExpired = cachedEntry
? now - cachedEntry.timestamp > cacheTTL
: true;
if (cachedEntry && !isExpired) {
setResults(cachedEntry.results);
return;
}
const currentQuery = searchQuery;
void documents.search({ query: searchQuery, shareId }).then((res) => {
searchCache.current.set(currentQuery, { timestamp: now, results: res });
if (searchQueryRef.current === currentQuery) {
setResults(res);
}
});
}, [documents, searchQuery, shareId]);
const addRecentDoc = React.useCallback((doc: Document) => {
const prev = recentDocsRef.current;
const filtered = prev.filter((d) => d.id !== doc.id);
const next = [doc, ...filtered].slice(0, maxRecentDocs);
recentDocsRef.current = next;
setRecentDocs(next);
}, []);
const documentIcon = React.useCallback(
(doc: Document) =>
doc.icon ? (
<Icon
value={doc.icon}
initial={doc.initial}
color={doc.color ?? undefined}
/>
) : (
<DocumentIcon />
),
[]
);
const actions = React.useMemo(
() =>
results.map((result) =>
createAction({
id: `shared-search-${result.document.id}`,
name: result.document.titleWithDefault,
description: rehighlightContext(result.context, searchQuery),
keywords: searchQuery,
analyticsName: "Open shared search result",
section: SearchResultsSection,
icon: documentIcon(result.document),
perform: () => {
if (shareId) {
const currentQuery = searchQueryRef.current;
addRecentDoc(result.document);
history.push({
pathname: sharedModelPath(shareId, result.document.url),
search: currentQuery
? `?q=${encodeURIComponent(currentQuery)}`
: undefined,
});
}
},
})
),
[results, shareId, searchQuery, addRecentDoc, documentIcon]
);
const recentDocActions = React.useMemo(
() =>
recentDocs.map((doc) =>
createAction({
id: `shared-recent-doc-${doc.id}`,
name: doc.titleWithDefault,
analyticsName: "Open recent shared document",
section: RecentSearchesSection,
icon: documentIcon(doc),
perform: () => {
if (shareId) {
history.push(sharedModelPath(shareId, doc.url));
}
},
})
),
[recentDocs, shareId, documentIcon]
);
useCommandBarActions(searchQuery ? actions : recentDocActions, [
searchQuery
? actions.map((a) => a.id).join("")
: recentDocActions.map((a) => a.id).join(""),
searchQuery,
]);
return null;
}
export default observer(SharedSearchActions);
-11
View File
@@ -1,11 +0,0 @@
import styled from "styled-components";
import { s } from "@shared/styles";
const Divider = styled.hr`
border: 0;
border-bottom: 1px solid ${s("divider")};
margin: 0;
padding: 0;
`;
export default Divider;
+85 -12
View File
@@ -5,9 +5,14 @@ import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Icon from "@shared/components/Icon";
import type { NavigationNode } from "@shared/types";
import type Collection from "~/models/Collection";
import type Document from "~/models/Document";
import Breadcrumb from "~/components/Breadcrumb";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { ContextMenu } from "~/components/Menu/ContextMenu";
import { ActionContextProvider } from "~/hooks/useActionContext";
import { useCollectionMenuAction } from "~/hooks/useCollectionMenuAction";
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
@@ -68,7 +73,9 @@ function DocumentBreadcrumb(
to: archivePath(),
}),
createInternalLinkAction({
name: collection?.name,
name: collection ? (
<CollectionName collection={collection} />
) : undefined,
section: ActiveDocumentSection,
icon: collection ? (
<CollectionIcon collection={collection} expanded />
@@ -90,17 +97,14 @@ function DocumentBreadcrumb(
...path.map((node) => {
const title = node.title || t("Untitled");
return createInternalLinkAction({
name: node.icon ? (
<>
<StyledIcon
value={node.icon}
color={node.color}
initial={node.title.charAt(0).toUpperCase()}
/>{" "}
{title}
</>
) : (
title
name: (
<DocumentName
documentId={node.id}
collection={collection}
icon={node.icon}
color={node.color}
title={title}
/>
),
section: ActiveDocumentSection,
to: {
@@ -169,6 +173,75 @@ function DocumentBreadcrumb(
);
}
/** Renders a collection name wrapped in a context menu. */
const CollectionName = observer(function CollectionName_({
collection,
}: {
collection: Collection;
}) {
const { t } = useTranslation();
const menuAction = useCollectionMenuAction({
collectionId: collection.id,
});
return (
<ActionContextProvider value={{ activeModels: [collection] }}>
<ContextMenu action={menuAction} ariaLabel={t("Collection options")}>
<span>{collection.name}</span>
</ContextMenu>
</ActionContextProvider>
);
});
/** Renders a document name wrapped in a context menu. */
const DocumentName = observer(function DocumentName_({
documentId,
collection,
icon,
color,
title,
}: {
documentId: string;
collection: Collection | undefined;
icon: string | undefined;
color: string | undefined;
title: string;
}) {
const { t } = useTranslation();
const { documents } = useStores();
const doc = documents.get(documentId);
const menuAction = useDocumentMenuAction({ documentId });
const content = icon ? (
<>
<StyledIcon
value={icon}
color={color}
initial={title.charAt(0).toUpperCase()}
/>{" "}
{title}
</>
) : (
title
);
if (!doc) {
return <>{content}</>;
}
return (
<ActionContextProvider
value={{
activeModels: [doc, ...(collection ? [collection] : [])],
}}
>
<ContextMenu action={menuAction} ariaLabel={t("Document options")}>
<span>{content}</span>
</ContextMenu>
</ActionContextProvider>
);
});
const StyledIcon = styled(Icon)`
margin-right: 2px;
`;
@@ -3,6 +3,7 @@ import { useState, useMemo } from "react";
import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import type { NavigationNode } from "@shared/types";
import { descendants, flattenTree } from "@shared/utils/tree";
import type Document from "~/models/Document";
import Button from "~/components/Button";
import Text from "~/components/Text";
@@ -23,13 +24,23 @@ function DocumentMove({ document }: Props) {
const [selectedPath, selectPath] = useState<NavigationNode | null>(null);
const items = useMemo(() => {
// Recursively filter out the document itself and its existing parent doc, if any.
// Collect the IDs of the document itself and all of its descendants so they
// can be excluded from the move targets (moving to self or a descendant
// would create a cycle; moving to the exact same location is a no-op).
const allNodes = collectionTrees.flatMap(flattenTree);
const sourceNode = allNodes.find((node) => node.id === document.id);
const excludedIds = new Set<string>([document.id]);
if (sourceNode) {
descendants(sourceNode).forEach((n) => excludedIds.add(n.id));
}
// Recursively filter out the document itself and its descendants.
// The document's current parent is intentionally kept so that siblings
// remain visible as valid move targets.
const filterSourceDocument = (node: NavigationNode): NavigationNode => ({
...node,
children: node.children
?.filter(
(c) => c.id !== document.id && c.id !== document.parentDocumentId
)
?.filter((c) => !excludedIds.has(c.id))
.map(filterSourceDocument),
});
@@ -43,7 +54,7 @@ function DocumentMove({ document }: Props) {
);
return nodes;
}, [policies, collectionTrees, document.id, document.parentDocumentId]);
}, [policies, collectionTrees, document.id]);
const move = async () => {
if (!selectedPath) {
-167
View File
@@ -1,167 +0,0 @@
import {
useFocusEffect,
useRovingTabIndex,
} from "@getoutline/react-roving-tabindex";
import { observer } from "mobx-react";
import * as React from "react";
import { Link } from "react-router-dom";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s, hover, ellipsis } from "@shared/styles";
import type Document from "~/models/Document";
import Highlight, { Mark } from "~/components/Highlight";
import { sharedModelPath } from "~/utils/routeHelpers";
type Props = {
document: Document;
highlight: string;
context: string | undefined;
showParentDocuments?: boolean;
showCollection?: boolean;
showPublished?: boolean;
shareId?: string;
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
};
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
function replaceResultMarks(tag: string) {
// don't use SEARCH_RESULT_REGEX here as it causes
// an infinite loop to trigger a regex inside it's own callback
return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
}
function DocumentListItem(
props: Props,
ref: React.RefObject<HTMLAnchorElement>
) {
const { document, highlight, context, shareId, ...rest } = props;
let itemRef: React.Ref<HTMLAnchorElement> =
React.useRef<HTMLAnchorElement>(null);
if (ref) {
itemRef = ref;
}
const { focused, ...rovingTabIndex } = useRovingTabIndex(itemRef, false);
useFocusEffect(focused, itemRef);
return (
<DocumentLink
ref={itemRef}
dir={document.dir}
to={{
pathname: shareId
? sharedModelPath(shareId, document.url)
: document.url,
search: highlight ? `?q=${encodeURIComponent(highlight)}` : undefined,
state: {
title: document.titleWithDefault,
},
}}
{...rest}
{...rovingTabIndex}
onClick={(ev) => {
if (rest.onClick) {
rest.onClick(ev);
}
rovingTabIndex.onClick(ev);
}}
>
<Content>
<Heading dir={document.dir}>
<Title
text={document.titleWithDefault}
highlight={highlight}
dir={document.dir}
/>
</Heading>
{
<ResultContext
text={context}
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
processResult={replaceResultMarks}
/>
}
</Content>
</DocumentLink>
);
}
const Content = styled.div`
flex-grow: 1;
flex-shrink: 1;
min-width: 0;
`;
const DocumentLink = styled(Link)<{
$isStarred?: boolean;
$menuOpen?: boolean;
}>`
display: flex;
align-items: center;
padding: 6px 12px;
max-height: 50vh;
cursor: var(--pointer);
&:not(:last-child) {
margin-bottom: 4px;
}
&:focus-visible {
outline: none;
}
${breakpoint("tablet")`
width: auto;
`};
&:${hover},
&:active,
&:focus,
&:focus-within {
background: ${s("listItemHoverBackground")};
}
${(props) =>
props.$menuOpen &&
css`
background: ${s("listItemHoverBackground")};
`}
`;
const Heading = styled.h4<{ rtl?: boolean }>`
display: flex;
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
align-items: center;
height: 22px;
margin-top: 0;
margin-bottom: 0.25em;
overflow: hidden;
white-space: nowrap;
color: ${s("text")};
`;
const Title = styled(Highlight)`
max-width: 90%;
${ellipsis()}
${Mark} {
padding: 0;
}
`;
const ResultContext = styled(Highlight)`
display: block;
color: ${s("textTertiary")};
font-size: 14px;
margin-top: -0.25em;
margin-bottom: 0;
${ellipsis()}
${Mark} {
padding: 0;
}
`;
export default observer(React.forwardRef(DocumentListItem));
-289
View File
@@ -1,289 +0,0 @@
import debounce from "lodash/debounce";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Empty from "~/components/Empty";
import { Outline } from "~/components/Input";
import InputSearch from "~/components/InputSearch";
import Placeholder from "~/components/List/Placeholder";
import PaginatedList from "~/components/PaginatedList";
import {
Popover,
PopoverAnchor,
PopoverContent,
} from "~/components/primitives/Popover";
import { id as bodyContentId } from "~/components/SkipNavContent";
import useKeyDown from "~/hooks/useKeyDown";
import useStores from "~/hooks/useStores";
import { preventDefault } from "~/utils/events";
import type { SearchResult } from "~/types";
import SearchListItem from "./SearchListItem";
interface Props extends React.HTMLAttributes<HTMLInputElement> {
shareId: string;
className?: string;
}
function SearchPopover({ shareId, className }: Props) {
const { t } = useTranslation();
const { documents } = useStores();
const focusRef = React.useRef<HTMLElement | null>(null);
const searchInputRef = React.useRef<HTMLInputElement>(null);
const firstSearchItem = React.useRef<HTMLAnchorElement>(null);
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState("");
const [searchResults, setSearchResults] = React.useState<
SearchResult[] | undefined
>();
// Cache search results by query string to avoid redundant API calls
const cacheRef = React.useRef(new Map<string, SearchResult[]>());
const queryRef = React.useRef(query);
queryRef.current = query;
// When the query changes, restore cached results (including empty) or keep
// previous results visible until new results arrive to avoid layout shift
React.useEffect(() => {
if (!query) {
setSearchResults(undefined);
return;
}
const cached = cacheRef.current.get(query);
if (cached !== undefined) {
setSearchResults(cached);
if (cached.length) {
setOpen(true);
}
}
}, [query]);
const performSearch = React.useCallback(
async ({
query: searchQuery,
offset = 0,
...options
}: Record<string, any>) => {
if (!searchQuery?.length) {
return undefined;
}
// Return cached results for first-page lookups
if (offset === 0 && cacheRef.current.has(searchQuery)) {
return cacheRef.current.get(searchQuery)!;
}
// Force offset to 0 for new queries — PaginatedList's reset() sets
// offset via setState but fetchResults still uses the stale value
// from its closure
if (!cacheRef.current.has(searchQuery)) {
offset = 0;
}
const response = await documents.search({
query: searchQuery,
shareId,
offset,
...options,
});
// Build complete result set in cache: replace for new queries, append
// for pagination of an existing query
const existing = cacheRef.current.get(searchQuery);
cacheRef.current.set(
searchQuery,
existing ? [...existing, ...response] : response
);
// Only update state if this query is still current to prevent stale
// results from overwriting newer results after a race condition
if (queryRef.current === searchQuery) {
setSearchResults(cacheRef.current.get(searchQuery)!);
setOpen(true);
}
return response;
},
[documents, shareId]
);
const debouncedSetQuery = React.useMemo(
() =>
debounce((value: string) => {
setQuery(value);
setOpen(!!value);
}, 250),
[]
);
const handleSearchInputChange = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
debouncedSetQuery(event.target.value.trim());
},
[debouncedSetQuery]
);
React.useEffect(() => () => debouncedSetQuery.cancel(), [debouncedSetQuery]);
const handleEscapeList = React.useCallback(
() => searchInputRef.current?.focus(),
[]
);
const handleSearchInputFocus = React.useCallback(() => {
focusRef.current = searchInputRef.current;
}, []);
const handleKeyDown = React.useCallback(
(ev: React.KeyboardEvent<HTMLInputElement>) => {
if (ev.nativeEvent.isComposing) {
return;
}
if (ev.key === "Enter") {
if (searchResults) {
setOpen(true);
}
return;
}
if (ev.key === "ArrowDown" && !ev.shiftKey) {
if (ev.currentTarget.value.length) {
const atEnd =
ev.currentTarget.value.length === ev.currentTarget.selectionStart;
if (atEnd) {
setOpen(true);
}
if (open || atEnd) {
ev.preventDefault();
firstSearchItem.current?.focus();
}
}
return;
}
if (ev.key === "ArrowUp") {
if (open) {
setOpen(false);
if (!ev.shiftKey) {
ev.preventDefault();
}
}
if (ev.currentTarget.value && ev.currentTarget.selectionEnd === 0) {
ev.currentTarget.selectionStart = 0;
ev.currentTarget.selectionEnd = ev.currentTarget.value.length;
ev.preventDefault();
}
return;
}
if (ev.key === "Escape" && open) {
setOpen(false);
ev.preventDefault();
}
},
[open, searchResults]
);
const handleSearchItemClick = React.useCallback(() => {
setOpen(false);
setQuery("");
if (searchInputRef.current) {
searchInputRef.current.value = "";
focusRef.current = document.getElementById(bodyContentId);
}
}, []);
useKeyDown("/", (ev) => {
if (
searchInputRef.current &&
searchInputRef.current !== document.activeElement
) {
searchInputRef.current.focus();
ev.preventDefault();
}
});
return (
<Popover open={open} onOpenChange={setOpen} modal={true}>
<PopoverAnchor>
<StyledInputSearch
role="combobox"
aria-controls="search-results"
aria-expanded={open}
aria-haspopup="listbox"
ref={searchInputRef}
onChange={handleSearchInputChange}
onFocus={handleSearchInputFocus}
onKeyDown={handleKeyDown}
className={className}
label={t("Search")}
labelHidden
/>
</PopoverAnchor>
<PopoverContent
id="search-results"
aria-label={t("Results")}
side="bottom"
align="start"
shrink
onEscapeKeyDown={handleEscapeList}
onOpenAutoFocus={preventDefault}
onInteractOutside={(event) => {
const target = event.target as Element | null;
if (target === searchInputRef.current) {
event.preventDefault();
}
}}
>
<PaginatedList<SearchResult>
role="listbox"
options={{
query,
snippetMinWords: 10,
snippetMaxWords: 11,
limit: 10,
}}
items={searchResults}
fetch={performSearch}
onEscape={handleEscapeList}
empty={
<NoResults>{t("No results for {{query}}", { query })}</NoResults>
}
loading={<PlaceholderList count={3} header={{ height: 20 }} />}
renderItem={(item, index) => (
<SearchListItem
key={item.document.id}
shareId={shareId}
ref={index === 0 ? firstSearchItem : undefined}
document={item.document}
context={item.context}
highlight={query}
onClick={handleSearchItemClick}
/>
)}
/>
</PopoverContent>
</Popover>
);
}
const NoResults = styled(Empty)`
padding: 0 12px;
margin: 6px 0;
`;
const PlaceholderList = styled(Placeholder)`
padding: 6px 12px;
`;
const StyledInputSearch = styled(InputSearch)`
${Outline} {
border-radius: 16px;
}
`;
export default observer(SearchPopover);
@@ -16,7 +16,6 @@ import Scrollable from "~/components/Scrollable";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useMaxHeight from "~/hooks/useMaxHeight";
import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import type { Permission } from "~/types";
import { EmptySelectValue } from "~/types";
@@ -38,10 +37,12 @@ type Props = {
invitedInSession: string[];
/** Whether the popover is visible. */
visible: boolean;
/** Whether the share data is currently loading. */
loading: boolean;
};
export const AccessControlList = observer(
({ collection, share, invitedInSession, visible }: Props) => {
({ collection, share, invitedInSession, visible, loading }: Props) => {
const { memberships, groupMemberships } = useStores();
const team = useCurrentTeam();
const can = usePolicy(collection);
@@ -49,35 +50,13 @@ export const AccessControlList = observer(
const theme = useTheme();
const collectionId = collection.id;
const { request: fetchMemberships, loading: membershipLoading } =
useRequest(
React.useCallback(
() => memberships.fetchAll({ id: collectionId }),
[memberships, collectionId]
)
);
const { request: fetchGroupMemberships, loading: groupMembershipLoading } =
useRequest(
React.useCallback(
() => groupMemberships.fetchAll({ collectionId }),
[groupMemberships, collectionId]
)
);
const groupMembershipsInCollection =
groupMemberships.inCollection(collectionId);
const membershipsInCollection = memberships.inCollection(collectionId);
const hasMemberships =
groupMembershipsInCollection.length > 0 ||
membershipsInCollection.length > 0;
const showLoading =
!hasMemberships && (membershipLoading || groupMembershipLoading);
React.useEffect(() => {
void fetchMemberships();
void fetchGroupMemberships();
}, [fetchMemberships, fetchGroupMemberships]);
const showLoading = !hasMemberships && loading;
const containerRef = React.useRef<HTMLDivElement | null>(null);
const publicAccessRef = React.useRef<HTMLDivElement | null>(null);
@@ -18,6 +18,7 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
import useKeyDown from "~/hooks/useKeyDown";
import usePolicy from "~/hooks/usePolicy";
import usePrevious from "~/hooks/usePrevious";
import useShareDataLoader from "~/hooks/useShareDataLoader";
import useStores from "~/hooks/useStores";
import type { Permission } from "~/types";
import { collectionPath, urlify } from "~/utils/routeHelpers";
@@ -35,11 +36,22 @@ type Props = {
onRequestClose: () => void;
/** Whether the popover is visible. */
visible: boolean;
/** Whether the share data is currently loading, managed externally. */
loading?: boolean;
};
function SharePopover({ collection, visible, onRequestClose }: Props) {
function SharePopover({
collection,
visible,
onRequestClose,
loading: externalLoading,
}: Props) {
const team = useCurrentTeam();
const { groupMemberships, users, groups, memberships, shares } = useStores();
const { preload, loading: internalLoading } = useShareDataLoader({
collection,
});
const loading = externalLoading ?? internalLoading;
const { t } = useTranslation();
const can = usePolicy(collection);
const [query, setQuery] = React.useState("");
@@ -94,10 +106,12 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
React.useEffect(() => {
if (visible) {
void collection.share();
if (externalLoading === undefined) {
preload();
}
setHasRendered(true);
}
}, [collection, visible]);
}, [visible, externalLoading, preload]);
React.useEffect(() => {
if (prevPendingIds && pendingIds.length > prevPendingIds.length) {
@@ -368,6 +382,7 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
share={share}
invitedInSession={invitedInSession}
visible={visible}
loading={loading}
/>
</div>
</Wrapper>
@@ -4,7 +4,6 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import styled, { useTheme } from "styled-components";
import Squircle from "@shared/components/Squircle";
import { Pagination } from "@shared/constants";
import { s } from "@shared/styles";
import { CollectionPermission, IconType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
@@ -43,6 +42,8 @@ type Props = {
onRequestClose: () => void;
/** Whether the popover is visible. */
visible: boolean;
/** Whether the share data is currently loading. */
loading: boolean;
};
export const AccessControlList = observer(
@@ -53,13 +54,14 @@ export const AccessControlList = observer(
sharedParent,
onRequestClose,
visible,
loading,
}: Props) => {
const { t } = useTranslation();
const theme = useTheme();
const collection = document.collection;
const usersInCollection = useUsersInCollection(collection);
const user = useCurrentUser();
const { userMemberships, groupMemberships } = useStores();
const { groupMemberships } = useStores();
const collectionSharingDisabled = document.collection?.sharing === false;
const team = useCurrentTeam();
const can = usePolicy(document);
@@ -75,36 +77,10 @@ export const AccessControlList = observer(
margin: 24,
});
const { loading: userMembershipLoading, request: fetchUserMemberships } =
useRequest(
React.useCallback(
() =>
userMemberships.fetchDocumentMemberships({
id: documentId,
limit: Pagination.defaultLimit,
}),
[userMemberships, documentId]
)
);
const { loading: groupMembershipLoading, request: fetchGroupMemberships } =
useRequest(
React.useCallback(
() => groupMemberships.fetchAll({ documentId }),
[groupMemberships, documentId]
)
);
const hasMemberships =
groupMemberships.inDocument(documentId)?.length > 0 ||
document.members.length > 0;
const showLoading =
!hasMemberships && (groupMembershipLoading || userMembershipLoading);
React.useEffect(() => {
void fetchUserMemberships();
void fetchGroupMemberships();
}, [fetchUserMemberships, fetchGroupMemberships]);
const showLoading = !hasMemberships && loading;
React.useEffect(() => {
calcMaxHeight();
@@ -18,6 +18,7 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
import useKeyDown from "~/hooks/useKeyDown";
import usePolicy from "~/hooks/usePolicy";
import usePrevious from "~/hooks/usePrevious";
import useShareDataLoader from "~/hooks/useShareDataLoader";
import useStores from "~/hooks/useStores";
import type { Permission } from "~/types";
import { documentPath, urlify } from "~/utils/routeHelpers";
@@ -35,9 +36,16 @@ type Props = {
onRequestClose: () => void;
/** Whether the popover is visible. */
visible: boolean;
/** Whether the share data is currently loading, managed externally. */
loading?: boolean;
};
function SharePopover({ document, onRequestClose, visible }: Props) {
function SharePopover({
document,
onRequestClose,
visible,
loading: externalLoading,
}: Props) {
const team = useCurrentTeam();
const { t } = useTranslation();
const can = usePolicy(document);
@@ -46,6 +54,10 @@ function SharePopover({ document, onRequestClose, visible }: Props) {
const sharedParent = shares.getByDocumentParents(document);
const [hasRendered, setHasRendered] = React.useState(visible);
const { users, userMemberships, groups, groupMemberships } = useStores();
const { preload, loading: internalLoading } = useShareDataLoader({
document,
});
const loading = externalLoading ?? internalLoading;
const [query, setQuery] = React.useState("");
const [picker, showPicker, hidePicker] = useBoolean();
const [invitedInSession, setInvitedInSession] = React.useState<string[]>([]);
@@ -79,13 +91,14 @@ function SharePopover({ document, onRequestClose, visible }: Props) {
}
);
// Fetch sharefocus the link button when the popover is opened
React.useEffect(() => {
if (visible) {
void document.share();
if (externalLoading === undefined) {
preload();
}
setHasRendered(true);
}
}, [document, hidePicker, visible]);
}, [visible, externalLoading, preload]);
// Hide the picker when the popover is closed
React.useEffect(() => {
@@ -377,6 +390,7 @@ function SharePopover({ document, onRequestClose, visible }: Props) {
share={share}
sharedParent={sharedParent}
visible={visible}
loading={loading}
onRequestClose={onRequestClose}
/>
</div>
@@ -14,6 +14,7 @@ import type User from "~/models/User";
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
import type { IAvatar } from "~/components/Avatar";
import { Avatar, GroupAvatar, AvatarSize } from "~/components/Avatar";
import ButtonLink from "~/components/ButtonLink";
import Empty from "~/components/Empty";
import Placeholder from "~/components/List/Placeholder";
import Scrollable from "~/components/Scrollable";
@@ -21,6 +22,7 @@ import useCurrentUser from "~/hooks/useCurrentUser";
import useMaxHeight from "~/hooks/useMaxHeight";
import useStores from "~/hooks/useStores";
import useThrottledCallback from "~/hooks/useThrottledCallback";
import { GroupMembersPopover } from "./GroupMembersPopover";
import { InviteIcon, ListItem } from "./ListItem";
type Suggestion = IAvatar & {
@@ -148,9 +150,18 @@ export const Suggestions = observer(
if (suggestion instanceof Group) {
return {
title: suggestion.name,
subtitle: t("{{ count }} member", {
count: suggestion.memberCount,
}),
subtitle: (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
<span onClick={(ev) => ev.stopPropagation()}>
<GroupMembersPopover group={suggestion}>
<StyledButtonLink>
{t("{{ count }} member", {
count: suggestion.memberCount,
})}
</StyledButtonLink>
</GroupMembersPopover>
</span>
),
image: <GroupAvatar group={suggestion} />,
};
}
@@ -268,6 +279,13 @@ const Separator = styled.div`
margin: 12px 0;
`;
const StyledButtonLink = styled(ButtonLink)`
color: ${s("textTertiary")};
&:hover {
text-decoration: underline;
}
`;
const ScrollableContainer = styled(Scrollable)`
padding: 12px 24px;
margin: -12px -24px;
+1 -1
View File
@@ -31,7 +31,7 @@ function SettingsSidebar() {
const groupedConfig = groupBy(
configs.filter((item) =>
item.group === "Integrations" && item.pluginId
item.group === t("Integrations") && item.pluginId
? integrations.findByService(item.pluginId)
: true
),
+41 -11
View File
@@ -1,10 +1,15 @@
import { useKBar } from "kbar";
import { observer } from "mobx-react";
import { SearchIcon } from "outline-icons";
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { metaDisplay } from "@shared/utils/keyboard";
import type Share from "~/models/Share";
import Flex from "~/components/Flex";
import Scrollable from "~/components/Scrollable";
import SearchPopover from "~/components/SearchPopover";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
@@ -17,8 +22,6 @@ import Section from "./components/Section";
import { SharedCollectionLink } from "./components/SharedCollectionLink";
import { SharedDocumentLink } from "./components/SharedDocumentLink";
import SidebarButton from "./components/SidebarButton";
import { useEffect } from "react";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
type Props = {
share: Share;
@@ -29,6 +32,7 @@ function SharedSidebar({ share }: Props) {
const user = useCurrentUser({ rejectOnEmpty: false });
const { ui, documents, collections } = useStores();
const { t } = useTranslation();
const { query } = useKBar();
const teamAvailable = !!team?.name;
const rootNode = share.tree;
@@ -38,6 +42,10 @@ function SharedSidebar({ share }: Props) {
? ProsemirrorHelper.isEmptyData(collection?.data)
: false;
const handleOpenSearch = useCallback(() => {
query.toggle();
}, [query]);
useEffect(() => {
ui.tocVisible = share.showTOC;
}, []);
@@ -64,9 +72,11 @@ function SharedSidebar({ share }: Props) {
)}
<ScrollContainer topShadow flex>
<TopSection>
<SearchWrapper>
<StyledSearchPopover shareId={shareId} />
</SearchWrapper>
<SearchButton onClick={handleOpenSearch}>
<SearchIcon size={20} />
<SearchLabel>{t("Search")}</SearchLabel>
<Shortcut>{metaDisplay}K</Shortcut>
</SearchButton>
</TopSection>
<Section>
{share.collectionId ? (
@@ -102,14 +112,34 @@ const TopSection = styled(Flex)`
flex-shrink: 0;
`;
const SearchWrapper = styled.div`
const SearchButton = styled.button`
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 12px;
margin: 8px 0;
border: 1px solid ${s("inputBorder")};
border-radius: 16px;
background: ${s("background")};
color: ${s("textTertiary")};
cursor: var(--pointer);
font-size: 14px;
&:hover {
border-color: ${s("inputBorderFocused")};
color: ${s("textSecondary")};
}
`;
const StyledSearchPopover = styled(SearchPopover)`
width: 100%;
transition: width 100ms ease-out;
margin: 8px 0;
const SearchLabel = styled.span`
flex-grow: 1;
text-align: left;
`;
const Shortcut = styled.span`
flex-shrink: 0;
font-size: 13px;
`;
export default observer(SharedSidebar);
@@ -265,27 +265,30 @@ function InnerDocumentLink(
};
});
const nodeChildren = React.useMemo(() => {
const insertDraftDocument =
activeDocument?.isDraft &&
activeDocument?.isActive &&
activeDocument?.parentDocumentId === node.id;
const insertDraftChild = !!(
activeDocument?.isDraft &&
activeDocument?.isActive &&
activeDocument?.parentDocumentId === node.id
);
return collection && insertDraftDocument
? sortNavigationNodes(
[activeDocument?.asNavigationNode, ...node.children],
collection.sort,
false
)
: node.children;
}, [
activeDocument?.isActive,
activeDocument?.isDraft,
activeDocument?.parentDocumentId,
activeDocument?.asNavigationNode,
collection,
node,
]);
// Only subscribe to asNavigationNode when this node is the parent of an
// active draft. This avoids every DocumentLink observer re-rendering on
// every title keystroke.
const draftNavNode = insertDraftChild
? activeDocument?.asNavigationNode
: undefined;
const nodeChildren = React.useMemo(
() =>
collection && draftNavNode
? sortNavigationNodes(
[draftNavNode, ...node.children],
collection.sort,
false
)
: node.children,
[draftNavNode, collection, node]
);
const doc = documents.get(node.id);
const title = doc?.title || node.title || t("Untitled");
@@ -7,38 +7,32 @@ export default function useCollectionDocuments(
collection: Collection | undefined,
activeDocument: Document | undefined
) {
const insertDraftDocument = useMemo(
() =>
activeDocument &&
activeDocument.isActive &&
activeDocument.isDraft &&
activeDocument.collectionId === collection?.id &&
!activeDocument.parentDocumentId,
[
activeDocument?.isActive,
activeDocument?.isDraft,
activeDocument?.collectionId,
activeDocument?.parentDocumentId,
collection?.id,
]
const insertDraftDocument = !!(
activeDocument &&
activeDocument.isActive &&
activeDocument.isDraft &&
activeDocument.collectionId === collection?.id &&
!activeDocument.parentDocumentId
);
// Only subscribe to asNavigationNode when we actually need to insert a draft
// into the sorted list. This avoids every CollectionLinkChildren observer
// re-rendering on every title keystroke.
const draftNavNode = insertDraftDocument
? activeDocument?.asNavigationNode
: undefined;
return useMemo(() => {
if (!collection?.sortedDocuments) {
return undefined;
}
return insertDraftDocument && activeDocument
return draftNavNode
? sortNavigationNodes(
[activeDocument.asNavigationNode, ...collection.sortedDocuments],
[draftNavNode, ...collection.sortedDocuments],
collection.sort,
false
)
: collection.sortedDocuments;
}, [
insertDraftDocument,
activeDocument?.asNavigationNode,
collection?.sortedDocuments,
collection?.sort,
]);
}, [draftNavNode, collection?.sortedDocuments, collection?.sort]);
}
-30
View File
@@ -1,30 +0,0 @@
import { observer } from "mobx-react";
import { useCallback } from "react";
import { toast } from "sonner";
import { TemplateForm } from "./TemplateForm";
import type Template from "~/models/Template";
type Props = {
template: Template;
onSubmit: () => void;
};
export const TemplateEdit = observer(function TemplateEdit_({
template,
onSubmit,
}: Props) {
const handleSubmit = useCallback(async () => {
try {
await template?.save();
onSubmit?.();
} catch (error) {
toast.error(error.message);
}
}, [template, onSubmit]);
if (!template) {
return null;
}
return <TemplateForm template={template} handleSubmit={handleSubmit} />;
});
-36
View File
@@ -1,36 +0,0 @@
import { observer } from "mobx-react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import Template from "~/models/Template";
import useStores from "~/hooks/useStores";
import { TemplateForm } from "./TemplateForm";
type Props = {
collectionId?: string | null;
onSubmit?: () => void;
};
export const TemplateNew = observer(function TemplateNew_({
collectionId,
onSubmit,
}: Props) {
const { templates } = useStores();
const [template] = useState(
new Template({ title: "", collectionId }, templates)
);
const handleSubmit = useCallback(async () => {
try {
await template.save();
onSubmit?.();
} catch (error) {
toast.error(error.message);
}
}, [template, onSubmit]);
if (!template) {
return null;
}
return <TemplateForm template={template} handleSubmit={handleSubmit} />;
});
-22
View File
@@ -1,22 +0,0 @@
import * as React from "react";
import styled from "styled-components";
import Flex from "~/components/Flex";
const Label = ({ icon, value }: { icon: React.ReactNode; value: string }) => (
<Flex align="center" gap={4}>
<IconWrapper>{icon}</IconWrapper>
{value}
</Flex>
);
const IconWrapper = styled.span`
display: flex;
justify-content: center;
align-items: center;
height: 24px;
width: 24px;
overflow: hidden;
flex-shrink: 0;
`;
export default Label;
+188 -48
View File
@@ -14,6 +14,8 @@ import { ancestors } from "@shared/editor/utils";
import FindAndReplace from "../components/FindAndReplace";
const pluginKey = new PluginKey("find-and-replace");
const supportsHighlightAPI =
typeof CSS !== "undefined" && CSS.highlights !== undefined;
export default class FindAndReplaceExtension extends Extension {
public get name() {
@@ -22,13 +24,34 @@ export default class FindAndReplaceExtension extends Extension {
public get defaultOptions() {
return {
resultClassName: "find-result",
resultCurrentClassName: "current-result",
caseSensitive: false,
regexEnabled: false,
};
}
keys(): Record<string, Command> {
return {
Escape: (state, dispatch) => {
if (!this.searchTerm) {
return false;
}
const params = new URLSearchParams(window.location.search);
if (params.has("q")) {
params.delete("q");
const search = params.toString();
window.history.replaceState(
window.history.state,
"",
window.location.pathname + (search ? `?${search}` : "")
);
}
return this.clear()(state, dispatch);
},
};
}
public commands() {
return {
/**
@@ -82,20 +105,6 @@ export default class FindAndReplaceExtension extends Extension {
};
}
private get decorations() {
return this.results.map((deco, index) => {
const decorationType =
deco.type === "node" ? Decoration.node : Decoration.inline;
return decorationType(deco.from, deco.to, {
class:
this.options.resultClassName +
(this.currentResultIndex === index
? ` ${this.options.resultCurrentClassName}`
: ""),
});
});
}
public replace(replace: string): Command {
return (state, dispatch) => {
// Redo the search to ensure we have the latest results, the document may
@@ -209,14 +218,25 @@ export default class FindAndReplaceExtension extends Extension {
}
private scrollToCurrentMatch() {
const element = window.document.querySelector(
`.${this.options.resultCurrentClassName}`
);
if (element) {
scrollIntoView(element, {
scrollMode: "if-needed",
block: "center",
});
if (supportsHighlightAPI) {
if (this.currentHighlightRange) {
const node = this.currentHighlightRange.startContainer;
const element = node instanceof HTMLElement ? node : node.parentElement;
if (element) {
scrollIntoView(element, {
scrollMode: "if-needed",
block: "center",
});
}
}
} else {
const element = window.document.querySelector(".current-result");
if (element) {
scrollIntoView(element, {
scrollMode: "if-needed",
block: "center",
});
}
}
}
@@ -384,13 +404,83 @@ export default class FindAndReplaceExtension extends Extension {
});
}
private createDeco(doc: Node) {
/**
* Build ProseMirror decorations from search results (fallback for browsers
* without CSS Custom Highlight API support).
*/
private get decorations() {
return this.results.map((deco, index) => {
const decorationType =
deco.type === "node" ? Decoration.node : Decoration.inline;
return decorationType(deco.from, deco.to, {
class:
"find-result" +
(this.currentResultIndex === index ? " current-result" : ""),
});
});
}
/**
* Create a DecorationSet from the current search results.
*/
private createDecorationSet(doc: Node) {
this.search(doc);
return this.decorations
return this.decorations.length
? DecorationSet.create(doc, this.decorations)
: DecorationSet.empty;
}
/**
* Update CSS Custom Highlight API highlights based on current search results.
*/
private updateHighlights() {
const view = this.editor?.view;
if (!view || !this.results.length || !this.searchTerm) {
CSS.highlights.delete("search-results");
CSS.highlights.delete("search-results-current");
this.currentHighlightRange = undefined;
return;
}
const allRanges: StaticRange[] = [];
const currentRanges: StaticRange[] = [];
this.currentHighlightRange = undefined;
for (let i = 0; i < this.results.length; i++) {
const result = this.results[i];
try {
const from = view.domAtPos(result.from);
const to = view.domAtPos(result.to);
const range = new StaticRange({
startContainer: from.node,
startOffset: from.offset,
endContainer: to.node,
endOffset: to.offset,
});
allRanges.push(range);
if (i === this.currentResultIndex) {
currentRanges.push(range);
this.currentHighlightRange = range;
}
} catch {
// Position may not be in the visible DOM (e.g. inside folded toggle)
}
}
CSS.highlights.set("search-results", new Highlight(...allRanges));
if (currentRanges.length) {
CSS.highlights.set(
"search-results-current",
new Highlight(...currentRanges)
);
} else {
CSS.highlights.delete("search-results-current");
}
}
private currentHighlightRange?: StaticRange;
get allowInReadOnly() {
return true;
}
@@ -400,35 +490,85 @@ export default class FindAndReplaceExtension extends Extension {
}
get plugins() {
return [
new Plugin({
key: pluginKey,
state: {
init: () => DecorationSet.empty,
apply: (tr, decorationSet) => {
const action = tr.getMeta(pluginKey);
if (supportsHighlightAPI) {
return [this.highlightAPIPlugin];
}
return [this.decorationPlugin];
}
if (action) {
if (action.open) {
this.open = true;
}
return this.createDeco(tr.doc);
/** Plugin using the CSS Custom Highlight API (no DOM modifications). */
private get highlightAPIPlugin() {
return new Plugin({
key: pluginKey,
state: {
init: () => 0,
apply: (tr, generation) => {
const action = tr.getMeta(pluginKey);
if (action) {
if (action.open) {
this.open = true;
}
this.search(tr.doc);
return generation + 1;
}
if (tr.docChanged) {
return decorationSet.map(tr.mapping, tr.doc);
if (tr.docChanged && this.searchTerm) {
this.search(tr.doc);
return generation + 1;
}
return generation;
},
},
view: () => {
let lastGeneration = 0;
return {
update: (view) => {
const generation = pluginKey.getState(view.state) as number;
if (generation !== lastGeneration) {
lastGeneration = generation;
this.updateHighlights();
}
},
destroy: () => {
CSS.highlights?.delete("search-results");
CSS.highlights?.delete("search-results-current");
},
};
},
});
}
return decorationSet;
},
/** Fallback plugin using ProseMirror decorations. */
private get decorationPlugin() {
return new Plugin({
key: pluginKey,
state: {
init: () => DecorationSet.empty,
apply: (tr, decorationSet) => {
const action = tr.getMeta(pluginKey);
if (action) {
if (action.open) {
this.open = true;
}
return this.createDecorationSet(tr.doc);
}
if (tr.docChanged) {
return decorationSet.map(tr.mapping, tr.doc);
}
return decorationSet;
},
props: {
decorations(state) {
return this.getState(state);
},
},
props: {
decorations(state) {
return this.getState(state);
},
}),
];
},
});
}
public widget = ({ readOnly }: WidgetProps) => (
+1 -1
View File
@@ -19,7 +19,7 @@ export default class Suggestion extends Extension {
super(options);
this.openRegex = new RegExp(
`(?:^|\\s|\\()${escapeRegExp(
`(?:^|\\s|\\(|[\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Hangul}])${escapeRegExp(
this.options.trigger
)}(${`[\\p{L}\/\\p{M}\\d${
this.options.allowSpaces ? "\\s{1}" : ""
+1 -1
View File
@@ -944,7 +944,7 @@ const EditorContainer = styled(Styles)<{
a#comment-${props.focusedCommentId}
~ span.component-image
div.image-wrapper {
outline: ${props.theme.commentMarkBackground} solid 2px;
outline: ${props.theme.commentedImageOutlineDark} solid 2px;
}
`}
+79
View File
@@ -0,0 +1,79 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Pagination } from "@shared/constants";
import type Collection from "~/models/Collection";
import type Document from "~/models/Document";
import useStores from "./useStores";
type Params =
| { document: Document; collection?: undefined }
| { collection: Collection; document?: undefined };
/**
* Hook to preload all data needed by the share popover. Returns a `preload`
* function that can be called on hover so the popover renders instantly.
*
* @param params - the document or collection to load share data for.
* @returns preload function, loading state, and reset function.
*/
export default function useShareDataLoader(params: Params) {
const { userMemberships, groupMemberships, memberships } = useStores();
const [loading, setLoading] = useState(false);
const requestedRef = useRef(false);
const requestCountRef = useRef(0);
const entityId = params.document?.id ?? params.collection?.id;
// Reset when the entity changes so preload fires for the new target.
useEffect(() => {
requestedRef.current = false;
setLoading(false);
}, [entityId]);
const preload = useCallback(() => {
if (requestedRef.current) {
return;
}
requestedRef.current = true;
setLoading(true);
const thisRequest = ++requestCountRef.current;
const promises: Promise<unknown>[] = [];
if (params.document) {
const doc = params.document;
promises.push(
doc.share(),
userMemberships.fetchDocumentMemberships({
id: doc.id,
limit: Pagination.defaultLimit,
}),
groupMemberships.fetchAll({ documentId: doc.id })
);
} else {
const col = params.collection;
promises.push(
col.share(),
memberships.fetchAll({ id: col.id }),
groupMemberships.fetchAll({ collectionId: col.id })
);
}
void Promise.all(promises).finally(() => {
if (requestCountRef.current === thisRequest) {
setLoading(false);
}
});
}, [
params.document,
params.collection,
userMemberships,
groupMemberships,
memberships,
]);
const reset = useCallback(() => {
requestedRef.current = false;
}, []);
return { preload, loading, reset };
}
-30
View File
@@ -1,30 +0,0 @@
import { useLayoutEffect, useState } from "react";
/**
* Hook to get the current viewport height, accounting for mobile virtual keyboards.
* Uses the VisualViewport API when available, falling back to window.innerHeight.
*
* @returns The current viewport height in pixels
*/
export default function useViewportHeight(): number | void {
// https://developer.mozilla.org/en-US/docs/Web/API/VisualViewport#browser_compatibility
// Note: No support in Firefox at time of writing, however this mainly exists
// for virtual keyboards on mobile devices, so that's okay.
const [height, setHeight] = useState<number>(
() => window.visualViewport?.height || window.innerHeight
);
useLayoutEffect(() => {
const handleResize = () => {
setHeight(() => window.visualViewport?.height || window.innerHeight);
};
window.visualViewport?.addEventListener("resize", handleResize);
return () => {
window.visualViewport?.removeEventListener("resize", handleResize);
};
}, []);
return height;
}
-7
View File
@@ -1,7 +0,0 @@
import type { MenuSeparator } from "~/types";
export default function separator(): MenuSeparator {
return {
type: "separator",
};
}
+4
View File
@@ -64,6 +64,10 @@ class Team extends Model {
@observable
defaultUserRole: UserRole;
@Field
@observable
guidanceMCP: string | null;
@Field
@observable
preferences: TeamPreferences | null;
@@ -11,6 +11,7 @@ import {
} from "~/components/primitives/Popover";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useMobile from "~/hooks/useMobile";
import useShareDataLoader from "~/hooks/useShareDataLoader";
import useStores from "~/hooks/useStores";
import { preventDefault } from "~/utils/events";
import lazyWithRetry from "~/utils/lazyWithRetry";
@@ -33,14 +34,23 @@ function ShareButton({ collection }: Props) {
const share = shares.getByCollectionId(collection.id);
const isPubliclyShared =
team.sharing !== false && collection?.sharing !== false && share?.published;
const { preload, loading, reset } = useShareDataLoader({ collection });
const handleOpenChange = useCallback(
(isOpen: boolean) => {
setOpen(isOpen);
if (isOpen) {
preload();
} else {
reset();
}
},
[preload, reset]
);
const closePopover = useCallback(() => {
setOpen(false);
}, []);
const handleMouseEnter = useCallback(() => {
void collection.share();
}, [collection]);
handleOpenChange(false);
}, [handleOpenChange]);
if (isMobile) {
return null;
@@ -53,9 +63,9 @@ function ShareButton({ collection }: Props) {
);
return (
<Popover open={open} onOpenChange={setOpen}>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger>
<Button icon={icon} neutral onMouseEnter={handleMouseEnter}>
<Button icon={icon} neutral onMouseEnter={preload}>
{t("Share")}
</Button>
</PopoverTrigger>
@@ -72,6 +82,7 @@ function ShareButton({ collection }: Props) {
collection={collection}
onRequestClose={closePopover}
visible={open}
loading={loading}
/>
</Suspense>
</PopoverContent>
@@ -103,6 +103,11 @@ function CommentForm({
useOnClickOutside(formRef, reset);
React.useEffect(() => {
window.addEventListener("beforeunload", reset);
return () => window.removeEventListener("beforeunload", reset);
}, [reset]);
const handleCreateComment = action(async (event: React.FormEvent) => {
event.preventDefault();
@@ -254,11 +259,13 @@ function CommentForm({
const handleMounted = React.useCallback(
(ref) => {
if (autoFocus && ref && !hasFocusedOnMount.current) {
ref.focusAtStart();
if (!draft) {
ref.focusAtStart();
}
hasFocusedOnMount.current = true;
}
},
[autoFocus]
[autoFocus, draft]
);
const presence = animatePresence
+1 -1
View File
@@ -93,7 +93,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
}, [ref]);
React.useEffect(() => {
if (focusedComment) {
if (focusedComment && focusedComment.documentId === document.id) {
const viewingResolved = params.get("resolved") === "";
if (
(focusedComment.isResolved && !viewingResolved) ||
@@ -7,7 +7,9 @@ import Icon from "@shared/components/Icon";
import { richExtensions } from "@shared/editor/nodes";
import { canUseElementFullscreen } from "@shared/utils/browser";
import { s, depths, hover } from "@shared/styles";
import cloneDeep from "lodash/cloneDeep";
import type { ProsemirrorData } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { colorPalette } from "@shared/utils/collections";
import Editor from "~/components/Editor";
import NudeButton from "~/components/NudeButton";
@@ -130,8 +132,16 @@ function PresentationMode({ title, icon, iconColor, data, onClose }: Props) {
const supportsFullscreen = React.useMemo(() => canUseElementFullscreen(), []);
const isIdle = useIdle(3000, idleEvents);
const strippedData = React.useMemo(
() =>
ProsemirrorHelper.removeMarks(cloneDeep(data), [
"comment",
]) as ProsemirrorData,
[data]
);
const slides = React.useMemo(() => {
const result = splitIntoSlides(data, title, icon, iconColor);
const result = splitIntoSlides(strippedData, title, icon, iconColor);
const contentSlides = result.filter((s) => s.type === "content");
const hasContent =
contentSlides.length > 0 &&
@@ -144,7 +154,7 @@ function PresentationMode({ title, icon, iconColor, data, onClose }: Props) {
}
return result;
}, [data, title, icon, iconColor]);
}, [strippedData, title, icon, iconColor]);
const totalSlides = slides.length;
@@ -246,7 +256,7 @@ function PresentationMode({ title, icon, iconColor, data, onClose }: Props) {
}
const availableWidth = container.clientWidth - 160;
const availableHeight = container.clientHeight - 48 - 160;
const availableHeight = container.clientHeight - 160;
const scaleX = availableWidth / width;
const scaleY = availableHeight / height;
const newScale = Math.min(scaleX, scaleY, 1.5);
@@ -444,8 +454,13 @@ const TopBar = styled.div<{ $idle: boolean }>`
align-items: center;
justify-content: center;
padding: 16px;
position: relative;
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 1;
opacity: ${(props) => (props.$idle ? 0 : 1)};
pointer-events: ${(props) => (props.$idle ? "none" : "auto")};
transition: opacity 300ms ease;
`;
+19 -8
View File
@@ -10,6 +10,7 @@ import {
PopoverContent,
} from "~/components/primitives/Popover";
import useMobile from "~/hooks/useMobile";
import useShareDataLoader from "~/hooks/useShareDataLoader";
import useStores from "~/hooks/useStores";
import { preventDefault } from "~/utils/events";
import lazyWithRetry from "~/utils/lazyWithRetry";
@@ -31,14 +32,23 @@ function ShareButton({ document }: Props) {
const share = shares.getByDocumentId(document.id);
const sharedParent = shares.getByDocumentParents(document);
const domain = share?.domain || sharedParent?.domain;
const { preload, loading, reset } = useShareDataLoader({ document });
const handleOpenChange = useCallback(
(isOpen: boolean) => {
setOpen(isOpen);
if (isOpen) {
preload();
} else {
reset();
}
},
[preload, reset]
);
const closePopover = useCallback(() => {
setOpen(false);
}, []);
const handleMouseEnter = useCallback(() => {
void document.share();
}, [document]);
handleOpenChange(false);
}, [handleOpenChange]);
if (isMobile) {
return null;
@@ -47,9 +57,9 @@ function ShareButton({ document }: Props) {
const icon = document.isPubliclyShared ? <GlobeIcon /> : undefined;
return (
<Popover open={open} onOpenChange={setOpen}>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger>
<Button icon={icon} neutral onMouseEnter={handleMouseEnter}>
<Button icon={icon} neutral onMouseEnter={preload}>
{t("Share")} {domain && <>&middot; {domain}</>}
</Button>
</PopoverTrigger>
@@ -66,6 +76,7 @@ function ShareButton({ document }: Props) {
document={document}
onRequestClose={closePopover}
visible={open}
loading={loading}
/>
</Suspense>
</PopoverContent>
@@ -89,12 +89,16 @@ function AuthenticationProvider(props: Props) {
// Populate hidden form fields with authentication data
if (formRef.current) {
const createInputs = (obj: any, prefix = "") => {
const createInputs = (obj: Record<string, unknown>, prefix = "") => {
Object.entries(obj).forEach(([key, value]) => {
if (value === undefined || value === null) {
return;
}
const fieldName = prefix ? `${prefix}[${key}]` : key;
if (value && typeof value === "object" && !Array.isArray(value)) {
createInputs(value, fieldName);
if (typeof value === "object" && !Array.isArray(value)) {
createInputs(value as Record<string, unknown>, fieldName);
} else {
// Create hidden input for primitive values
const input = document.createElement("input");
+39
View File
@@ -4,6 +4,7 @@ import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import { TeamPreference } from "@shared/types";
import { TeamValidation } from "@shared/validations";
import Heading from "~/components/Heading";
import Scene from "~/components/Scene";
import Switch from "~/components/Switch";
@@ -30,6 +31,18 @@ function Features() {
[team, t]
);
const handleGuidanceMCPChange = React.useCallback(
async (ev: React.ChangeEvent<HTMLTextAreaElement>) => {
team.guidanceMCP = ev.target.value || null;
},
[team]
);
const handleGuidanceMCPBlur = React.useCallback(async () => {
await team.save();
toast.success(t("Settings saved"));
}, [team, t]);
const handleCopied = React.useCallback(() => {
toast.success(t("Copied to clipboard"));
}, [t]);
@@ -46,6 +59,7 @@ function Features() {
<SettingRow
name={TeamPreference.MCP}
label={t("MCP server")}
border={!team.getPreference(TeamPreference.MCP)}
description={
<>
<Text type="secondary" as="p">
@@ -97,6 +111,31 @@ function Features() {
/>
</SettingRow>
{team.getPreference(TeamPreference.MCP) && (
<SettingRow
name="guidanceMCP"
label={t("Additional guidance")}
description={
<>
<div style={{ marginBottom: 8 }}>
{t(
"You can use these optional instructions to tell MCP clients how to use your knowledge base."
)}
</div>
<Input
id="guidanceMCP"
type="textarea"
rows={6}
value={team.guidanceMCP ?? ""}
maxLength={TeamValidation.maxGuidanceMCPLength}
onChange={handleGuidanceMCPChange}
onBlur={handleGuidanceMCPBlur}
/>
</>
}
/>
)}
<SettingRow
name="answers"
label={t("AI answers")}
+1 -1
View File
@@ -27,7 +27,7 @@ function Integrations() {
const groupedItems = groupBy(
items.filter(
(item) =>
item.group === "Integrations" &&
item.group === t("Integrations") &&
item.enabled &&
item.path !== settingsPath("integrations") &&
item.name.toLowerCase().includes(query.toLowerCase())
+2
View File
@@ -11,6 +11,7 @@ import Collection from "~/models/Collection";
import Document from "~/models/Document";
import type Share from "~/models/Share";
import Error404 from "~/scenes/Errors/Error404";
import SharedCommandBar from "~/components/CommandBar/SharedCommandBar";
import { DocumentContextProvider } from "~/components/DocumentContext";
import Layout from "~/components/Layout";
import Sidebar from "~/components/Sidebar/Shared";
@@ -270,6 +271,7 @@ function SharedScene() {
<CollectionScene collection={model} />
) : null}
</Layout>
<SharedCommandBar />
<ClickablePadding minHeight="20vh" />
</DocumentContextProvider>
</ThemeProvider>
+1 -1
View File
@@ -618,7 +618,7 @@ export default class DocumentsStore extends Store<Document> {
});
const collection = this.getCollectionForDocument(document);
if (collection) {
await collection.refresh();
collection.removeDocument(document.id);
}
};
+5 -1
View File
@@ -24,7 +24,11 @@ class UnfurlsStore extends Store<Unfurl<any>> {
}): Promise<Unfurl<UnfurlType> | undefined> => {
try {
const protocol = new URL(url).protocol;
if (protocol !== "http:" && protocol !== "https:" && protocol !== "mention:") {
if (
protocol !== "http:" &&
protocol !== "https:" &&
protocol !== "mention:"
) {
return;
}
} catch (_err) {
-1
View File
@@ -1 +0,0 @@
export const runAllPromises = () => new Promise<void>(setImmediate);
+1
View File
@@ -138,6 +138,7 @@ type BaseAction = {
analyticsName?: string;
name: ((context: ActionContext) => React.ReactNode) | React.ReactNode;
section: ((context: ActionContext) => string) | string;
description?: ((context: ActionContext) => string) | string;
shortcut?: string[];
keywords?: string;
/** Higher number is higher in results, default is 0. */
+2
View File
@@ -141,6 +141,8 @@ declare module "styled-components" {
textDiffDeletedBackground: string;
placeholder: string;
commentMarkBackground: string;
commentedImageOutlineLight: string;
commentedImageOutlineDark: string;
sidebarBackground: string;
sidebarHoverBackground: string;
sidebarActiveBackground: string;
+2 -6
View File
@@ -41,7 +41,7 @@
"url": "https://github.com/sponsors/outline"
},
"engines": {
"node": ">=20.12 <21 || 22"
"node": ">=20.12 <21 || 22 || 24"
},
"repository": {
"type": "git",
@@ -83,7 +83,6 @@
"@node-oauth/oauth2-server": "^5.2.0",
"@notionhq/client": "^2.3.0",
"@octokit/auth-app": "^6.1.4",
"@octokit/webhooks": "^13.9.1",
"@outlinewiki/koa-passport": "^4.2.1",
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
"@radix-ui/react-collapsible": "^1.1.12",
@@ -112,7 +111,6 @@
"addressparser": "^1.0.1",
"async-sema": "^3.1.1",
"autotrack": "^2.4.1",
"body-scroll-lock": "^4.0.0-beta.0",
"bull": "^4.16.5",
"class-validator": "^0.14.3",
"command-score": "^0.1.2",
@@ -171,7 +169,7 @@
"markdown-it": "^14.1.0",
"markdown-it-container": "^3.0.0",
"markdown-it-emoji": "^3.0.0",
"mermaid": "11.12.1",
"mermaid": "11.13.0",
"mime-types": "^3.0.1",
"mobx": "^4.15.4",
"mobx-react": "^6.3.1",
@@ -287,7 +285,6 @@
"@faker-js/faker": "^8.4.1",
"@relative-ci/agent": "^4.3.1",
"@types/addressparser": "^1.0.3",
"@types/body-scroll-lock": "^3.1.2",
"@types/cookie": "0.6.0",
"@types/crypto-js": "^4.2.2",
"@types/diff": "^5.0.9",
@@ -382,7 +379,6 @@
"@hocuspocus/server": "1.1.2",
"fengari": "0.1.5",
"prosemirror-transform": "1.10.0",
"body-scroll-lock": "^4.0.0-beta.0",
"d3": "^7.0.0",
"debug": "4.3.4",
"node-fetch": "^2.7.0",
+22 -2
View File
@@ -95,12 +95,20 @@ router.post(
const { user } = ctx.state.auth;
authorize(user, "createUserPasskey", user.team);
// Fetch existing passkeys to exclude them from registration
const existingPasskeys = await UserPasskey.findAll({
where: { userId: user.id },
});
const options = await generateRegistrationOptions({
rpName,
rpID: getRpID(ctx),
userID: isoBase64URL.toBuffer(user.id),
userName: user.email || user.name,
// Don't exclude credentials, so we can detect if one is already registered (optional)
excludeCredentials: existingPasskeys.map((pk) => ({
id: pk.credentialId,
transports: pk.transports as AuthenticatorTransportFuture[],
})),
authenticatorSelection: {
residentKey: "preferred",
userVerification: "preferred",
@@ -154,6 +162,7 @@ router.post(
}
const { verified, registrationInfo } = verification;
const ZERO_AAGUID = "00000000-0000-0000-0000-000000000000";
if (verified && registrationInfo) {
const { credential, aaguid } = registrationInfo;
@@ -166,7 +175,7 @@ router.post(
const userAgent = ctx.request.get("user-agent");
const transports = body.response.transports || [];
// Check if already exists
// Check if already exists by credential ID
const existing = await UserPasskey.findOne({
where: { credentialId: credentialIdBase64 },
});
@@ -183,6 +192,17 @@ router.post(
aaguid,
});
} else {
// Check if user already has a passkey from the same authenticator
if (aaguid && aaguid !== ZERO_AAGUID) {
const duplicateDevice = await UserPasskey.findOne({
where: { userId: user.id, aaguid },
});
if (duplicateDevice) {
throw ValidationError("You already have a passkey on this device");
}
}
await UserPasskey.createWithCtx(ctx, {
userId: user.id,
credentialId: credentialIdBase64,
+6
View File
@@ -0,0 +1,6 @@
{
"id": "search-postgres",
"name": "PostgreSQL Search",
"priority": 0,
"description": "Full-text search powered by PostgreSQL tsvector."
}
@@ -4,7 +4,6 @@ import {
SortFilter,
StatusFilter,
} from "@shared/types";
import SearchHelper from "@server/models/helpers/SearchHelper";
import {
buildDocument,
buildDraftDocument,
@@ -14,15 +13,19 @@ import {
buildShare,
buildGroup,
} from "@server/test/factories";
import UserMembership from "../UserMembership";
import GroupMembership from "../GroupMembership";
import UserMembership from "@server/models/UserMembership";
import GroupMembership from "@server/models/GroupMembership";
import SearchProviderManager from "@server/utils/SearchProviderManager";
import PostgresSearchProvider from "./PostgresSearchProvider";
const provider = SearchProviderManager.getProvider();
beforeEach(async () => {
jest.resetAllMocks();
await buildDocument();
});
describe("SearchHelper", () => {
describe("PostgresSearchProvider", () => {
describe("#searchForTeam", () => {
it("should return search results from public collections", async () => {
const team = await buildTeam();
@@ -34,7 +37,7 @@ describe("SearchHelper", () => {
collectionId: collection.id,
title: "test",
});
const { results } = await SearchHelper.searchForTeam(team, {
const { results } = await provider.searchForTeam(team, {
query: "test",
});
expect(results.length).toBe(1);
@@ -58,7 +61,7 @@ describe("SearchHelper", () => {
title: "document 2",
}),
]);
const { results } = await SearchHelper.searchForTeam(team);
const { results } = await provider.searchForTeam(team);
expect(results.length).toBe(2);
expect(results.map((r) => r.document.id).sort()).toEqual(
documents.map((doc) => doc.id).sort()
@@ -76,7 +79,7 @@ describe("SearchHelper", () => {
collectionId: collection.id,
title: "test",
});
const { results } = await SearchHelper.searchForTeam(team, {
const { results } = await provider.searchForTeam(team, {
query: "test",
});
expect(results.length).toBe(0);
@@ -93,7 +96,7 @@ describe("SearchHelper", () => {
collectionId: collection.id,
title: "test",
});
const { results } = await SearchHelper.searchForTeam(team, {
const { results } = await provider.searchForTeam(team, {
query: "test",
collectionId: collection.id,
});
@@ -122,7 +125,7 @@ describe("SearchHelper", () => {
includeChildDocuments: true,
});
const { results } = await SearchHelper.searchForTeam(team, {
const { results } = await provider.searchForTeam(team, {
query: "test",
collectionId: collection.id,
share,
@@ -132,7 +135,7 @@ describe("SearchHelper", () => {
it("should handle no collections", async () => {
const team = await buildTeam();
const { results } = await SearchHelper.searchForTeam(team, {
const { results } = await provider.searchForTeam(team, {
query: "test",
});
expect(results.length).toBe(0);
@@ -148,7 +151,7 @@ describe("SearchHelper", () => {
collectionId: collection.id,
title: "test with backslash \\",
});
const { results } = await SearchHelper.searchForTeam(team, {
const { results } = await provider.searchForTeam(team, {
query: "test with backslash \\",
});
expect(results.length).toBe(1);
@@ -170,7 +173,7 @@ describe("SearchHelper", () => {
collectionId: collection.id,
title: "test number 2",
});
const { total } = await SearchHelper.searchForTeam(team, {
const { total } = await provider.searchForTeam(team, {
query: "test",
});
expect(total).toBe(2);
@@ -188,7 +191,7 @@ describe("SearchHelper", () => {
});
document.title = "change";
await document.save();
const { total } = await SearchHelper.searchForTeam(team, {
const { total } = await provider.searchForTeam(team, {
query: "test number",
});
expect(total).toBe(1);
@@ -206,7 +209,7 @@ describe("SearchHelper", () => {
});
document.title = "change";
await document.save();
const { total } = await SearchHelper.searchForTeam(team, {
const { total } = await provider.searchForTeam(team, {
query: "title doesn't exist",
});
expect(total).toBe(0);
@@ -234,7 +237,7 @@ describe("SearchHelper", () => {
deletedAt: new Date(),
title: "test",
});
const { results } = await SearchHelper.searchForUser(user, {
const { results } = await provider.searchForUser(user, {
query: "test",
});
expect(results.length).toBe(1);
@@ -263,7 +266,7 @@ describe("SearchHelper", () => {
title: "document 2",
}),
]);
const { results } = await SearchHelper.searchForUser(user);
const { results } = await provider.searchForUser(user);
expect(results.length).toBe(2);
expect(results.map((r) => r.document.id).sort()).toEqual(
documents.map((doc) => doc.id).sort()
@@ -291,7 +294,7 @@ describe("SearchHelper", () => {
title: "document 2",
}),
]);
const { results } = await SearchHelper.searchForUser(user, {
const { results } = await provider.searchForUser(user, {
collectionId: collection.id,
});
expect(results.length).toBe(2);
@@ -339,7 +342,7 @@ describe("SearchHelper", () => {
title: "document 2 in collection 2",
}),
]);
const { results } = await SearchHelper.searchForUser(user, {
const { results } = await provider.searchForUser(user, {
collectionId: collection1.id,
});
expect(results.length).toBe(2);
@@ -351,7 +354,7 @@ describe("SearchHelper", () => {
it("should handle no collections", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const { results } = await SearchHelper.searchForUser(user, {
const { results } = await provider.searchForUser(user, {
query: "test",
});
expect(results.length).toBe(0);
@@ -381,7 +384,7 @@ describe("SearchHelper", () => {
title: "test",
archivedAt: new Date(),
});
const { results } = await SearchHelper.searchForUser(user, {
const { results } = await provider.searchForUser(user, {
query: "test",
statusFilter: [StatusFilter.Draft],
});
@@ -406,7 +409,7 @@ describe("SearchHelper", () => {
permission: DocumentPermission.Read,
});
const { results } = await SearchHelper.searchForUser(user, {
const { results } = await provider.searchForUser(user, {
query: "test",
statusFilter: [StatusFilter.Published, StatusFilter.Archived],
});
@@ -437,7 +440,7 @@ describe("SearchHelper", () => {
title: "test",
archivedAt: new Date(),
});
const { results } = await SearchHelper.searchForUser(user, {
const { results } = await provider.searchForUser(user, {
query: "test",
statusFilter: [StatusFilter.Published],
});
@@ -474,7 +477,7 @@ describe("SearchHelper", () => {
title: "test",
archivedAt: new Date(),
});
const { results } = await SearchHelper.searchForUser(user, {
const { results } = await provider.searchForUser(user, {
query: "test",
statusFilter: [StatusFilter.Archived],
});
@@ -502,7 +505,7 @@ describe("SearchHelper", () => {
title: "test",
archivedAt: new Date(),
});
const { results } = await SearchHelper.searchForUser(user, {
const { results } = await provider.searchForUser(user, {
query: "test",
statusFilter: [StatusFilter.Archived, StatusFilter.Published],
});
@@ -530,7 +533,7 @@ describe("SearchHelper", () => {
title: "archived not draft",
archivedAt: new Date(),
});
const { results } = await SearchHelper.searchForUser(user, {
const { results } = await provider.searchForUser(user, {
query: "draft",
statusFilter: [StatusFilter.Published, StatusFilter.Draft],
});
@@ -558,7 +561,7 @@ describe("SearchHelper", () => {
title: "archived not draft",
archivedAt: new Date(),
});
const { results } = await SearchHelper.searchForUser(user, {
const { results } = await provider.searchForUser(user, {
query: "draft",
statusFilter: [StatusFilter.Draft, StatusFilter.Archived],
});
@@ -584,7 +587,7 @@ describe("SearchHelper", () => {
collectionId: collection.id,
title: "test number 2",
});
const { total } = await SearchHelper.searchForUser(user, {
const { total } = await provider.searchForUser(user, {
query: "test",
});
expect(total).toBe(2);
@@ -605,7 +608,7 @@ describe("SearchHelper", () => {
});
document.title = "change";
await document.save();
const { total } = await SearchHelper.searchForUser(user, {
const { total } = await provider.searchForUser(user, {
query: "test number",
});
expect(total).toBe(1);
@@ -626,7 +629,7 @@ describe("SearchHelper", () => {
});
document.title = "change";
await document.save();
const { total } = await SearchHelper.searchForUser(user, {
const { total } = await provider.searchForUser(user, {
query: "title doesn't exist",
});
expect(total).toBe(0);
@@ -647,7 +650,7 @@ describe("SearchHelper", () => {
});
document.title = "change";
await document.save();
const { total } = await SearchHelper.searchForUser(user, {
const { total } = await provider.searchForUser(user, {
query: `"test number"`,
});
expect(total).toBe(1);
@@ -668,7 +671,7 @@ describe("SearchHelper", () => {
});
document.title = "change";
await document.save();
const { total } = await SearchHelper.searchForUser(user, {
const { total } = await provider.searchForUser(user, {
query: "env: ",
});
expect(total).toBe(1);
@@ -681,7 +684,7 @@ describe("SearchHelper", () => {
const collection = await buildCollection({
userId: otherUser.id,
teamId: team.id,
permission: null, // private collection
permission: null,
});
const document = await buildDocument({
userId: otherUser.id,
@@ -690,7 +693,6 @@ describe("SearchHelper", () => {
title: "group test document",
});
// Document with no access should not appear in results
await buildDocument({
userId: otherUser.id,
teamId: team.id,
@@ -698,7 +700,6 @@ describe("SearchHelper", () => {
title: "group test document 2",
});
// Create a group and add the user to it
const group = await buildGroup({
teamId: team.id,
});
@@ -708,14 +709,13 @@ describe("SearchHelper", () => {
},
});
// Add group membership to the document
await GroupMembership.create({
createdById: otherUser.id,
groupId: group.id,
documentId: document.id,
});
const { results } = await SearchHelper.searchForUser(user, {
const { results } = await provider.searchForUser(user, {
query: "group test",
});
@@ -739,7 +739,7 @@ describe("SearchHelper", () => {
collectionId: collection.id,
title: "test",
});
const documents = await SearchHelper.searchTitlesForUser(user, {
const documents = await provider.searchTitlesForUser(user, {
query: "test",
});
expect(documents.length).toBe(1);
@@ -774,7 +774,7 @@ describe("SearchHelper", () => {
collectionId: collection1.id,
title: "test",
});
const documents = await SearchHelper.searchTitlesForUser(user, {
const documents = await provider.searchTitlesForUser(user, {
query: "test",
collectionId: collection.id,
});
@@ -785,7 +785,7 @@ describe("SearchHelper", () => {
it("should handle no collections", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const documents = await SearchHelper.searchTitlesForUser(user, {
const documents = await provider.searchTitlesForUser(user, {
query: "test",
});
expect(documents.length).toBe(0);
@@ -815,7 +815,7 @@ describe("SearchHelper", () => {
title: "test",
archivedAt: new Date(),
});
const documents = await SearchHelper.searchTitlesForUser(user, {
const documents = await provider.searchTitlesForUser(user, {
query: "test",
statusFilter: [StatusFilter.Draft],
});
@@ -846,7 +846,7 @@ describe("SearchHelper", () => {
title: "test",
archivedAt: new Date(),
});
const documents = await SearchHelper.searchTitlesForUser(user, {
const documents = await provider.searchTitlesForUser(user, {
query: "test",
statusFilter: [StatusFilter.Published],
});
@@ -883,7 +883,7 @@ describe("SearchHelper", () => {
title: "test",
archivedAt: new Date(),
});
const documents = await SearchHelper.searchTitlesForUser(user, {
const documents = await provider.searchTitlesForUser(user, {
query: "test",
statusFilter: [StatusFilter.Archived],
});
@@ -911,7 +911,7 @@ describe("SearchHelper", () => {
title: "test",
archivedAt: new Date(),
});
const documents = await SearchHelper.searchTitlesForUser(user, {
const documents = await provider.searchTitlesForUser(user, {
query: "test",
statusFilter: [StatusFilter.Archived, StatusFilter.Published],
});
@@ -939,7 +939,7 @@ describe("SearchHelper", () => {
title: "archived not draft",
archivedAt: new Date(),
});
const documents = await SearchHelper.searchTitlesForUser(user, {
const documents = await provider.searchTitlesForUser(user, {
query: "draft",
statusFilter: [StatusFilter.Published, StatusFilter.Draft],
});
@@ -967,7 +967,7 @@ describe("SearchHelper", () => {
title: "archived not draft",
archivedAt: new Date(),
});
const documents = await SearchHelper.searchTitlesForUser(user, {
const documents = await provider.searchTitlesForUser(user, {
query: "draft",
statusFilter: [StatusFilter.Draft, StatusFilter.Archived],
});
@@ -981,7 +981,7 @@ describe("SearchHelper", () => {
const collection = await buildCollection({
userId: otherUser.id,
teamId: team.id,
permission: null, // private collection
permission: null,
});
const document = await buildDocument({
userId: otherUser.id,
@@ -990,7 +990,6 @@ describe("SearchHelper", () => {
title: "group title test document",
});
// Document with no access should not appear in results
await buildDocument({
userId: otherUser.id,
teamId: team.id,
@@ -998,7 +997,6 @@ describe("SearchHelper", () => {
title: "group title test document 2",
});
// Create a group and add the user to it
const group = await buildGroup({
teamId: team.id,
});
@@ -1008,14 +1006,13 @@ describe("SearchHelper", () => {
},
});
// Add group membership to the document
await GroupMembership.create({
createdById: otherUser.id,
groupId: group.id,
documentId: document.id,
});
const documents = await SearchHelper.searchTitlesForUser(user, {
const documents = await provider.searchTitlesForUser(user, {
query: "group title",
});
@@ -1039,7 +1036,7 @@ describe("SearchHelper", () => {
name: "Other Collection",
});
const results = await SearchHelper.searchCollectionsForUser(user, {
const results = await provider.searchCollectionsForUser(user, {
query: "test",
});
@@ -1061,7 +1058,7 @@ describe("SearchHelper", () => {
name: "Beta",
});
const results = await SearchHelper.searchCollectionsForUser(user);
const results = await provider.searchCollectionsForUser(user);
expect(results.length).toBe(2);
expect(results[0].id).toBe(collection1.id);
@@ -1096,7 +1093,7 @@ describe("SearchHelper", () => {
title: "Beta Document",
});
const { results } = await SearchHelper.searchForUser(user, {
const { results } = await provider.searchForUser(user, {
sort: SortFilter.Title,
direction: DirectionFilter.ASC,
});
@@ -1133,7 +1130,7 @@ describe("SearchHelper", () => {
title: "Beta Document",
});
const { results } = await SearchHelper.searchForUser(user, {
const { results } = await provider.searchForUser(user, {
sort: SortFilter.Title,
direction: DirectionFilter.DESC,
});
@@ -1176,7 +1173,7 @@ describe("SearchHelper", () => {
updatedAt: new Date("2023-12-01"),
});
const { results } = await SearchHelper.searchForUser(user, {
const { results } = await provider.searchForUser(user, {
sort: SortFilter.CreatedAt,
direction: DirectionFilter.ASC,
});
@@ -1216,7 +1213,7 @@ describe("SearchHelper", () => {
updatedAt: new Date("2023-06-01"),
});
const { results } = await SearchHelper.searchForUser(user);
const { results } = await provider.searchForUser(user);
expect(results.length).toBe(3);
expect(results[0].document.id).toBe(doc2.id);
@@ -1252,7 +1249,7 @@ describe("SearchHelper", () => {
updatedAt: new Date("2023-01-01"),
});
const { results } = await SearchHelper.searchForUser(user, {
const { results } = await provider.searchForUser(user, {
query: "search",
});
@@ -1288,7 +1285,7 @@ describe("SearchHelper", () => {
updatedAt: new Date("2025-12-01"),
});
const { results } = await SearchHelper.searchForUser(user, {
const { results } = await provider.searchForUser(user, {
query: "search",
sort: SortFilter.UpdatedAt,
direction: DirectionFilter.DESC,
@@ -1326,7 +1323,7 @@ describe("SearchHelper", () => {
});
// Without popularity boost, pure relevance should win
const { results: withoutBoost } = await SearchHelper.searchForTeam(team, {
const { results: withoutBoost } = await provider.searchForTeam(team, {
query: "testing",
usePopularityBoost: false,
});
@@ -1335,7 +1332,7 @@ describe("SearchHelper", () => {
expect(withoutBoost[0].document.id).toBe(relevantDoc.id);
// With popularity boost, the popular document may rank higher
const { results: withBoost } = await SearchHelper.searchForTeam(team, {
const { results: withBoost } = await provider.searchForTeam(team, {
query: "testing",
usePopularityBoost: true,
});
@@ -1350,22 +1347,28 @@ describe("SearchHelper", () => {
describe("webSearchQuery", () => {
it("should correctly sanitize query", () => {
expect(SearchHelper.webSearchQuery("one/two")).toBe("one/two:*");
expect(SearchHelper.webSearchQuery("one\\two")).toBe("one\\\\two:*");
expect(SearchHelper.webSearchQuery("test''")).toBe("test");
expect(PostgresSearchProvider.webSearchQuery("one/two")).toBe(
"one/two:*"
);
expect(PostgresSearchProvider.webSearchQuery("one\\two")).toBe(
"one\\\\two:*"
);
expect(PostgresSearchProvider.webSearchQuery("test''")).toBe("test");
});
it("should wildcard unquoted queries", () => {
expect(SearchHelper.webSearchQuery("test")).toBe("test:*");
expect(SearchHelper.webSearchQuery("'")).toBe("");
expect(SearchHelper.webSearchQuery("'quoted'")).toBe(`"quoted":*`);
expect(PostgresSearchProvider.webSearchQuery("test")).toBe("test:*");
expect(PostgresSearchProvider.webSearchQuery("'")).toBe("");
expect(PostgresSearchProvider.webSearchQuery("'quoted'")).toBe(
`"quoted":*`
);
});
it("should wildcard multi-word queries", () => {
expect(SearchHelper.webSearchQuery("this is a test")).toBe(
expect(PostgresSearchProvider.webSearchQuery("this is a test")).toBe(
"this&is&a&test:*"
);
});
it("should not wildcard quoted queries", () => {
expect(SearchHelper.webSearchQuery(`"this is a test"`)).toBe(
expect(PostgresSearchProvider.webSearchQuery(`"this is a test"`)).toBe(
`"this<->is<->a<->test"`
);
});
@@ -11,63 +11,23 @@ import type {
WhereOptions,
} from "sequelize";
import { Op, Sequelize } from "sequelize";
import type { DateFilter } from "@shared/types";
import { DirectionFilter, SortFilter } from "@shared/types";
import { StatusFilter } from "@shared/types";
import type { SearchableModel } from "@shared/types";
import { DirectionFilter, SortFilter, StatusFilter } from "@shared/types";
import { regexIndexOf, regexLastIndexOf } from "@shared/utils/string";
import { getUrls } from "@shared/utils/urls";
import { ValidationError } from "@server/errors";
import Collection from "@server/models/Collection";
import type Comment from "@server/models/Comment";
import Document from "@server/models/Document";
import type Share from "@server/models/Share";
import Team from "@server/models/Team";
import User from "@server/models/User";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import { sequelize } from "@server/storage/database";
import { DocumentHelper } from "./DocumentHelper";
type SearchResponse = {
results: {
/** The search ranking, for sorting results */
ranking: number;
/** A snippet of contextual text around the search result */
context?: string;
/** The document result */
document: Document;
}[];
/** The total number of results for the search query without pagination */
total: number;
};
type SearchOptions = {
/** The query limit for pagination */
limit?: number;
/** The query offset for pagination */
offset?: number;
/** The text to search for */
query?: string;
/** Limit results to a collection. Authorization is presumed to have been done before passing to this helper. */
collectionId?: string | null;
/** Limit results to a shared document. */
share?: Share;
/** Limit results to a date range. */
dateFilter?: DateFilter;
/** Status of the documents to return */
statusFilter?: StatusFilter[];
/** Limit results to a list of documents. */
documentIds?: string[];
/** Limit results to a list of users that collaborated on the document. */
collaboratorIds?: string[];
/** The minimum number of words to be returned in the contextual snippet */
snippetMinWords?: number;
/** The maximum number of words to be returned in the contextual snippet */
snippetMaxWords?: number;
/** The field to sort results by */
sort?: SortFilter;
/** The sort direction */
direction?: DirectionFilter;
/** Whether to boost results by popularity score. Defaults to true. */
usePopularityBoost?: boolean;
};
import type {
SearchOptions,
SearchResponse,
} from "@server/utils/BaseSearchProvider";
import { BaseSearchProvider } from "@server/utils/BaseSearchProvider";
type RankedDocument = Document & {
id: string;
@@ -76,24 +36,31 @@ type RankedDocument = Document & {
};
};
export default class SearchHelper {
/**
* Search provider that uses PostgreSQL full-text search via tsvector.
* Indexing is handled by database triggers, so index/remove/updateMetadata
* are no-ops.
*/
export default class PostgresSearchProvider extends BaseSearchProvider {
id = "postgres";
/**
* The maximum length of a search query.
*/
public static maxQueryLength = 1000;
/**
* Cached regex pattern for single quotes to avoid recompilation
* Cached regex pattern for single quotes to avoid recompilation.
*/
private static readonly SINGLE_QUOTE_REGEX = /'+/g;
/**
* Cached regex pattern for quoted queries
* Cached regex pattern for quoted queries.
*/
private static readonly QUOTED_QUERY_REGEX = /"([^"]*)"/g;
/**
* Cached regex pattern for break characters
* Cached regex pattern for break characters.
*/
private static readonly BREAK_CHARS_REGEX = new RegExp(
`[ .,"'\n。!?!?…]`,
@@ -101,7 +68,7 @@ export default class SearchHelper {
);
/**
* Cached stop words set for efficient lookup
* Cached stop words set for efficient lookup.
* Based on: https://github.com/postgres/postgres/blob/fc0d0ce978752493868496be6558fa17b7c4c3cf/src/backend/snowball/stopwords/english.stop
*/
private static readonly STOP_WORDS = new Set([
@@ -215,13 +182,13 @@ export default class SearchHelper {
"should",
]);
public static async searchForTeam(
async searchForTeam(
team: Team,
options: SearchOptions = {}
): Promise<SearchResponse> {
const { limit = 15, offset = 0, query } = options;
const where = await this.buildWhere(team, {
const where = await PostgresSearchProvider.buildWhere(team, {
...options,
statusFilter: [...(options.statusFilter || []), StatusFilter.Published],
});
@@ -256,7 +223,7 @@ export default class SearchHelper {
});
}
const findOptions = this.buildFindOptions({
const findOptions = PostgresSearchProvider.buildFindOptions({
query,
sort: options.sort,
direction: options.direction,
@@ -292,7 +259,7 @@ export default class SearchHelper {
],
});
return this.buildResponse({
return PostgresSearchProvider.buildResponse({
query,
results,
documents,
@@ -306,12 +273,12 @@ export default class SearchHelper {
}
}
public static async searchTitlesForUser(
async searchTitlesForUser(
user: User,
options: SearchOptions = {}
): Promise<Document[]> {
const { limit = 15, offset = 0, query, ...rest } = options;
const where = await this.buildWhere(user, rest);
const where = await PostgresSearchProvider.buildWhere(user, rest);
if (query) {
where[Op.and].push({
@@ -379,7 +346,7 @@ export default class SearchHelper {
});
}
public static async searchCollectionsForUser(
async searchCollectionsForUser(
user: User,
options: SearchOptions = {}
): Promise<Collection[]> {
@@ -408,15 +375,15 @@ export default class SearchHelper {
});
}
public static async searchForUser(
async searchForUser(
user: User,
options: SearchOptions = {}
): Promise<SearchResponse> {
const { limit = 15, offset = 0, query } = options;
const where = await this.buildWhere(user, options);
const where = await PostgresSearchProvider.buildWhere(user, options);
const findOptions = this.buildFindOptions({
const findOptions = PostgresSearchProvider.buildFindOptions({
query,
sort: options.sort,
direction: options.direction,
@@ -484,7 +451,7 @@ export default class SearchHelper {
: countQuery,
]);
return this.buildResponse({
return PostgresSearchProvider.buildResponse({
query,
results,
documents,
@@ -498,6 +465,49 @@ export default class SearchHelper {
}
}
/**
* No-op for PostgreSQL indexing is handled by database triggers.
*
* @param _model - unused.
* @param _item - unused.
*/
async index(
_model: SearchableModel,
_item: Document | Collection | Comment
): Promise<void> {
// PostgreSQL uses tsvector triggers for indexing
}
/**
* No-op for PostgreSQL removal is handled by database cascades.
*
* @param _model - unused.
* @param _id - unused.
* @param _teamId - unused.
*/
async remove(
_model: SearchableModel,
_id: string,
_teamId: string
): Promise<void> {
// PostgreSQL handles removal via cascading deletes
}
/**
* No-op for PostgreSQL metadata is stored in the same tables.
*
* @param _model - unused.
* @param _id - unused.
* @param _metadata - unused.
*/
async updateMetadata(
_model: SearchableModel,
_id: string,
_metadata: Record<string, unknown>
): Promise<void> {
// PostgreSQL metadata lives in the same row as the document
}
private static buildFindOptions({
query,
sort,
@@ -519,7 +529,7 @@ export default class SearchHelper {
: `ts_rank("searchVector", to_tsquery('english', :query))`;
attributes.push([Sequelize.literal(rankExpression), "searchRanking"]);
replacements["query"] = this.webSearchQuery(query);
replacements["query"] = PostgresSearchProvider.webSearchQuery(query);
}
// When searching with a query and no explicit sort, prioritize search
@@ -551,8 +561,10 @@ export default class SearchHelper {
private static buildResultContext(document: Document, query: string) {
// Reset regex lastIndex to avoid state issues with global regex
this.QUOTED_QUERY_REGEX.lastIndex = 0;
const quotedQueries = Array.from(query.matchAll(this.QUOTED_QUERY_REGEX));
PostgresSearchProvider.QUOTED_QUERY_REGEX.lastIndex = 0;
const quotedQueries = Array.from(
query.matchAll(PostgresSearchProvider.QUOTED_QUERY_REGEX)
);
const text = DocumentHelper.toPlainText(document);
// Regex to highlight quoted queries as ts_headline will not do this by default due to stemming.
@@ -562,7 +574,7 @@ export default class SearchHelper {
fullMatchRegex.source,
...(quotedQueries.length
? quotedQueries.map((match) => escapeRegExp(match[1]))
: this.removeStopWords(query)
: PostgresSearchProvider.removeStopWords(query)
.trim()
.split(" ")
.map((match) => `\\b${escapeRegExp(match)}\\b`)),
@@ -571,8 +583,8 @@ export default class SearchHelper {
);
// Reset regex lastIndex to avoid state issues with global regex
this.BREAK_CHARS_REGEX.lastIndex = 0;
const breakCharsRegex = this.BREAK_CHARS_REGEX;
PostgresSearchProvider.BREAK_CHARS_REGEX.lastIndex = 0;
const breakCharsRegex = PostgresSearchProvider.BREAK_CHARS_REGEX;
// chop text around the first match, prefer the first full match if possible.
const fullMatchIndex = text.search(fullMatchRegex);
@@ -715,15 +727,17 @@ export default class SearchHelper {
let likelyUrls = getUrls(options.query);
// remove likely urls, and escape the rest of the query.
let limitedQuery = this.escapeQuery(
let limitedQuery = PostgresSearchProvider.escapeQuery(
likelyUrls
.reduce((q, url) => q.replace(url, ""), options.query)
.slice(0, this.maxQueryLength)
.slice(0, PostgresSearchProvider.maxQueryLength)
.trim()
);
// Escape the URLs
likelyUrls = likelyUrls.map((url) => this.escapeQuery(url));
likelyUrls = likelyUrls.map((url) =>
PostgresSearchProvider.escapeQuery(url)
);
// Extract quoted queries and add them to the where clause, up to a maximum of 3 total.
const quotedQueries = Array.from(limitedQuery.matchAll(/"([^"]*)"/g)).map(
@@ -785,7 +799,9 @@ export default class SearchHelper {
return {
ranking: result.dataValues.searchRanking,
context: query ? this.buildResultContext(document, query) : undefined,
context: query
? PostgresSearchProvider.buildResultContext(document, query)
: undefined,
document,
};
}),
@@ -794,22 +810,26 @@ export default class SearchHelper {
}
/**
* Convert a user search query into a format that can be used by Postgres
* Convert a user search query into a format that can be used by Postgres.
*
* @param query The user search query
* @returns The query formatted for Postgres ts_query
* @param query - the user search query.
* @returns the query formatted for Postgres ts_query.
*/
public static webSearchQuery(query: string): string {
// limit length of search queries as we're using regex against untrusted input
let limitedQuery = this.escapeQuery(query.slice(0, this.maxQueryLength));
let limitedQuery = PostgresSearchProvider.escapeQuery(
query.slice(0, PostgresSearchProvider.maxQueryLength)
);
const quotedSearch =
limitedQuery.startsWith('"') && limitedQuery.endsWith('"');
// Replace single quote characters with &.
// Reset regex lastIndex to avoid state issues with global regex
this.SINGLE_QUOTE_REGEX.lastIndex = 0;
const singleQuotes = limitedQuery.matchAll(this.SINGLE_QUOTE_REGEX);
PostgresSearchProvider.SINGLE_QUOTE_REGEX.lastIndex = 0;
const singleQuotes = limitedQuery.matchAll(
PostgresSearchProvider.SINGLE_QUOTE_REGEX
);
for (const match of singleQuotes) {
if (
@@ -851,11 +871,9 @@ export default class SearchHelper {
}
private static removeStopWords(query: string): string {
// Based on:
// https://github.com/postgres/postgres/blob/fc0d0ce978752493868496be6558fa17b7c4c3cf/src/backend/snowball/stopwords/english.stop
return query
.split(" ")
.filter((word) => !this.STOP_WORDS.has(word))
.filter((word) => !PostgresSearchProvider.STOP_WORDS.has(word))
.join(" ");
}
}
+13
View File
@@ -0,0 +1,13 @@
import { PluginManager, Hook } from "@server/utils/PluginManager";
import config from "../plugin.json";
import PostgresSearchProvider from "./PostgresSearchProvider";
const provider = new PostgresSearchProvider();
PluginManager.add([
{
...config,
type: Hook.SearchProvider,
value: provider,
},
]);
+2 -2
View File
@@ -23,7 +23,7 @@ import {
AuthenticationProvider,
Comment,
} from "@server/models";
import SearchHelper from "@server/models/helpers/SearchHelper";
import SearchProviderManager from "@server/utils/SearchProviderManager";
import { can } from "@server/policies";
import type { APIContext } from "@server/types";
import { safeEqual } from "@server/utils/crypto";
@@ -238,7 +238,7 @@ router.post(
return;
}
const { results, total } = await SearchHelper.searchForUser(user, options);
const { results, total } = await SearchProviderManager.getProvider().searchForUser(user, options);
await SearchQuery.create({
userId: user ? user.id : null,
+7 -4
View File
@@ -1,5 +1,4 @@
import type { Optional } from "utility-types";
import { ProsemirrorHelper as SharedProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { TextHelper } from "@shared/utils/TextHelper";
import { Document, type Template } from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
@@ -26,6 +25,8 @@ type Props = Optional<
| "publishedAt"
| "createdAt"
| "updatedAt"
| "createdById"
| "lastModifiedById"
>
> & {
state?: Buffer;
@@ -59,6 +60,8 @@ export default async function documentCreator(
editorVersion,
publishedAt,
sourceMetadata,
createdById,
lastModifiedById,
}: Props
): Promise<Document> {
const { user } = ctx.state.auth;
@@ -94,7 +97,7 @@ export default async function documentCreator(
: text
? ProsemirrorHelper.toProsemirror(text).toJSON()
: template
? SharedProsemirrorHelper.replaceTemplateVariables(
? ProsemirrorHelper.replaceTemplateVariables(
await DocumentHelper.toJSON(template),
user
)
@@ -109,8 +112,8 @@ export default async function documentCreator(
teamId: user.teamId,
createdAt,
updatedAt: updatedAt ?? createdAt,
lastModifiedById: user.id,
createdById: user.id,
lastModifiedById: lastModifiedById ?? createdById ?? user.id,
createdById: createdById ?? user.id,
templateId,
publishedAt,
importId,
+8
View File
@@ -772,6 +772,14 @@ export class Environment {
environment.ALLOWED_PRIVATE_IP_ADDRESSES
);
/**
* The search provider to use. Defaults to "postgres" which uses PostgreSQL
* full-text search. Alternative providers can be registered via plugins.
*/
@IsOptional()
public SEARCH_PROVIDER =
this.toOptionalString(environment.SEARCH_PROVIDER) ?? "postgres";
/**
* The product name
*/
@@ -0,0 +1,14 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
return queryInterface.addColumn("teams", "guidanceMCP", {
type: Sequelize.TEXT,
allowNull: true,
});
},
down: async (queryInterface) => {
return queryInterface.removeColumn("teams", "guidanceMCP");
},
};
+19
View File
@@ -1,4 +1,5 @@
import { randomString } from "@shared/random";
import { Scope } from "@shared/types";
import { buildApiKey } from "@server/test/factories";
import ApiKey from "./ApiKey";
@@ -110,5 +111,23 @@ describe("#ApiKey", () => {
expect(apiKey.canAccess("/api/documents.create")).toBe(false);
expect(apiKey.canAccess("/api/collections.create")).toBe(false);
});
it("should allow MCP access for scoped API keys", async () => {
const apiKey = await buildApiKey({
name: "Dev",
scope: [Scope.Read],
});
expect(apiKey.canAccess("/mcp")).toBe(true);
expect(apiKey.canAccess("/mcp/")).toBe(true);
});
it("should allow MCP access for unscoped API keys", async () => {
const apiKey = await buildApiKey({
name: "Dev",
});
expect(apiKey.canAccess("/mcp")).toBe(true);
});
});
});
+6
View File
@@ -176,6 +176,12 @@ class ApiKey extends ParanoidModel<
return true;
}
// MCP endpoint access is allowed if the key has any valid scope.
// Fine-grained scope enforcement happens at the tool level.
if (path.startsWith("/mcp")) {
return this.scope.length > 0;
}
return AuthenticationHelper.canAccess(path, this.scope);
};
}
+189
View File
@@ -0,0 +1,189 @@
import { v4 as uuidv4 } from "uuid";
import { MentionType } from "@shared/types";
import { buildComment, buildDocument, buildUser } from "@server/test/factories";
import Comment from "./Comment";
describe("Comment", () => {
describe("toPlainText", () => {
it("should convert simple text to plain text", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const comment = await buildComment({
userId: user.id,
documentId: document.id,
});
const text = comment.toPlainText();
expect(text).toBe("test");
});
it("should convert comment with mention to plain text", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const comment = await Comment.create({
documentId: document.id,
createdById: user.id,
data: {
type: "doc",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: "Hello ",
},
{
type: "mention",
attrs: {
type: MentionType.User,
label: "Jane",
modelId: uuidv4(),
id: uuidv4(),
},
},
],
},
],
},
});
const text = comment.toPlainText();
expect(text).toBe("Hello @Jane");
});
it("should convert comment with document mention to plain text", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const comment = await Comment.create({
documentId: document.id,
createdById: user.id,
data: {
type: "doc",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: "See ",
},
{
type: "mention",
attrs: {
type: MentionType.Document,
label: "My Document",
modelId: uuidv4(),
id: uuidv4(),
},
},
],
},
],
},
});
const text = comment.toPlainText();
expect(text).toBe("See My Document");
});
});
describe("resolve", () => {
it("should resolve the comment", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const comment = await buildComment({
userId: user.id,
documentId: document.id,
});
comment.resolve(user);
expect(comment.isResolved).toBe(true);
expect(comment.resolvedById).toBe(user.id);
expect(comment.resolvedAt).toBeTruthy();
});
it("should throw if already resolved", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const comment = await buildComment({
userId: user.id,
documentId: document.id,
});
comment.resolve(user);
expect(() => comment.resolve(user)).toThrow();
});
it("should throw if comment is a reply", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const parent = await buildComment({
userId: user.id,
documentId: document.id,
});
const reply = await buildComment({
userId: user.id,
documentId: document.id,
parentCommentId: parent.id,
});
expect(() => reply.resolve(user)).toThrow();
});
});
describe("unresolve", () => {
it("should unresolve the comment", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const comment = await buildComment({
userId: user.id,
documentId: document.id,
});
comment.resolve(user);
comment.unresolve();
expect(comment.isResolved).toBe(false);
expect(comment.resolvedById).toBeNull();
expect(comment.resolvedAt).toBeNull();
});
it("should throw if not resolved", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const comment = await buildComment({
userId: user.id,
documentId: document.id,
});
expect(() => comment.unresolve()).toThrow();
});
});
});
+2 -2
View File
@@ -13,7 +13,7 @@ import {
import type { ProsemirrorData, ReactionSummary } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { CommentValidation } from "@shared/validations";
import { basicSchema } from "@server/editor";
import { commentSchema } from "@server/editor";
import { ValidationError } from "@server/errors";
import Document from "./Document";
import User from "./User";
@@ -137,7 +137,7 @@ class Comment extends ParanoidModel<
* @returns The plain text representation of the comment data
*/
public toPlainText() {
const node = Node.fromJSON(basicSchema, this.data);
const node = Node.fromJSON(commentSchema, this.data);
return ProsemirrorHelper.toPlainText(node);
}
+50 -1
View File
@@ -1,4 +1,5 @@
import { buildTeam, buildCollection } from "@server/test/factories";
import { randomUUID } from "node:crypto";
import { buildTeam, buildCollection, buildAttachment } from "@server/test/factories";
describe("Team", () => {
describe("collectionIds", () => {
@@ -40,4 +41,52 @@ describe("Team", () => {
expect(team.previousSubdomains?.[1]).toEqual(subdomain);
});
});
describe("publicAvatarUrl", () => {
it("should return null when no avatarUrl is set", async () => {
const team = await buildTeam({ avatarUrl: null });
const result = await team.publicAvatarUrl();
expect(result).toBeNull();
});
it("should return external URL unchanged", async () => {
const url = "https://example.com/logo.png";
const team = await buildTeam({ avatarUrl: url });
const result = await team.publicAvatarUrl();
expect(result).toEqual(url);
});
it("should return signed URL for private-bucket attachment redirect", async () => {
const team = await buildTeam();
const attachment = await buildAttachment({
teamId: team.id,
acl: "private",
});
await team.update({
avatarUrl: `/api/attachments.redirect?id=${attachment.id}`,
});
const result = await team.publicAvatarUrl();
expect(result).toEqual(await attachment.signedUrl);
});
it("should return canonical URL for public-bucket attachment redirect", async () => {
const team = await buildTeam();
const id = randomUUID();
const attachment = await buildAttachment({
id,
teamId: team.id,
key: `avatars/${team.id}/${id}/logo.png`,
acl: "public-read",
});
await team.update({
avatarUrl: `/api/attachments.redirect?id=${attachment.id}`,
});
const result = await team.publicAvatarUrl();
expect(result).toEqual(attachment.canonicalUrl);
});
});
});
+42
View File
@@ -29,6 +29,7 @@ import { TeamPreferenceDefaults } from "@shared/constants";
import type { TeamPreferences } from "@shared/types";
import { TeamPreference, UserRole } from "@shared/types";
import { getBaseDomain, RESERVED_SUBDOMAINS } from "@shared/utils/domains";
import { attachmentRedirectRegex } from "@shared/utils/ProsemirrorHelper";
import { parseEmail } from "@shared/utils/email";
import { TeamValidation } from "@shared/validations";
import env from "@server/env";
@@ -57,6 +58,8 @@ export enum TeamFlag {
MarkedSafe = "markedSafe",
}
const avatarRedirectPattern = new RegExp(attachmentRedirectRegex.source, "i");
@Scopes(() => ({
withDomains: {
include: [{ model: TeamDomain }],
@@ -145,6 +148,37 @@ class Team extends ParanoidModel<
this.setDataValue("avatarUrl", value);
}
/**
* Returns a directly-accessible URL for the team's avatar suitable for use
* in contexts without authentication. Attachment is loaded and a signed (or
* canonical) URL is returned; any other URL is returned unchanged.
*
* @returns A promise resolving to a direct URL, or null when no avatar is set.
*/
async publicAvatarUrl(): Promise<string | null> {
const url = this.avatarUrl;
if (!url) {
return null;
}
const match = avatarRedirectPattern.exec(url);
if (!match?.groups?.id) {
return url;
}
const attachment = await Attachment.findOne({
where: { id: match.groups.id, teamId: this.id },
});
if (!attachment) {
return url;
}
return attachment.isStoredInPublicBucket
? attachment.canonicalUrl
: await attachment.signedUrl;
}
@Default(true)
@Column
sharing: boolean;
@@ -187,6 +221,14 @@ class Team extends ParanoidModel<
@SkipChangeset
approximateTotalAttachmentsSize: number;
@AllowNull
@Length({
max: TeamValidation.maxGuidanceMCPLength,
msg: `MCP guidance must be ${TeamValidation.maxGuidanceMCPLength} characters or less`,
})
@Column(DataType.TEXT)
guidanceMCP: string | null;
@AllowNull
@Column(DataType.JSONB)
preferences: TeamPreferences | null;
@@ -2,7 +2,6 @@ import { faker } from "@faker-js/faker";
import type { DeepPartial } from "utility-types";
import type { ProsemirrorData } from "@shared/types";
import { MentionType } from "@shared/types";
import { ProsemirrorHelper as SharedProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { createContext } from "@server/context";
import { buildProseMirrorDoc, buildUser } from "@server/test/factories";
import type { MentionAttrs } from "./ProsemirrorHelper";
@@ -973,7 +972,7 @@ describe("ProsemirrorHelper", () => {
},
]);
const images = SharedProsemirrorHelper.getImages(doc);
const images = ProsemirrorHelper.getImages(doc);
expect(images.length).toBe(1);
expect(images[0].attrs.src).toBe("https://example.com/image.png");
expect(images[0].attrs.alt).toBe("Test image");
+4 -30
View File
@@ -21,6 +21,7 @@ import {
attachmentRedirectRegex,
ProsemirrorHelper as SharedProsemirrorHelper,
} from "@shared/utils/ProsemirrorHelper";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isRTL } from "@shared/utils/rtl";
import { isInternalUrl } from "@shared/utils/urls";
@@ -62,7 +63,7 @@ export type MentionAttrs = {
};
@trace()
export class ProsemirrorHelper {
export class ProsemirrorHelper extends SharedProsemirrorHelper {
/**
* Returns the input text as a Y.Doc.
*
@@ -255,33 +256,6 @@ export class ProsemirrorHelper {
return blockNode ? doc.copy(Fragment.fromArray([blockNode])) : undefined;
}
/**
* Removes all marks from the node that match the given types.
*
* @param data The ProsemirrorData object to remove marks from
* @param marks The mark types to remove
* @returns The content with marks removed
*/
static removeMarks(doc: Node | ProsemirrorData, marks: string[]) {
const json = "toJSON" in doc ? (doc.toJSON() as ProsemirrorData) : doc;
function removeMarksInner(node: ProsemirrorData) {
if (node.marks) {
node.marks = node.marks.filter((mark) => !marks.includes(mark.type));
}
if (node.attrs?.marks) {
node.attrs.marks = (node.attrs.marks as { type: string }[])?.filter(
(mark) => !marks.includes(mark.type)
);
}
if (node.content) {
node.content.forEach(removeMarksInner);
}
return node;
}
return removeMarksInner(json);
}
static async replaceInternalUrls(
doc: Node | ProsemirrorData,
basePath: string
@@ -875,8 +849,8 @@ export class ProsemirrorHelper {
doc: Node,
user: User
): Promise<Node> {
const images = SharedProsemirrorHelper.getImages(doc);
const videos = SharedProsemirrorHelper.getVideos(doc);
const images = ProsemirrorHelper.getImages(doc);
const videos = ProsemirrorHelper.getVideos(doc);
const nodes = [...images, ...videos];
if (!nodes.length) {
+1 -2
View File
@@ -1,4 +1,3 @@
import { ProsemirrorHelper as SharedProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { createContext } from "@server/context";
import { buildProseMirrorDoc, buildUser } from "@server/test/factories";
import { ProsemirrorHelper } from "./ProsemirrorHelper";
@@ -43,7 +42,7 @@ describe("ProsemirrorHelper", () => {
},
]);
const images = SharedProsemirrorHelper.getImages(doc);
const images = ProsemirrorHelper.getImages(doc);
expect(images.length).toBe(1);
expect(images[0].attrs.src).toBe("https://example.com/image.png");
expect(images[0].attrs.alt).toBe("Test image");
@@ -0,0 +1,48 @@
import { Scope } from "@shared/types";
import { buildOAuthAuthentication, buildUser } from "@server/test/factories";
describe("OAuthAuthentication", () => {
describe("canAccess", () => {
it("should allow MCP access for scoped tokens", async () => {
const user = await buildUser();
const authentication = await buildOAuthAuthentication({
user,
scope: [Scope.Read],
});
expect(authentication.canAccess("/mcp")).toBe(true);
expect(authentication.canAccess("/mcp/")).toBe(true);
});
it("should deny MCP access for tokens with empty scope", async () => {
const user = await buildUser();
const authentication = await buildOAuthAuthentication({
user,
scope: [],
});
expect(authentication.canAccess("/mcp")).toBe(false);
});
it("should always allow the revoke endpoint", async () => {
const user = await buildUser();
const authentication = await buildOAuthAuthentication({
user,
scope: [Scope.Read],
});
expect(authentication.canAccess("/oauth/revoke")).toBe(true);
});
it("should check scopes for API paths", async () => {
const user = await buildUser();
const authentication = await buildOAuthAuthentication({
user,
scope: [Scope.Read],
});
expect(authentication.canAccess("/api/documents.list")).toBe(true);
expect(authentication.canAccess("/api/documents.update")).toBe(false);
});
});
});
+13 -9
View File
@@ -1,4 +1,7 @@
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import {
ProsemirrorHelper,
type CommentMark,
} from "@shared/utils/ProsemirrorHelper";
import type { Comment } from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import presentUser from "./user";
@@ -6,22 +9,23 @@ import presentUser from "./user";
type Options = {
/** Whether to include anchor text, if it exists */
includeAnchorText?: boolean;
/** Precomputed comment marks to avoid reparsing the document. */
commentMarks?: CommentMark[];
};
export default function present(
comment: Comment,
{ includeAnchorText }: Options = {}
{ includeAnchorText, commentMarks }: Options = {}
) {
let anchorText: string | undefined;
if (includeAnchorText && comment.document) {
const commentMarks = ProsemirrorHelper.getComments(
DocumentHelper.toProsemirror(comment.document)
);
anchorText = ProsemirrorHelper.getAnchorTextForComment(
commentMarks,
comment.id
);
const marks =
commentMarks ??
ProsemirrorHelper.getComments(
DocumentHelper.toProsemirror(comment.document)
);
anchorText = ProsemirrorHelper.getAnchorTextForComment(marks, comment.id);
}
return {
+1
View File
@@ -20,5 +20,6 @@ export default function presentTeam(team: Team) {
inviteRequired: team.inviteRequired,
allowedDomains: team.allowedDomains?.map((d) => d.name),
preferences: team.preferences,
guidanceMCP: team.guidanceMCP,
};
}
@@ -16,7 +16,7 @@ describe("DocumentArchivedProcessor", () => {
userId: user.id,
documentId: document.id,
});
// Verify the star exists
expect(
await Star.count({
@@ -56,7 +56,7 @@ describe("DocumentArchivedProcessor", () => {
teamId: actor.teamId,
userId: actor.id,
});
// Create stars for both users
await buildStar({
userId: actor.id,
@@ -95,7 +95,7 @@ describe("DocumentArchivedProcessor", () => {
},
})
).toBe(0);
// Verify the other user's star still exists
expect(
await Star.count({
@@ -0,0 +1,82 @@
import { SearchableModel } from "@shared/types";
import {
buildDocument,
buildCollection,
buildUser,
} from "@server/test/factories";
import SearchProviderManager from "@server/utils/SearchProviderManager";
import SearchIndexProcessor from "./SearchIndexProcessor";
const processor = new SearchIndexProcessor();
describe("SearchIndexProcessor", () => {
it("should have the expected applicable events", () => {
expect(SearchIndexProcessor.applicableEvents).toContain(
"documents.publish"
);
expect(SearchIndexProcessor.applicableEvents).toContain(
"documents.update.delayed"
);
expect(SearchIndexProcessor.applicableEvents).toContain(
"documents.permanent_delete"
);
expect(SearchIndexProcessor.applicableEvents).toContain(
"collections.create"
);
expect(SearchIndexProcessor.applicableEvents).toContain("comments.create");
expect(SearchIndexProcessor.applicableEvents).toContain("comments.delete");
});
it("should call provider.index for documents.publish", async () => {
const user = await buildUser();
const collection = await buildCollection({
teamId: user.teamId,
userId: user.id,
});
const document = await buildDocument({
teamId: user.teamId,
collectionId: collection.id,
userId: user.id,
});
const provider = SearchProviderManager.getProvider();
const indexSpy = jest.spyOn(provider, "index");
await processor.perform({
name: "documents.publish",
documentId: document.id,
collectionId: collection.id,
teamId: user.teamId,
actorId: user.id,
} as any);
expect(indexSpy).toHaveBeenCalledWith(
SearchableModel.Document,
expect.objectContaining({ id: document.id })
);
indexSpy.mockRestore();
});
it("should call provider.remove for documents.permanent_delete", async () => {
const user = await buildUser();
const provider = SearchProviderManager.getProvider();
const removeSpy = jest.spyOn(provider, "remove");
await processor.perform({
name: "documents.permanent_delete",
documentId: "deleted-doc-id",
collectionId: "some-collection-id",
teamId: user.teamId,
actorId: user.id,
} as any);
expect(removeSpy).toHaveBeenCalledWith(
SearchableModel.Document,
"deleted-doc-id",
user.teamId
);
removeSpy.mockRestore();
});
});
@@ -0,0 +1,139 @@
import { SearchableModel } from "@shared/types";
import { Document, Collection, Comment } from "@server/models";
import BaseProcessor from "@server/queues/processors/BaseProcessor";
import type {
DocumentEvent,
DocumentMovedEvent,
CollectionEvent,
CommentEvent,
CommentUpdateEvent,
Event,
} from "@server/types";
import SearchProviderManager from "@server/utils/SearchProviderManager";
/**
* Processor that keeps the search index in sync with data changes.
* For PostgreSQL this is largely a no-op since tsvector triggers handle
* indexing, but external providers (Elasticsearch, etc.) rely on these
* events to maintain their indexes.
*/
export default class SearchIndexProcessor extends BaseProcessor {
static applicableEvents: Event["name"][] = [
"documents.publish",
"documents.update.delayed",
"documents.archive",
"documents.unarchive",
"documents.delete",
"documents.permanent_delete",
"documents.move",
"collections.create",
"collections.update",
"collections.delete",
"comments.create",
"comments.update",
"comments.delete",
];
async perform(
event: DocumentEvent | DocumentMovedEvent | CollectionEvent | CommentEvent
): Promise<void> {
const provider = SearchProviderManager.getProvider();
// When using the built-in Postgres search provider, tsvector triggers
// handle indexing directly and the provider methods are effectively no-ops for now.
if (process.env.SEARCH_PROVIDER === "postgres") {
return;
}
switch (event.name) {
case "documents.publish":
case "documents.update.delayed":
case "documents.unarchive": {
const document = await Document.findByPk(
(event as DocumentEvent).documentId
);
if (document) {
await provider.index(SearchableModel.Document, document);
}
break;
}
case "documents.archive":
case "documents.delete": {
const document = await Document.findByPk(
(event as DocumentEvent).documentId,
{ paranoid: false }
);
if (document) {
await provider.updateMetadata(SearchableModel.Document, document.id, {
archivedAt: document.archivedAt,
deletedAt: document.deletedAt,
});
}
break;
}
case "documents.permanent_delete": {
await provider.remove(
SearchableModel.Document,
(event as DocumentEvent).documentId,
event.teamId
);
break;
}
case "documents.move": {
const movedEvent = event as DocumentMovedEvent;
for (const documentId of movedEvent.data.documentIds) {
await provider.updateMetadata(SearchableModel.Document, documentId, {
collectionId: movedEvent.collectionId,
});
}
break;
}
case "collections.create":
case "collections.update": {
const collection = await Collection.findByPk(
(event as CollectionEvent).collectionId
);
if (collection) {
await provider.index(SearchableModel.Collection, collection);
}
break;
}
case "collections.delete": {
await provider.remove(
SearchableModel.Collection,
(event as CollectionEvent).collectionId,
event.teamId
);
break;
}
case "comments.create":
case "comments.update": {
const comment = await Comment.findByPk(
(event as CommentEvent | CommentUpdateEvent).modelId
);
if (comment) {
await provider.index(SearchableModel.Comment, comment);
}
break;
}
case "comments.delete": {
await provider.remove(
SearchableModel.Comment,
(event as CommentEvent).modelId,
event.teamId
);
break;
}
default:
break;
}
}
}
+3 -4
View File
@@ -13,7 +13,6 @@ import type {
ProsemirrorDoc,
} from "@shared/types";
import { AttachmentPreset, ImportState, ImportTaskState } from "@shared/types";
import { ProsemirrorHelper as SharedProseMirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { createContext } from "@server/context";
import { schema } from "@server/editor";
import Logger from "@server/logging/Logger";
@@ -262,9 +261,9 @@ export default abstract class APIImportTask<
}): Promise<ProsemirrorDoc> {
const docNode = ProsemirrorHelper.toProsemirror(doc);
const nodes = [
...SharedProseMirrorHelper.getImages(docNode),
...SharedProseMirrorHelper.getVideos(docNode),
...SharedProseMirrorHelper.getAttachments(docNode),
...ProsemirrorHelper.getImages(docNode),
...ProsemirrorHelper.getVideos(docNode),
...ProsemirrorHelper.getAttachments(docNode),
];
if (!nodes.length) {
+141 -23
View File
@@ -1,37 +1,155 @@
import path from "node:path";
import { FileOperation } from "@server/models";
import { buildFileOperation } from "@server/test/factories";
import { FileOperation, User } from "@server/models";
import {
buildFileOperation,
buildUser,
buildTeam,
buildAdmin,
} from "@server/test/factories";
import ImportJSONTask from "./ImportJSONTask";
// The fixture has these values for both documents:
// createdById: "ccec260a-e060-4925-ade8-17cfabaf2cac"
// createdByEmail: "hmac.devo@gmail.com"
const fixtureCreatedById = "ccec260a-e060-4925-ade8-17cfabaf2cac";
const fixtureCreatedByEmail = "hmac.devo@gmail.com";
const fixturePath = path.resolve(
__dirname,
"..",
"..",
"test",
"fixtures",
"outline-json.zip"
);
function mockHandle(fileOperation: FileOperation) {
Object.defineProperty(fileOperation, "handle", {
get() {
return {
path: fixturePath,
cleanup: async () => {},
};
},
});
jest.spyOn(FileOperation, "findByPk").mockResolvedValue(fileOperation);
}
describe("ImportJSONTask", () => {
it("should import the documents, attachments", async () => {
const fileOperation = await buildFileOperation();
Object.defineProperty(fileOperation, "handle", {
get() {
return {
path: path.resolve(
__dirname,
"..",
"..",
"test",
"fixtures",
"outline-json.zip"
),
cleanup: async () => {},
};
},
});
jest.spyOn(FileOperation, "findByPk").mockResolvedValue(fileOperation);
const props = {
fileOperationId: fileOperation.id,
};
mockHandle(fileOperation);
const task = new ImportJSONTask();
const response = await task.perform(props);
const response = await task.perform({
fileOperationId: fileOperation.id,
});
expect(response.collections.size).toEqual(1);
expect(response.documents.size).toEqual(2);
expect(response.attachments.size).toEqual(1);
});
describe("user mapping", () => {
it("should map createdById to an existing user by ID", async () => {
// Ensure a user exists with the fixture's createdById, handling the
// case where it may already exist from a prior test run.
let originalAuthor = await User.findByPk(fixtureCreatedById);
const teamId = originalAuthor?.teamId ?? (await buildTeam()).id;
if (!originalAuthor) {
originalAuthor = await buildUser({
id: fixtureCreatedById,
teamId,
});
}
const admin = await buildAdmin({ teamId });
const fileOperation = await buildFileOperation({
userId: admin.id,
teamId,
});
mockHandle(fileOperation);
const task = new ImportJSONTask();
const response = await task.perform({
fileOperationId: fileOperation.id,
});
for (const document of response.documents.values()) {
expect(document.createdById).toEqual(originalAuthor.id);
expect(document.lastModifiedById).toEqual(originalAuthor.id);
}
});
it("should fall back to email matching when ID does not match", async () => {
const team = await buildTeam();
// User has matching email but a different ID
const originalAuthor = await buildUser({
teamId: team.id,
email: fixtureCreatedByEmail,
});
const admin = await buildAdmin({ teamId: team.id });
const fileOperation = await buildFileOperation({
userId: admin.id,
teamId: team.id,
});
mockHandle(fileOperation);
const task = new ImportJSONTask();
const response = await task.perform({
fileOperationId: fileOperation.id,
});
for (const document of response.documents.values()) {
expect(document.createdById).toEqual(originalAuthor.id);
expect(document.lastModifiedById).toEqual(originalAuthor.id);
}
});
it("should fall back to importing user when no match is found", async () => {
const team = await buildTeam();
const admin = await buildAdmin({ teamId: team.id });
const fileOperation = await buildFileOperation({
userId: admin.id,
teamId: team.id,
});
mockHandle(fileOperation);
const task = new ImportJSONTask();
const response = await task.perform({
fileOperationId: fileOperation.id,
});
for (const document of response.documents.values()) {
expect(document.createdById).toEqual(admin.id);
expect(document.lastModifiedById).toEqual(admin.id);
}
});
it("should not match users from a different team", async () => {
const team = await buildTeam();
const otherTeam = await buildTeam();
// Create user with matching email in a different team
await buildUser({
teamId: otherTeam.id,
email: fixtureCreatedByEmail,
});
const admin = await buildAdmin({ teamId: team.id });
const fileOperation = await buildFileOperation({
userId: admin.id,
teamId: team.id,
});
mockHandle(fileOperation);
const task = new ImportJSONTask();
const response = await task.perform({
fileOperationId: fileOperation.id,
});
for (const document of response.documents.values()) {
expect(document.createdById).toEqual(admin.id);
}
});
});
});
+64
View File
@@ -305,6 +305,7 @@ export default abstract class ImportTask extends BaseTask<Props> {
const collections = new Map<string, Collection>();
const documents = new Map<string, Document>();
const attachments = new Map<string, Attachment>();
const userIdCache = new Map<string, string | undefined>();
const user = await User.findByPk(fileOperation.userId, {
rejectOnEmpty: true,
@@ -437,6 +438,13 @@ export default abstract class ImportTask extends BaseTask<Props> {
);
}
const resolvedUserId =
(await this.resolveUserId(
item,
fileOperation.teamId,
userIdCache
)) ?? fileOperation.userId;
const document = await documentCreator(ctx, {
sourceMetadata: {
fileName: path.basename(item.path),
@@ -457,6 +465,8 @@ export default abstract class ImportTask extends BaseTask<Props> {
publishedAt: item.updatedAt ?? item.createdAt ?? new Date(),
parentDocumentId: item.parentDocumentId,
importId: fileOperation.id,
createdById: resolvedUserId,
lastModifiedById: resolvedUserId,
});
documents.set(item.id, document);
@@ -535,6 +545,60 @@ export default abstract class ImportTask extends BaseTask<Props> {
};
}
/**
* Resolves the original document author to an internal user, using a cache
* to avoid redundant database queries. Attempts to match by user ID first,
* then by email. Both hits and misses are cached.
*
* @param item the document import item containing createdById and createdByEmail.
* @param teamId the team ID to scope the lookup to.
* @param cache a map used to cache resolved user IDs across calls.
* @returns the resolved user ID, or undefined if no match was found.
*/
private async resolveUserId(
item: { createdById?: string; createdByEmail?: string | null },
teamId: string,
cache: Map<string, string | undefined>
): Promise<string | undefined> {
if (item.createdById) {
const cacheKey = `id:${item.createdById}`;
if (cache.has(cacheKey)) {
return cache.get(cacheKey);
}
const user = await User.findOne({
where: { id: item.createdById, teamId },
});
if (user) {
cache.set(cacheKey, user.id);
return user.id;
}
cache.set(cacheKey, undefined);
}
if (item.createdByEmail) {
const email = item.createdByEmail.toLowerCase().trim();
const cacheKey = `email:${email}`;
if (cache.has(cacheKey)) {
return cache.get(cacheKey);
}
const user = await User.findOne({
where: { email, teamId },
});
if (user) {
cache.set(cacheKey, user.id);
if (item.createdById) {
cache.set(`id:${item.createdById}`, user.id);
}
return user.id;
}
cache.set(cacheKey, undefined);
}
return undefined;
}
private async preprocessDocUrlIds(data: StructuredImportData) {
for (const doc of data.documents) {
// check DB only if urlId is present in the input.
-1
View File
@@ -1 +0,0 @@
export { default } from "./comments";
+15 -14
View File
@@ -64,7 +64,7 @@ import {
import AttachmentHelper from "@server/models/helpers/AttachmentHelper";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import SearchHelper from "@server/models/helpers/SearchHelper";
import SearchProviderManager from "@server/utils/SearchProviderManager";
import { TextHelper } from "@server/models/helpers/TextHelper";
import { authorize, cannot } from "@server/policies";
import {
@@ -1015,17 +1015,18 @@ router.post(
collaboratorIds = [userId];
}
const documents = await SearchHelper.searchTitlesForUser(user, {
query,
dateFilter,
statusFilter,
collectionId,
collaboratorIds,
offset,
limit,
sort: sort as SortFilter,
direction: direction as DirectionFilter,
});
const documents =
await SearchProviderManager.getProvider().searchTitlesForUser(user, {
query,
dateFilter,
statusFilter,
collectionId,
collaboratorIds,
offset,
limit,
sort: sort as SortFilter,
direction: direction as DirectionFilter,
});
const policies = presentPolicies(user, documents);
const data = await presentDocuments(ctx, documents);
@@ -1099,7 +1100,7 @@ router.post(
const team = await share.$get("team");
invariant(team, "Share must belong to a team");
response = await SearchHelper.searchForTeam(team, {
response = await SearchProviderManager.getProvider().searchForTeam(team, {
query,
collectionId: collection?.id || document?.collectionId,
share,
@@ -1145,7 +1146,7 @@ router.post(
collaboratorIds = [userId];
}
response = await SearchHelper.searchForUser(user, {
response = await SearchProviderManager.getProvider().searchForUser(user, {
query,
collaboratorIds,
collectionId,
+3 -3
View File
@@ -5,7 +5,7 @@ import { StatusFilter } from "@shared/types";
import auth from "@server/middlewares/authentication";
import validate from "@server/middlewares/validate";
import { Group, User } from "@server/models";
import SearchHelper from "@server/models/helpers/SearchHelper";
import SearchProviderManager from "@server/utils/SearchProviderManager";
import { can } from "@server/policies";
import {
presentDocuments,
@@ -29,7 +29,7 @@ router.post(
const actor = ctx.state.auth.user;
const [documents, users, groups, collections] = await Promise.all([
SearchHelper.searchTitlesForUser(actor, {
SearchProviderManager.getProvider().searchTitlesForUser(actor, {
query,
offset,
limit,
@@ -74,7 +74,7 @@ router.post(
offset,
limit,
}),
SearchHelper.searchCollectionsForUser(actor, { query, offset, limit }),
SearchProviderManager.getProvider().searchCollectionsForUser(actor, { query, offset, limit }),
]);
ctx.body = {
+3
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import { EmailDisplay, TOCPosition, UserRole } from "@shared/types";
import { TeamValidation } from "@shared/validations";
import { BaseSchema } from "@server/routes/api/schema";
export const TeamsUpdateSchema = BaseSchema.extend({
@@ -32,6 +33,8 @@ export const TeamsUpdateSchema = BaseSchema.extend({
inviteRequired: z.boolean().optional(),
/** Domains allowed to sign-in with SSO */
allowedDomains: z.array(z.string()).optional(),
/** Workspace guidance provided to MCP clients on connection */
guidanceMCP: z.string().max(TeamValidation.maxGuidanceMCPLength).nullish(),
/** Team preferences */
preferences: z
.object({
+3 -1
View File
@@ -340,7 +340,9 @@ export const renderShare = async (ctx: Context, next: Next) => {
(publicBranding && team?.description ? team.description : undefined),
content,
shortcutIcon:
publicBranding && team?.avatarUrl ? team.avatarUrl : undefined,
publicBranding && team?.avatarUrl
? (await team.publicAvatarUrl()) ?? undefined
: undefined,
analytics,
isShare: true,
rootShareId,
+144
View File
@@ -1,10 +1,12 @@
import { Scope, TeamPreference } from "@shared/types";
import type { ProsemirrorData } from "@shared/types";
import {
buildUser,
buildAdmin,
buildCollection,
buildDocument,
buildComment,
buildCommentMark,
buildOAuthAuthentication,
} from "@server/test/factories";
import { getTestServer } from "@server/test/support";
@@ -620,6 +622,148 @@ describe("POST /mcp/", () => {
expect(data.text).toContain("Updated comment text");
});
it("list_comments includes anchorText when comment is anchored", async () => {
const { user, accessToken } = await buildOAuthUser();
const collection = await buildCollection({
teamId: user.teamId,
userId: user.id,
});
const document = await buildDocument({
teamId: user.teamId,
userId: user.id,
collectionId: collection.id,
});
const comment = await buildComment({
userId: user.id,
documentId: document.id,
});
const anchorText = "highlighted text";
const content = {
type: "doc",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: anchorText,
marks: [buildCommentMark({ id: comment.id, userId: user.id })],
},
],
},
],
} as ProsemirrorData;
await document.update({ content });
const res = await callMcpTool(server, accessToken, "list_comments", {
documentId: document.id,
});
const data = (res?.result?.content ?? []).map((c: { text: string }) =>
JSON.parse(c.text)
);
const match = data.find((c: { id: string }) => c.id === comment.id) as {
anchorText: string;
};
expect(match).toBeDefined();
expect(match.anchorText).toEqual(anchorText);
});
it("list_comments returns undefined anchorText for non-anchored comment", async () => {
const { user, accessToken } = await buildOAuthUser();
const collection = await buildCollection({
teamId: user.teamId,
userId: user.id,
});
const document = await buildDocument({
teamId: user.teamId,
userId: user.id,
collectionId: collection.id,
});
await buildComment({
userId: user.id,
documentId: document.id,
});
const res = await callMcpTool(server, accessToken, "list_comments", {
documentId: document.id,
});
const data = (res?.result?.content ?? []).map((c: { text: string }) =>
JSON.parse(c.text)
);
expect(data.length).toBeGreaterThanOrEqual(1);
expect(data[0].anchorText).toBeUndefined();
});
it("create_comment includes anchorText in response", async () => {
const { user, accessToken } = await buildOAuthUser();
const collection = await buildCollection({
teamId: user.teamId,
userId: user.id,
});
const document = await buildDocument({
teamId: user.teamId,
userId: user.id,
collectionId: collection.id,
});
const res = await callMcpTool(server, accessToken, "create_comment", {
documentId: document.id,
text: "A new comment",
});
const data = JSON.parse(res?.result?.content?.[0]?.text ?? "{}");
// New comments have no anchor mark in the document, so anchorText is undefined
expect(data.id).toBeDefined();
expect(data.anchorText).toBeUndefined();
});
it("update_comment includes anchorText in response", async () => {
const { user, accessToken } = await buildOAuthUser();
const collection = await buildCollection({
teamId: user.teamId,
userId: user.id,
});
const document = await buildDocument({
teamId: user.teamId,
userId: user.id,
collectionId: collection.id,
});
const comment = await buildComment({
userId: user.id,
documentId: document.id,
});
const anchorText = "anchored content";
const content = {
type: "doc",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: anchorText,
marks: [buildCommentMark({ id: comment.id, userId: user.id })],
},
],
},
],
} as ProsemirrorData;
await document.update({ content });
const res = await callMcpTool(server, accessToken, "update_comment", {
id: comment.id,
text: "Updated text",
});
const data = JSON.parse(res?.result?.content?.[0]?.text ?? "{}");
expect(data.id).toEqual(comment.id);
expect(data.anchorText).toEqual(anchorText);
});
it("delete_comment deletes own comment", async () => {
const { user, accessToken } = await buildOAuthUser();
const collection = await buildCollection({
+15 -2
View File
@@ -12,6 +12,7 @@ import { rateLimiter } from "@server/middlewares/rateLimiter";
import requestTracer from "@server/middlewares/requestTracer";
import { AuthenticationType } from "@server/types";
import { RateLimiterStrategy } from "@server/utils/RateLimiter";
import { attachmentTools } from "@server/tools/attachments";
import { collectionTools } from "@server/tools/collections";
import { commentTools } from "@server/tools/comments";
import { documentTools } from "@server/tools/documents";
@@ -22,14 +23,21 @@ import { version } from "../../../package.json";
const app = new Koa();
const router = new Router();
const defaultInstructions = `Document and collection markdown support @mentions using the syntax: @[Display Name](mention://user/userId). For example: @[John Doe](mention://user/c9a1b2e3-...). Use the list_users tool to find user IDs.`;
/**
* Creates a fresh MCP server instance with tools filtered by the OAuth
* scopes granted to the current token.
*
* @param scopes - the OAuth scopes granted to the access token.
* @param guidance - optional workspace guidance to append to default instructions.
* @returns a configured McpServer ready to be connected to a transport.
*/
function createMcpServer(scopes: string[]): McpServer {
function createMcpServer(scopes: string[], guidance?: string): McpServer {
const instructions = guidance
? `${defaultInstructions}\n\n${guidance}`
: defaultInstructions;
const server = new McpServer(
{
name: "outline",
@@ -39,9 +47,11 @@ function createMcpServer(scopes: string[]): McpServer {
capabilities: {
tools: {},
},
instructions,
}
);
attachmentTools(server, scopes);
collectionTools(server, scopes);
commentTools(server, scopes);
documentTools(server, scopes);
@@ -68,7 +78,10 @@ router.post(
throw NotFoundError();
}
const server = createMcpServer(scope ?? []);
const server = createMcpServer(
scope ?? [],
user.team.guidanceMCP ?? undefined
);
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
+112
View File
@@ -0,0 +1,112 @@
import { randomUUID } from "crypto";
import { z } from "zod";
import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { Attachment, Team } from "@server/models";
import AttachmentHelper from "@server/models/helpers/AttachmentHelper";
import { authorize } from "@server/policies";
import presentAttachment from "@server/presenters/attachment";
import FileStorage from "@server/storage/files";
import AuthenticationHelper from "@shared/helpers/AuthenticationHelper";
import { AttachmentPreset } from "@shared/types";
import { error, success, buildAPIContext, withTracing } from "./util";
/**
* Registers attachment-related MCP tools on the given server, filtered by
* the OAuth scopes granted to the current token.
*
* @param server - the MCP server instance to register on.
* @param scopes - the OAuth scopes granted to the access token.
*/
export function attachmentTools(server: McpServer, scopes: string[]) {
if (AuthenticationHelper.canAccess("attachments.create", scopes)) {
server.registerTool(
"create_attachment",
{
title: "Create attachment upload",
description:
"Requests a pre-signed upload URL. Use the returned uploadUrl and form fields to upload a file directly via a multipart POST request (e.g. with curl). The returned attachment URL is returned for use in documents.",
annotations: {
idempotentHint: false,
readOnlyHint: false,
},
inputSchema: {
contentType: z
.string()
.describe("The MIME type of the file, e.g. image/png, image/jpeg."),
name: z
.string()
.describe("The filename including extension, e.g. screenshot.png."),
size: z.coerce.number().describe("The file size in bytes."),
},
},
withTracing(
"create_attachment",
async ({ contentType, name, size }, extra) => {
try {
const ctx = buildAPIContext(extra);
const { user } = ctx.state.auth;
const team = await Team.findByPk(user.teamId, {
rejectOnEmpty: true,
});
authorize(user, "createAttachment", team);
const preset = AttachmentPreset.DocumentAttachment;
const maxUploadSize =
AttachmentHelper.presetToMaxUploadSize(preset);
const id = randomUUID();
const acl = AttachmentHelper.presetToAcl(preset);
const key = AttachmentHelper.getKey({
id,
name,
userId: user.id,
});
const attachment = await Attachment.createWithCtx(ctx, {
id,
key,
acl,
size,
contentType,
teamId: user.teamId,
userId: user.id,
});
const presignedPost = await FileStorage.getPresignedPost(
ctx,
key,
acl,
maxUploadSize,
contentType
);
const uploadUrl = FileStorage.getUploadUrl();
const form = {
"Cache-Control": "max-age=31557600",
"Content-Type": contentType,
...presignedPost.fields,
};
// Build a ready-to-use curl command for the MCP client
const formArgs = Object.entries(form)
.map(([k, v]) => `-F '${k}=${v}'`)
.join(" ");
const curlCommand = `curl -X POST ${formArgs} -F 'file=@/path/to/file' '${uploadUrl}'`;
return success({
uploadUrl,
form,
maxUploadSize,
curlCommand,
attachment: {
...presentAttachment(attachment),
url: attachment.redirectUrl,
},
});
} catch (message) {
return error(message);
}
}
)
);
}
}
+36 -3
View File
@@ -4,7 +4,10 @@ import type { FindOptions, WhereOptions } from "sequelize";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { CommentStatusFilter } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import type { CommentMark } from "@shared/utils/ProsemirrorHelper";
import { commentParser } from "@server/editor";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import { Comment, Collection, Document } from "@server/models";
import { authorize } from "@server/policies";
import { presentComment } from "@server/presenters";
@@ -23,10 +26,17 @@ import {
* ProseMirror JSON.
*
* @param comment - the comment model instance.
* @param commentMarks - optional precomputed comment marks to avoid reparsing.
* @returns the presented comment with an additional `text` field.
*/
function presentCommentWithText(comment: Comment) {
const presented = presentComment(comment);
function presentCommentWithText(
comment: Comment,
commentMarks?: CommentMark[]
) {
const presented = presentComment(comment, {
includeAnchorText: true,
commentMarks,
});
return {
...presented,
text: comment.toPlainText(),
@@ -182,7 +192,25 @@ export function commentTools(server: McpServer, scopes: string[]) {
});
}
const presented = comments.map(presentCommentWithText);
// Precompute comment marks per document to avoid reparsing
// the same document for every comment.
const marksCache = new Map<string, CommentMark[]>();
const presented = comments.map((comment) => {
const doc = comment.document;
let marks: CommentMark[] | undefined;
if (doc) {
if (!marksCache.has(doc.id)) {
marksCache.set(
doc.id,
ProsemirrorHelper.getComments(
DocumentHelper.toProsemirror(doc)
)
);
}
marks = marksCache.get(doc.id);
}
return presentCommentWithText(comment, marks);
});
return success(presented);
} catch (err) {
return error(err);
@@ -238,6 +266,7 @@ export function commentTools(server: McpServer, scopes: string[]) {
});
comment.createdBy = user;
comment.document = document!;
const presented = presentCommentWithText(comment);
return {
@@ -292,6 +321,9 @@ export function commentTools(server: McpServer, scopes: string[]) {
userId: user.id,
});
authorize(user, "read", comment);
authorize(user, "read", document);
if (text !== undefined) {
authorize(user, "update", comment);
authorize(user, "comment", document);
@@ -312,6 +344,7 @@ export function commentTools(server: McpServer, scopes: string[]) {
await comment.saveWithCtx(ctx, status ? { silent: true } : undefined);
comment.document = document!;
const presented = presentCommentWithText(comment);
return {
content: [
+4 -2
View File
@@ -7,7 +7,6 @@ import documentUpdater from "@server/commands/documentUpdater";
import { Op } from "sequelize";
import { Collection, Document } from "@server/models";
import { sequelize } from "@server/storage/database";
import SearchHelper from "@server/models/helpers/SearchHelper";
import { authorize } from "@server/policies";
import { presentDocument } from "@server/presenters";
import AuthenticationHelper from "@shared/helpers/AuthenticationHelper";
@@ -22,6 +21,7 @@ import {
withTracing,
} from "./util";
import { TextEditMode } from "@shared/types";
import SearchProviderManager from "@server/utils/SearchProviderManager";
/**
* Registers document-related MCP tools on the given server, filtered by
@@ -93,6 +93,8 @@ export function documentTools(server: McpServer, scopes: string[]) {
}
if (query) {
const searchProvider = SearchProviderManager.getProvider();
// If the query looks like a document ID or urlId, try direct
// lookup first so exact matches appear at the top of results.
let exactMatch: Document | null = null;
@@ -109,7 +111,7 @@ export function documentTools(server: McpServer, scopes: string[]) {
}
}
const { results } = await SearchHelper.searchForUser(user, {
const { results } = await searchProvider.searchForUser(user, {
query,
collectionId,
offset: effectiveOffset,
+149
View File
@@ -0,0 +1,149 @@
import type { DateFilter } from "@shared/types";
import type { SearchableModel } from "@shared/types";
import type { DirectionFilter, SortFilter, StatusFilter } from "@shared/types";
import type Collection from "@server/models/Collection";
import type Comment from "@server/models/Comment";
import type Document from "@server/models/Document";
import type Share from "@server/models/Share";
import type Team from "@server/models/Team";
import type User from "@server/models/User";
export interface SearchResponse {
results: {
/** The search ranking, for sorting results. */
ranking: number;
/** A snippet of contextual text around the search result. */
context?: string;
/** The document result. */
document: Document;
}[];
/** The total number of results for the search query without pagination. */
total: number;
}
export interface SearchOptions {
/** The query limit for pagination. */
limit?: number;
/** The query offset for pagination. */
offset?: number;
/** The text to search for. */
query?: string;
/** Limit results to a collection. Authorization is presumed to have been done before passing to this provider. */
collectionId?: string | null;
/** Limit results to a shared document. */
share?: Share;
/** Limit results to a date range. */
dateFilter?: DateFilter;
/** Status of the documents to return. */
statusFilter?: StatusFilter[];
/** Limit results to a list of documents. */
documentIds?: string[];
/** Limit results to a list of users that collaborated on the document. */
collaboratorIds?: string[];
/** The minimum number of words to be returned in the contextual snippet. */
snippetMinWords?: number;
/** The maximum number of words to be returned in the contextual snippet. */
snippetMaxWords?: number;
/** The field to sort results by. */
sort?: SortFilter;
/** The sort direction. */
direction?: DirectionFilter;
/** Whether to boost results by popularity score. Defaults to true. */
usePopularityBoost?: boolean;
}
/**
* Abstract base class for search providers. Implementations handle full-text
* search, title search, collection search, and index management.
*/
export abstract class BaseSearchProvider {
/** Unique identifier for this provider, matched against `SEARCH_PROVIDER` env var. */
abstract id: string;
/**
* Perform a full-text search scoped to a user's accessible documents.
*
* @param user - the user performing the search.
* @param options - search options.
* @returns search results with ranking and context.
*/
abstract searchForUser(
user: User,
options?: SearchOptions
): Promise<SearchResponse>;
/**
* Perform a full-text search scoped to a team (used for shared document search).
*
* @param team - the team to search within.
* @param options - search options.
* @returns search results with ranking and context.
*/
abstract searchForTeam(
team: Team,
options?: SearchOptions
): Promise<SearchResponse>;
/**
* Search document titles for a user (used for link suggestions, quick search).
*
* @param user - the user performing the search.
* @param options - search options.
* @returns matching documents.
*/
abstract searchTitlesForUser(
user: User,
options?: SearchOptions
): Promise<Document[]>;
/**
* Search collections for a user.
*
* @param user - the user performing the search.
* @param options - search options.
* @returns matching collections.
*/
abstract searchCollectionsForUser(
user: User,
options?: SearchOptions
): Promise<Collection[]>;
/**
* Index or re-index a searchable item. For providers that rely on database
* triggers (e.g. PostgreSQL tsvector), this may be a no-op.
*
* @param model - the type of model being indexed.
* @param item - the model instance to index.
*/
abstract index(
model: SearchableModel,
item: Document | Collection | Comment
): Promise<void>;
/**
* Remove an item from the search index.
*
* @param model - the type of model being removed.
* @param id - the id of the item to remove.
* @param teamId - the team id the item belongs to.
*/
abstract remove(
model: SearchableModel,
id: string,
teamId: string
): Promise<void>;
/**
* Update metadata for an indexed item without re-indexing the full content.
* Useful for permission changes, moves, archive/unarchive.
*
* @param model - the type of model being updated.
* @param id - the id of the item to update.
* @param metadata - the metadata fields to update.
*/
abstract updateMetadata(
model: SearchableModel,
id: string,
metadata: Record<string, unknown>
): Promise<void>;
}
+104
View File
@@ -395,5 +395,109 @@ Content`;
expect(image.attrs.width).toBe(400);
expect(image.attrs.height).toBe(300);
});
it("should extract dimensions from PNG data URI images", () => {
// Minimal 2x3 PNG (IHDR: width=2, height=3)
const pngBuffer = Buffer.alloc(33);
// PNG signature
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(
pngBuffer
);
// IHDR chunk length (13 bytes)
pngBuffer.writeUInt32BE(13, 8);
// "IHDR"
Buffer.from("IHDR").copy(pngBuffer, 12);
// Width = 200
pngBuffer.writeUInt32BE(200, 16);
// Height = 150
pngBuffer.writeUInt32BE(150, 20);
const base64 = pngBuffer.toString("base64");
const html = `<p><img src="data:image/png;base64,${base64}"></p>`;
const doc = DocumentConverter.htmlToProsemirror(html);
const paragraph = doc.content.child(0);
const image = paragraph.content.child(0);
expect(image.type.name).toBe("image");
expect(image.attrs.width).toBe(200);
expect(image.attrs.height).toBe(150);
});
it("should extract dimensions from JPEG data URI images", () => {
// Minimal JPEG with SOF0 marker
const jpegBuffer = Buffer.alloc(20);
// JPEG SOI marker
jpegBuffer[0] = 0xff;
jpegBuffer[1] = 0xd8;
// SOF0 marker
jpegBuffer[2] = 0xff;
jpegBuffer[3] = 0xc0;
// Segment length
jpegBuffer.writeUInt16BE(17, 4);
// Precision
jpegBuffer[6] = 8;
// Height = 300
jpegBuffer.writeUInt16BE(300, 7);
// Width = 400
jpegBuffer.writeUInt16BE(400, 9);
const base64 = jpegBuffer.toString("base64");
const html = `<p><img src="data:image/jpeg;base64,${base64}"></p>`;
const doc = DocumentConverter.htmlToProsemirror(html);
const paragraph = doc.content.child(0);
const image = paragraph.content.child(0);
expect(image.type.name).toBe("image");
expect(image.attrs.width).toBe(400);
expect(image.attrs.height).toBe(300);
});
it("should extract dimensions from GIF data URI images", () => {
// Minimal GIF header
const gifBuffer = Buffer.alloc(10);
// GIF signature
Buffer.from("GIF89a").copy(gifBuffer);
// Width = 320 (little-endian)
gifBuffer.writeUInt16LE(320, 6);
// Height = 240 (little-endian)
gifBuffer.writeUInt16LE(240, 8);
const base64 = gifBuffer.toString("base64");
const html = `<p><img src="data:image/gif;base64,${base64}"></p>`;
const doc = DocumentConverter.htmlToProsemirror(html);
const paragraph = doc.content.child(0);
const image = paragraph.content.child(0);
expect(image.type.name).toBe("image");
expect(image.attrs.width).toBe(320);
expect(image.attrs.height).toBe(240);
});
it("should not override existing width/height on data URI images", () => {
// PNG with dimensions 200x150 but HTML attributes say 100x75
const pngBuffer = Buffer.alloc(33);
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(
pngBuffer
);
pngBuffer.writeUInt32BE(13, 8);
Buffer.from("IHDR").copy(pngBuffer, 12);
pngBuffer.writeUInt32BE(200, 16);
pngBuffer.writeUInt32BE(150, 20);
const base64 = pngBuffer.toString("base64");
const html = `<p><img src="data:image/png;base64,${base64}" width="100" height="75"></p>`;
const doc = DocumentConverter.htmlToProsemirror(html);
const paragraph = doc.content.child(0);
const image = paragraph.content.child(0);
expect(image.type.name).toBe("image");
// Should use the HTML attributes, not the parsed dimensions
expect(image.attrs.width).toBe(100);
expect(image.attrs.height).toBe(75);
});
});
});
+119 -2
View File
@@ -6,7 +6,6 @@ import mammoth from "mammoth";
import type { Node } from "prosemirror-model";
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
import yaml from "js-yaml";
import { ProsemirrorHelper as SharedProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { schema, serializer } from "@server/editor";
import { FileImportError } from "@server/errors";
import { trace, traceFunction } from "@server/logging/tracing";
@@ -55,7 +54,7 @@ export class DocumentConverter {
// Extract title from first H1 heading
let title = "";
const headings = SharedProsemirrorHelper.getHeadings(doc);
const headings = ProsemirrorHelper.getHeadings(doc);
if (headings.length > 0 && headings[0].level === 1) {
title = headings[0].title;
doc = ProsemirrorHelper.removeFirstHeading(doc);
@@ -148,6 +147,30 @@ export class DocumentConverter {
const calculatedHeight = Math.round(parseInt(dataHeight) / ratio);
img.setAttribute("height", String(calculatedHeight));
}
// Extract dimensions from data URI images that lack width/height
// (e.g. images embedded by mammoth during docx import).
// Only decode a small prefix of the base64 data — headers for all
// supported formats live within the first 64 KB of the file.
if (!img.getAttribute("width") && !img.getAttribute("height")) {
const src = img.getAttribute("src") || "";
if (src.startsWith("data:") && src.includes(";base64,")) {
const base64Start = src.indexOf(";base64,") + 8;
// 4 base64 chars → 3 bytes; decode at most ~64 KB of image data.
const maxBase64Chars = Math.ceil(65536 / 3) * 4;
const base64Prefix = src.slice(
base64Start,
base64Start + maxBase64Chars
);
const dimensions = this.getImageDimensionsFromBuffer(
Buffer.from(base64Prefix, "base64")
);
if (dimensions) {
img.setAttribute("width", String(dimensions.width));
img.setAttribute("height", String(dimensions.height));
}
}
}
});
}
@@ -444,4 +467,98 @@ export class DocumentConverter {
return yamlCodeblock + remainingContent;
}
/**
* Parse image dimensions from a binary buffer. Supports PNG, JPEG, and GIF.
*
* @param buffer The image data.
* @returns The width and height if parseable, otherwise undefined.
*/
private static getImageDimensionsFromBuffer(
buffer: Buffer
): { width: number; height: number } | undefined {
try {
// PNG: signature + IHDR chunk
if (
buffer.length >= 24 &&
buffer[0] === 0x89 &&
buffer[1] === 0x50 &&
buffer[2] === 0x4e &&
buffer[3] === 0x47
) {
return {
width: buffer.readUInt32BE(16),
height: buffer.readUInt32BE(20),
};
}
// GIF: signature + logical screen descriptor
if (
buffer.length >= 10 &&
buffer[0] === 0x47 &&
buffer[1] === 0x49 &&
buffer[2] === 0x46
) {
return {
width: buffer.readUInt16LE(6),
height: buffer.readUInt16LE(8),
};
}
// JPEG: scan for SOF marker (cap at 64 KB to bound work)
if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xd8) {
const scanLimit = Math.min(buffer.length, 65536);
let offset = 2;
while (offset + 1 < scanLimit) {
if (buffer[offset] !== 0xff) {
offset++;
continue;
}
const marker = buffer[offset + 1];
offset += 2;
// Standalone markers without a payload
if (
marker === 0x00 ||
marker === 0x01 ||
(marker >= 0xd0 && marker <= 0xd9)
) {
continue;
}
if (offset + 2 > scanLimit) {
break;
}
const segmentLength = buffer.readUInt16BE(offset);
// SOF markers contain the frame dimensions — check before
// the advance guard since this returns immediately.
if (
(marker >= 0xc0 && marker <= 0xc3) ||
(marker >= 0xc5 && marker <= 0xc7) ||
(marker >= 0xc9 && marker <= 0xcb) ||
(marker >= 0xcd && marker <= 0xcf)
) {
if (offset + 7 <= buffer.length) {
return {
height: buffer.readUInt16BE(offset + 3),
width: buffer.readUInt16BE(offset + 5),
};
}
break;
}
// Length includes itself and must be >= 2; bail on malformed data.
if (segmentLength < 2 || offset + segmentLength > buffer.length) {
break;
}
offset += segmentLength;
}
}
} catch {
// Return undefined if parsing fails
}
return undefined;
}
}
+9 -5
View File
@@ -11,6 +11,7 @@ import type { BaseTask } from "@server/queues/tasks/base/BaseTask";
import type { UnfurlSignature, UninstallSignature } from "@server/types";
import type { BaseIssueProvider } from "./BaseIssueProvider";
import type { GroupSyncProvider } from "./GroupSyncProvider";
import type { BaseSearchProvider } from "./BaseSearchProvider";
export enum PluginPriority {
VeryHigh = 0,
@@ -29,6 +30,7 @@ export enum Hook {
EmailTemplate = "emailTemplate",
IssueProvider = "issueProvider",
Processor = "processor",
SearchProvider = "searchProvider",
Task = "task",
UnfurlProvider = "unfurl",
Uninstall = "uninstall",
@@ -45,6 +47,7 @@ type PluginValueMap = {
[Hook.EmailTemplate]: typeof BaseEmail<any>;
[Hook.IssueProvider]: BaseIssueProvider;
[Hook.Processor]: typeof BaseProcessor;
[Hook.SearchProvider]: BaseSearchProvider;
[Hook.Task]: typeof BaseTask<any>;
[Hook.Uninstall]: UninstallSignature;
[Hook.UnfurlProvider]: { unfurl: UnfurlSignature; cacheExpiry: number };
@@ -106,9 +109,10 @@ export class PluginManager {
/**
* Returns all the plugins of a given type in order of priority.
* Triggers loading of all plugins from disk if not already loaded.
*
* @param type The type of plugin to filter by
* @returns A list of plugins
* @param type - the type of plugin to filter by.
* @returns a list of plugins.
*/
public static getHooks<T extends Hook>(type: T) {
this.loadPlugins();
@@ -139,9 +143,9 @@ export class PluginManager {
glob
.sync(path.join(rootDir, "plugins/*/server/!(*.test|schema).[jt]s"))
.forEach((filePath: string) => {
require(path.join(process.cwd(), filePath));
});
.forEach((filePath: string) =>
require(path.join(process.cwd(), filePath))
);
this.loaded = true;
}
+49
View File
@@ -0,0 +1,49 @@
import env from "@server/env";
import Logger from "@server/logging/Logger";
import type { BaseSearchProvider } from "./BaseSearchProvider";
import { Hook, PluginManager } from "./PluginManager";
/**
* Manages selection and caching of the active search provider based on the
* `SEARCH_PROVIDER` environment variable.
*/
export default class SearchProviderManager {
private static cachedProvider: BaseSearchProvider | undefined;
/**
* Returns the active search provider. The provider is determined by matching
* `SEARCH_PROVIDER` env var against registered `Hook.SearchProvider` plugins.
*
* @returns the active search provider instance.
* @throws if no matching provider is found.
*/
public static getProvider(): BaseSearchProvider {
if (this.cachedProvider) {
return this.cachedProvider;
}
const providerId = env.SEARCH_PROVIDER;
const plugins = PluginManager.getHooks(Hook.SearchProvider);
for (const plugin of plugins) {
if (plugin.value.id === providerId) {
this.cachedProvider = plugin.value;
Logger.debug("plugins", `Using search provider: ${plugin.value.id}`);
return this.cachedProvider;
}
}
throw new Error(
`Search provider "${providerId}" not found. Available providers: ${plugins
.map((p) => p.value.id)
.join(", ")}`
);
}
/**
* Reset the cached provider. Useful for testing.
*/
public static reset(): void {
this.cachedProvider = undefined;
}
}
+120
View File
@@ -0,0 +1,120 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { resolveFileSecrets } from "./environment";
describe("resolveFileSecrets", () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "outline-env-test-"));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true });
});
it("should read env value from file when _FILE suffix is used", () => {
const secretFile = path.join(tmpDir, "secret");
fs.writeFileSync(secretFile, "my-secret-value");
const env: Record<string, string | undefined> = {
TEST_SECRET_FILE: secretFile,
};
resolveFileSecrets(env);
expect(env.TEST_SECRET).toBe("my-secret-value");
});
it("should trim whitespace and newlines from file contents", () => {
const secretFile = path.join(tmpDir, "secret");
fs.writeFileSync(secretFile, " my-secret-value\n\n");
const env: Record<string, string | undefined> = {
TEST_TRIM_FILE: secretFile,
};
resolveFileSecrets(env);
expect(env.TEST_TRIM).toBe("my-secret-value");
});
it("should not override existing env value with _FILE", () => {
const secretFile = path.join(tmpDir, "secret");
fs.writeFileSync(secretFile, "file-value");
const env: Record<string, string | undefined> = {
TEST_OVERRIDE: "direct-value",
TEST_OVERRIDE_FILE: secretFile,
};
resolveFileSecrets(env);
expect(env.TEST_OVERRIDE).toBe("direct-value");
});
it("should not override empty-string env value with _FILE", () => {
const secretFile = path.join(tmpDir, "secret");
fs.writeFileSync(secretFile, "file-value");
const env: Record<string, string | undefined> = {
TEST_OVERRIDE_EMPTY: "",
TEST_OVERRIDE_EMPTY_FILE: secretFile,
};
resolveFileSecrets(env);
expect(env.TEST_OVERRIDE_EMPTY).toBe("");
});
it("should skip a bare _FILE key with no base name", () => {
const secretFile = path.join(tmpDir, "secret");
fs.writeFileSync(secretFile, "value");
const env: Record<string, string | undefined> = {
_FILE: secretFile,
};
resolveFileSecrets(env);
expect(env[""]).toBeUndefined();
});
it("should handle missing file gracefully", () => {
const env: Record<string, string | undefined> = {
TEST_MISSING_FILE: path.join(tmpDir, "nonexistent"),
};
resolveFileSecrets(env);
expect(env.TEST_MISSING).toBeUndefined();
});
it("should skip _FILE entries with empty path", () => {
const env: Record<string, string | undefined> = {
TEST_EMPTY_FILE: "",
};
resolveFileSecrets(env);
expect(env.TEST_EMPTY).toBeUndefined();
});
it("should process multiple _FILE entries", () => {
const file1 = path.join(tmpDir, "secret1");
const file2 = path.join(tmpDir, "secret2");
fs.writeFileSync(file1, "value1");
fs.writeFileSync(file2, "value2");
const env: Record<string, string | undefined> = {
SECRET_KEY_FILE: file1,
DATABASE_PASSWORD_FILE: file2,
};
resolveFileSecrets(env);
expect(env.SECRET_KEY).toBe("value1");
expect(env.DATABASE_PASSWORD).toBe("value2");
});
});
+41
View File
@@ -36,4 +36,45 @@ process.env = {
...process.env,
};
/**
* Process environment variables with _FILE suffix by reading the referenced
* file and setting the base variable. If the base variable is already set, the
* file is not read. File contents are trimmed of leading/trailing whitespace.
*
* @param env - the environment record to process.
*/
export function resolveFileSecrets(
env: Record<string, string | undefined>
): void {
for (const key of Object.keys(env)) {
if (key.endsWith("_FILE")) {
const baseKey = key.slice(0, -5);
if (!baseKey.length) {
continue;
}
const filePath = env[key];
if (!filePath) {
continue;
}
if (env[baseKey] !== undefined) {
continue;
}
try {
env[baseKey] = fs.readFileSync(filePath, "utf8").trim();
} catch (err) {
// oxlint-disable-next-line no-console
console.error(
`Failed to read file for ${key} (${filePath}): ${(err as Error).message}`
);
}
}
}
}
resolveFileSecrets(process.env);
export default process.env;
+3 -2
View File
@@ -251,9 +251,10 @@ export class ValidateURL {
const { id, mentionType, modelId } = parseMentionUrl(url);
return (
id &&
isUUID(id) &&
(!id || isUUID(id)) &&
!!mentionType &&
Object.values(MentionType).includes(mentionType as MentionType) &&
!!modelId &&
isUUID(modelId)
);
} catch (_err) {

Some files were not shown because too many files have changed in this diff Show More