mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 517b0fb3ec | |||
| c3c5f148b7 | |||
| 0d0f5cb5c7 | |||
| af22ed4d06 | |||
| 864ec3e24b | |||
| db953c8b2f | |||
| c4479e257e | |||
| 222de9ef01 | |||
| 6e95aa441b | |||
| b70950627e | |||
| e354db8164 | |||
| 7f6ec4ae31 | |||
| 701d4bb6ee | |||
| 032d5c6b95 | |||
| 33b9a52dfe | |||
| 4b16545b10 | |||
| 27dc02aad1 | |||
| df5dd0b98d | |||
| 3cc85f1cdf | |||
| 0b213bd6b8 | |||
| c91b839d22 | |||
| 45b2f6e222 | |||
| b91d9e9a72 | |||
| 979d9a412d | |||
| c2ccdb6fd4 | |||
| 793804cd0d | |||
| f1e5a7cfa7 | |||
| 84aed78ee2 | |||
| 33d8e41e41 | |||
| 7dc1d12d3b | |||
| 0e978e1e34 | |||
| 0390f30e1d | |||
| 4a40712dcc | |||
| 0ba310e027 | |||
| eda59b1450 | |||
| ac1f68a447 | |||
| 5691ea5ae3 | |||
| 8f541eb321 | |||
| c0a6bc911c | |||
| fddf630e49 |
+16
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
<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);
|
||||
@@ -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;
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
});
|
||||
@@ -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} />;
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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}" : ""
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { MenuSeparator } from "~/types";
|
||||
|
||||
export default function separator(): MenuSeparator {
|
||||
return {
|
||||
type: "separator",
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
`;
|
||||
|
||||
|
||||
@@ -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 && <>· {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");
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 +0,0 @@
|
||||
export const runAllPromises = () => new Promise<void>(setImmediate);
|
||||
@@ -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. */
|
||||
|
||||
Vendored
+2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "search-postgres",
|
||||
"name": "PostgreSQL Search",
|
||||
"priority": 0,
|
||||
"description": "Full-text search powered by PostgreSQL tsvector."
|
||||
}
|
||||
+71
-68
@@ -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"`
|
||||
);
|
||||
});
|
||||
+102
-84
@@ -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(" ");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 +0,0 @@
|
||||
export { default } from "./comments";
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user