Compare commits

..

3 Commits

Author SHA1 Message Date
Salihu 2c0f375901 requested changes 2026-03-21 16:07:08 +01:00
Salihu 4894e92d6b add gradient 2026-03-21 16:07:08 +01:00
Salihu 5e6eebd0ec collapsible code blocks 2026-03-21 16:07:03 +01:00
128 changed files with 2112 additions and 3963 deletions
-16
View File
@@ -1,21 +1,5 @@
NODE_ENV=production
# –––––––––––––––––––––––––––––––––––––––––
# ––––––––––– FILE-BASED SECRETS ––––––––
# –––––––––––––––––––––––––––––––––––––––––
#
# Any environment variable can be loaded from a file by appending _FILE to the
# variable name and setting the value to the path of the file. This is useful
# for Docker secrets and other file-based secret management systems.
#
# For example, instead of:
# SECRET_KEY=your_secret_key
# You can use:
# SECRET_KEY_FILE=/run/secrets/outline_secret_key
#
# The file contents will be trimmed of leading/trailing whitespace. If both the
# variable and the _FILE variant are set, the direct variable takes precedence.
# This URL should point to the fully qualified, publicly accessible, URL. If using a
# proxy this will be the proxy's URL.
URL=
+13 -13
View File
@@ -24,17 +24,17 @@ jobs:
- uses: actions/checkout@v5
- name: Enable Corepack
run: corepack enable
- name: Use Node.js 24.x
- name: Use Node.js 22.x
uses: actions/setup-node@v5
with:
node-version: 24.x
node-version: 22.x
cache: "yarn"
- name: Cache node_modules
id: cache-node-modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-modules-24.x-${{ hashFiles('yarn.lock') }}
key: ${{ runner.os }}-node-modules-${{ 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: 24.x
node-version: 22.x
cache: "yarn"
- name: Restore node_modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-modules-24.x-${{ hashFiles('yarn.lock') }}
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
- run: yarn lint --quiet
types:
@@ -66,13 +66,13 @@ jobs:
run: corepack enable
- uses: actions/setup-node@v5
with:
node-version: 24.x
node-version: 22.x
cache: "yarn"
- name: Restore node_modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-modules-24.x-${{ hashFiles('yarn.lock') }}
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
- run: yarn tsc
changes:
@@ -114,13 +114,13 @@ jobs:
run: corepack enable
- uses: actions/setup-node@v5
with:
node-version: 24.x
node-version: 22.x
cache: "yarn"
- name: Restore node_modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-modules-24.x-${{ hashFiles('yarn.lock') }}
key: ${{ runner.os }}-node-modules-${{ 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: 24.x
node-version: 22.x
cache: "yarn"
- name: Restore node_modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-modules-24.x-${{ hashFiles('yarn.lock') }}
key: ${{ runner.os }}-node-modules-${{ 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: 24.x
node-version: 22.x
cache: "yarn"
- name: Restore node_modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-modules-24.x-${{ hashFiles('yarn.lock') }}
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
- name: Set environment to production
run: echo "NODE_ENV=production" >> $GITHUB_ENV
- run: yarn vite:build
-43
View File
@@ -1,43 +0,0 @@
name: Docker Build Check
on:
push:
paths:
- "Dockerfile"
- "Dockerfile.base"
pull_request:
paths:
- "Dockerfile"
- "Dockerfile.base"
env:
BASE_IMAGE_NAME: outline-base
jobs:
build:
runs-on: ubicloud-standard-8
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: docker
- name: Build base image
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.base
tags: ${{ env.BASE_IMAGE_NAME }}:latest
push: false
- name: Build main image
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
push: false
build-args: |
BASE_IMAGE=${{ env.BASE_IMAGE_NAME }}:latest
+15 -15
View File
@@ -17,11 +17,11 @@ jobs:
uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Docker base meta
id: base_meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@v5
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@v4
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push base image
id: base_build
uses: docker/build-push-action@v7
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.base
@@ -51,7 +51,7 @@ jobs:
- name: Docker meta
id: meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@v5
with:
images: |
${{ env.IMAGE_NAME }}
@@ -61,7 +61,7 @@ jobs:
- name: Build and push
id: build
uses: docker/build-push-action@v7
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
@@ -96,11 +96,11 @@ jobs:
uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Docker base meta
id: base_meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@v5
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@v4
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push base image
id: base_build
uses: docker/build-push-action@v7
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.base
@@ -130,7 +130,7 @@ jobs:
- name: Docker meta
id: meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@v5
with:
images: |
${{ env.IMAGE_NAME }}
@@ -140,7 +140,7 @@ jobs:
- name: Build and push
id: build
uses: docker/build-push-action@v7
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
@@ -182,17 +182,17 @@ jobs:
merge-multiple: true
- name: Login to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
tags: |
-94
View File
@@ -1,94 +0,0 @@
name: Update Node.js LTS
on:
schedule:
# Run every Monday at 9:00 UTC
- cron: "0 9 * * 1"
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
update-node:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Check for Node.js LTS update
id: check
run: |
# Get current Node version from Dockerfile
CURRENT_VERSION=$(grep -oP 'FROM node:\K[0-9]+\.[0-9]+\.[0-9]+' Dockerfile.base)
echo "current=$CURRENT_VERSION" >> "$GITHUB_OUTPUT"
echo "Current Node.js version: $CURRENT_VERSION"
# Fetch the latest LTS release (any major version) from nodejs.org
LATEST_VERSION=$(curl -s https://nodejs.org/dist/index.json | \
jq -r '[.[] | select(.lts != false)][0].version' | \
sed 's/^v//')
if ! [[ "$LATEST_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::Failed to fetch a valid LTS version (got '$LATEST_VERSION')"
exit 1
fi
echo "latest=$LATEST_VERSION" >> "$GITHUB_OUTPUT"
echo "Latest Node.js LTS version: $LATEST_VERSION"
if [ "$CURRENT_VERSION" = "$LATEST_VERSION" ]; then
echo "updated=false" >> "$GITHUB_OUTPUT"
echo "Already up to date."
else
echo "updated=true" >> "$GITHUB_OUTPUT"
echo "Update available: $CURRENT_VERSION -> $LATEST_VERSION"
fi
- name: Update Node.js version references
if: steps.check.outputs.updated == 'true'
env:
CURRENT: ${{ steps.check.outputs.current }}
LATEST: ${{ steps.check.outputs.latest }}
run: |
CURRENT_MAJOR=$(echo "$CURRENT" | cut -d. -f1)
LATEST_MAJOR=$(echo "$LATEST" | cut -d. -f1)
# Update Dockerfiles
sed -i "s/node:${CURRENT}-slim/node:${LATEST}-slim/g" Dockerfile
sed -i "s/node:${CURRENT} /node:${LATEST} /g" Dockerfile.base
# Update references that depend on major version
if [ "$CURRENT_MAJOR" != "$LATEST_MAJOR" ]; then
# .nvmrc
echo "$LATEST_MAJOR" > .nvmrc
# CI workflow: step name, node-version, and cache keys
sed -i "s/Use Node.js ${CURRENT_MAJOR}.x/Use Node.js ${LATEST_MAJOR}.x/g" .github/workflows/ci.yml
sed -i "s/node-version: ${CURRENT_MAJOR}.x/node-version: ${LATEST_MAJOR}.x/g" .github/workflows/ci.yml
# Update cache keys: replace node-modules-[optional old version] with new version
sed -i -E "s/node-modules-([0-9]+\.x-)?/node-modules-${LATEST_MAJOR}.x-/g" .github/workflows/ci.yml
# package.json engines field: append new major version
sed -i "s/\"node\": \"\(.*\)\"/\"node\": \"\1 || ${LATEST_MAJOR}\"/" package.json
fi
echo "Updated Node.js from $CURRENT to $LATEST"
- name: Create pull request
if: steps.check.outputs.updated == 'true'
uses: peter-evans/create-pull-request@v7
with:
commit-message: "fix: Update Node.js to ${{ steps.check.outputs.latest }}"
title: "fix: Update Node.js to ${{ steps.check.outputs.latest }}"
body: |
Automated update of Node.js in Docker images.
- **Previous version:** ${{ steps.check.outputs.current }}
- **New version:** ${{ steps.check.outputs.latest }}
[Release notes](https://nodejs.org/en/blog/release/v${{ steps.check.outputs.latest }})
branch: automated/update-node-lts
delete-branch: true
labels: dependencies
+1 -1
View File
@@ -1 +1 @@
24
22
+1 -1
View File
@@ -6,7 +6,7 @@ ARG APP_PATH
WORKDIR $APP_PATH
# ---
FROM node:24.14.1-slim AS runner
FROM node:22.21.0-slim AS runner
LABEL org.opencontainers.image.source="https://github.com/outline/outline"
+1 -1
View File
@@ -1,5 +1,5 @@
ARG APP_PATH=/opt/outline
FROM node:24.14.1 AS deps
FROM node:22.21.0 AS deps
ARG APP_PATH
WORKDIR $APP_PATH
-3
View File
@@ -210,7 +210,6 @@ 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
@@ -230,7 +229,6 @@ export function actionToKBar(
section,
keywords: action.keywords,
shortcut: action.shortcut,
subtitle,
icon,
priority,
perform: () => performAction(action, context),
@@ -256,7 +254,6 @@ export function actionToKBar(
keywords: action.keywords,
shortcut: action.shortcut,
icon,
subtitle,
priority,
},
...children.map((child) => ({
+1 -4
View File
@@ -15,9 +15,6 @@ 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) => {
@@ -61,7 +58,7 @@ export const ShareSection = ({ t }: ActionContext) => t("Share");
export const TeamSection = ({ t }: ActionContext) => t("Workspace");
export const RecentSearchesSection = ({ t }: ActionContext) =>
t("Recently viewed");
t("Recent searches");
RecentSearchesSection.priority = -0.1;
+14
View File
@@ -0,0 +1,14 @@
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>
);
}
+1 -11
View File
@@ -55,15 +55,6 @@ 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") {
@@ -77,7 +68,6 @@ function Breadcrumb(
{item.icon}
<Item
to={item.to}
onClick={handleClick}
$withIcon={!!item.icon}
$highlight={!!highlightFirstItem && index === 0}
>
@@ -86,7 +76,7 @@ function Breadcrumb(
</>
);
},
[actionContext, handleClick, highlightFirstItem]
[actionContext, highlightFirstItem]
);
return (
@@ -4,7 +4,6 @@ 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";
@@ -16,14 +15,6 @@ 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>
@@ -65,16 +56,6 @@ function CommandBarItem(
))}
{action.name}
{action.children?.length ? "…" : ""}
{action.subtitle && (
<Text type="secondary" ellipsis>
&nbsp;&nbsp;
<Highlight
text={action.subtitle}
highlight={SEARCH_RESULT_REGEX}
processResult={replaceResultMarks}
/>
</Text>
)}
</Content>
{action.shortcut?.length ? (
<Shortcut>
@@ -1,94 +0,0 @@
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);
@@ -1,187 +0,0 @@
import { useKBar } from "kbar";
import escapeRegExp from "lodash/escapeRegExp";
import { observer } from "mobx-react";
import { DocumentIcon } from "outline-icons";
import * as React from "react";
import Icon from "@shared/components/Icon";
import useShare from "@shared/hooks/useShare";
import { Minute } from "@shared/utils/time";
import { createAction } from "~/actions";
import {
RecentSearchesSection,
SearchResultsSection,
} from "~/actions/sections";
import useCommandBarActions from "~/hooks/useCommandBarActions";
import useStores from "~/hooks/useStores";
import type Document from "~/models/Document";
import history from "~/utils/history";
import { sharedModelPath } from "~/utils/routeHelpers";
import type { SearchResult } from "~/types";
interface CacheEntry {
timestamp: number;
results: SearchResult[];
}
const cacheTTL = Minute.ms * 5;
const maxRecentDocs = 5;
/**
* Strip server-generated `<b>` highlight tags from context and re-apply them
* using the current search query. This prevents stale highlights when the
* displayed results are from a previous (in-flight) query.
*
* @param context the server-generated context string with `<b>` tags.
* @param query the current search query to highlight.
* @returns the context string with highlights matching the current query.
*/
function rehighlightContext(
context: string | undefined,
query: string
): string | undefined {
if (!context) {
return context;
}
const plain = context.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
const trimmed = query.trim();
if (!trimmed) {
return plain;
}
const terms = trimmed.split(/\s+/).filter(Boolean);
const patterns = [escapeRegExp(trimmed)];
if (terms.length > 1) {
patterns.push(...terms.map((t) => `\\b${escapeRegExp(t)}\\b`));
}
const regex = new RegExp(patterns.join("|"), "gi");
return plain.replace(regex, "<b>$&</b>");
}
/**
* Registers search result actions in the command bar scoped to a public share.
*/
function SharedSearchActions() {
const { documents } = useStores();
const { shareId } = useShare();
const searchCache = React.useRef<Map<string, CacheEntry>>(new Map());
const [results, setResults] = React.useState<SearchResult[]>([]);
const recentDocsRef = React.useRef<Document[]>([]);
const [recentDocs, setRecentDocs] = React.useState<Document[]>([]);
const { searchQuery } = useKBar((state) => ({
searchQuery: state.searchQuery,
}));
const searchQueryRef = React.useRef(searchQuery);
searchQueryRef.current = searchQuery;
React.useEffect(() => {
if (!searchQuery || !shareId) {
setResults([]);
return;
}
const now = Date.now();
const cachedEntry = searchCache.current.get(searchQuery);
const isExpired = cachedEntry
? now - cachedEntry.timestamp > cacheTTL
: true;
if (cachedEntry && !isExpired) {
setResults(cachedEntry.results);
return;
}
const currentQuery = searchQuery;
void documents.search({ query: searchQuery, shareId }).then((res) => {
searchCache.current.set(currentQuery, { timestamp: now, results: res });
if (searchQueryRef.current === currentQuery) {
setResults(res);
}
});
}, [documents, searchQuery, shareId]);
const addRecentDoc = React.useCallback((doc: Document) => {
const prev = recentDocsRef.current;
const filtered = prev.filter((d) => d.id !== doc.id);
const next = [doc, ...filtered].slice(0, maxRecentDocs);
recentDocsRef.current = next;
setRecentDocs(next);
}, []);
const documentIcon = React.useCallback(
(doc: Document) =>
doc.icon ? (
<Icon
value={doc.icon}
initial={doc.initial}
color={doc.color ?? undefined}
/>
) : (
<DocumentIcon />
),
[]
);
const actions = React.useMemo(
() =>
results.map((result) =>
createAction({
id: `shared-search-${result.document.id}`,
name: result.document.titleWithDefault,
description: rehighlightContext(result.context, searchQuery),
keywords: searchQuery,
analyticsName: "Open shared search result",
section: SearchResultsSection,
icon: documentIcon(result.document),
perform: () => {
if (shareId) {
const currentQuery = searchQueryRef.current;
addRecentDoc(result.document);
history.push({
pathname: sharedModelPath(shareId, result.document.url),
search: currentQuery
? `?q=${encodeURIComponent(currentQuery)}`
: undefined,
});
}
},
})
),
[results, shareId, searchQuery, addRecentDoc, documentIcon]
);
const recentDocActions = React.useMemo(
() =>
recentDocs.map((doc) =>
createAction({
id: `shared-recent-doc-${doc.id}`,
name: doc.titleWithDefault,
analyticsName: "Open recent shared document",
section: RecentSearchesSection,
icon: documentIcon(doc),
perform: () => {
if (shareId) {
history.push(sharedModelPath(shareId, doc.url));
}
},
})
),
[recentDocs, shareId, documentIcon]
);
useCommandBarActions(searchQuery ? actions : recentDocActions, [
searchQuery
? actions.map((a) => a.id).join("")
: recentDocActions.map((a) => a.id).join(""),
searchQuery,
]);
return null;
}
export default observer(SharedSearchActions);
+11
View File
@@ -0,0 +1,11 @@
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;
+12 -85
View File
@@ -5,14 +5,9 @@ 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";
@@ -73,9 +68,7 @@ function DocumentBreadcrumb(
to: archivePath(),
}),
createInternalLinkAction({
name: collection ? (
<CollectionName collection={collection} />
) : undefined,
name: collection?.name,
section: ActiveDocumentSection,
icon: collection ? (
<CollectionIcon collection={collection} expanded />
@@ -97,14 +90,17 @@ function DocumentBreadcrumb(
...path.map((node) => {
const title = node.title || t("Untitled");
return createInternalLinkAction({
name: (
<DocumentName
documentId={node.id}
collection={collection}
icon={node.icon}
color={node.color}
title={title}
/>
name: node.icon ? (
<>
<StyledIcon
value={node.icon}
color={node.color}
initial={node.title.charAt(0).toUpperCase()}
/>{" "}
{title}
</>
) : (
title
),
section: ActiveDocumentSection,
to: {
@@ -173,75 +169,6 @@ 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,7 +3,6 @@ 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";
@@ -24,23 +23,13 @@ function DocumentMove({ document }: Props) {
const [selectedPath, selectPath] = useState<NavigationNode | null>(null);
const items = useMemo(() => {
// 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.
// Recursively filter out the document itself and its existing parent doc, if any.
const filterSourceDocument = (node: NavigationNode): NavigationNode => ({
...node,
children: node.children
?.filter((c) => !excludedIds.has(c.id))
?.filter(
(c) => c.id !== document.id && c.id !== document.parentDocumentId
)
.map(filterSourceDocument),
});
@@ -54,7 +43,7 @@ function DocumentMove({ document }: Props) {
);
return nodes;
}, [policies, collectionTrees, document.id]);
}, [policies, collectionTrees, document.id, document.parentDocumentId]);
const move = async () => {
if (!selectedPath) {
+167
View File
@@ -0,0 +1,167 @@
import {
useFocusEffect,
useRovingTabIndex,
} from "@getoutline/react-roving-tabindex";
import { observer } from "mobx-react";
import * as React from "react";
import { Link } from "react-router-dom";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s, hover, ellipsis } from "@shared/styles";
import type Document from "~/models/Document";
import Highlight, { Mark } from "~/components/Highlight";
import { sharedModelPath } from "~/utils/routeHelpers";
type Props = {
document: Document;
highlight: string;
context: string | undefined;
showParentDocuments?: boolean;
showCollection?: boolean;
showPublished?: boolean;
shareId?: string;
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
};
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
function replaceResultMarks(tag: string) {
// don't use SEARCH_RESULT_REGEX here as it causes
// an infinite loop to trigger a regex inside it's own callback
return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
}
function DocumentListItem(
props: Props,
ref: React.RefObject<HTMLAnchorElement>
) {
const { document, highlight, context, shareId, ...rest } = props;
let itemRef: React.Ref<HTMLAnchorElement> =
React.useRef<HTMLAnchorElement>(null);
if (ref) {
itemRef = ref;
}
const { focused, ...rovingTabIndex } = useRovingTabIndex(itemRef, false);
useFocusEffect(focused, itemRef);
return (
<DocumentLink
ref={itemRef}
dir={document.dir}
to={{
pathname: shareId
? sharedModelPath(shareId, document.url)
: document.url,
search: highlight ? `?q=${encodeURIComponent(highlight)}` : undefined,
state: {
title: document.titleWithDefault,
},
}}
{...rest}
{...rovingTabIndex}
onClick={(ev) => {
if (rest.onClick) {
rest.onClick(ev);
}
rovingTabIndex.onClick(ev);
}}
>
<Content>
<Heading dir={document.dir}>
<Title
text={document.titleWithDefault}
highlight={highlight}
dir={document.dir}
/>
</Heading>
{
<ResultContext
text={context}
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
processResult={replaceResultMarks}
/>
}
</Content>
</DocumentLink>
);
}
const Content = styled.div`
flex-grow: 1;
flex-shrink: 1;
min-width: 0;
`;
const DocumentLink = styled(Link)<{
$isStarred?: boolean;
$menuOpen?: boolean;
}>`
display: flex;
align-items: center;
padding: 6px 12px;
max-height: 50vh;
cursor: var(--pointer);
&:not(:last-child) {
margin-bottom: 4px;
}
&:focus-visible {
outline: none;
}
${breakpoint("tablet")`
width: auto;
`};
&:${hover},
&:active,
&:focus,
&:focus-within {
background: ${s("listItemHoverBackground")};
}
${(props) =>
props.$menuOpen &&
css`
background: ${s("listItemHoverBackground")};
`}
`;
const Heading = styled.h4<{ rtl?: boolean }>`
display: flex;
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
align-items: center;
height: 22px;
margin-top: 0;
margin-bottom: 0.25em;
overflow: hidden;
white-space: nowrap;
color: ${s("text")};
`;
const Title = styled(Highlight)`
max-width: 90%;
${ellipsis()}
${Mark} {
padding: 0;
}
`;
const ResultContext = styled(Highlight)`
display: block;
color: ${s("textTertiary")};
font-size: 14px;
margin-top: -0.25em;
margin-bottom: 0;
${ellipsis()}
${Mark} {
padding: 0;
}
`;
export default observer(React.forwardRef(DocumentListItem));
+289
View File
@@ -0,0 +1,289 @@
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,6 +16,7 @@ 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";
@@ -37,12 +38,10 @@ 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, loading }: Props) => {
({ collection, share, invitedInSession, visible }: Props) => {
const { memberships, groupMemberships } = useStores();
const team = useCurrentTeam();
const can = usePolicy(collection);
@@ -50,13 +49,35 @@ 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 && loading;
const showLoading =
!hasMemberships && (membershipLoading || groupMembershipLoading);
React.useEffect(() => {
void fetchMemberships();
void fetchGroupMemberships();
}, [fetchMemberships, fetchGroupMemberships]);
const containerRef = React.useRef<HTMLDivElement | null>(null);
const publicAccessRef = React.useRef<HTMLDivElement | null>(null);
@@ -18,7 +18,6 @@ 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";
@@ -36,22 +35,11 @@ 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,
loading: externalLoading,
}: Props) {
function SharePopover({ collection, visible, onRequestClose }: 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("");
@@ -106,12 +94,10 @@ function SharePopover({
React.useEffect(() => {
if (visible) {
if (externalLoading === undefined) {
preload();
}
void collection.share();
setHasRendered(true);
}
}, [visible, externalLoading, preload]);
}, [collection, visible]);
React.useEffect(() => {
if (prevPendingIds && pendingIds.length > prevPendingIds.length) {
@@ -382,7 +368,6 @@ function SharePopover({
share={share}
invitedInSession={invitedInSession}
visible={visible}
loading={loading}
/>
</div>
</Wrapper>
@@ -4,6 +4,7 @@ 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";
@@ -42,8 +43,6 @@ type Props = {
onRequestClose: () => void;
/** Whether the popover is visible. */
visible: boolean;
/** Whether the share data is currently loading. */
loading: boolean;
};
export const AccessControlList = observer(
@@ -54,14 +53,13 @@ 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 { groupMemberships } = useStores();
const { userMemberships, groupMemberships } = useStores();
const collectionSharingDisabled = document.collection?.sharing === false;
const team = useCurrentTeam();
const can = usePolicy(document);
@@ -77,10 +75,36 @@ 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 && loading;
const showLoading =
!hasMemberships && (groupMembershipLoading || userMembershipLoading);
React.useEffect(() => {
void fetchUserMemberships();
void fetchGroupMemberships();
}, [fetchUserMemberships, fetchGroupMemberships]);
React.useEffect(() => {
calcMaxHeight();
@@ -18,7 +18,6 @@ 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";
@@ -36,16 +35,9 @@ 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,
loading: externalLoading,
}: Props) {
function SharePopover({ document, onRequestClose, visible }: Props) {
const team = useCurrentTeam();
const { t } = useTranslation();
const can = usePolicy(document);
@@ -54,10 +46,6 @@ function SharePopover({
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[]>([]);
@@ -91,14 +79,13 @@ function SharePopover({
}
);
// Fetch sharefocus the link button when the popover is opened
React.useEffect(() => {
if (visible) {
if (externalLoading === undefined) {
preload();
}
void document.share();
setHasRendered(true);
}
}, [visible, externalLoading, preload]);
}, [document, hidePicker, visible]);
// Hide the picker when the popover is closed
React.useEffect(() => {
@@ -390,7 +377,6 @@ function SharePopover({
share={share}
sharedParent={sharedParent}
visible={visible}
loading={loading}
onRequestClose={onRequestClose}
/>
</div>
@@ -14,7 +14,6 @@ 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";
@@ -22,7 +21,6 @@ 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 & {
@@ -150,18 +148,9 @@ export const Suggestions = observer(
if (suggestion instanceof Group) {
return {
title: suggestion.name,
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>
),
subtitle: t("{{ count }} member", {
count: suggestion.memberCount,
}),
image: <GroupAvatar group={suggestion} />,
};
}
@@ -279,13 +268,6 @@ const Separator = styled.div`
margin: 12px 0;
`;
const StyledButtonLink = styled(ButtonLink)`
color: ${s("textTertiary")};
&:hover {
text-decoration: underline;
}
`;
const ScrollableContainer = styled(Scrollable)`
padding: 12px 24px;
margin: -12px -24px;
+1 -1
View File
@@ -31,7 +31,7 @@ function SettingsSidebar() {
const groupedConfig = groupBy(
configs.filter((item) =>
item.group === t("Integrations") && item.pluginId
item.group === "Integrations" && item.pluginId
? integrations.findByService(item.pluginId)
: true
),
+12 -42
View File
@@ -1,15 +1,10 @@
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";
@@ -22,6 +17,8 @@ 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;
@@ -32,7 +29,6 @@ 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;
@@ -42,10 +38,6 @@ function SharedSidebar({ share }: Props) {
? ProsemirrorHelper.isEmptyData(collection?.data)
: false;
const handleOpenSearch = useCallback(() => {
query.toggle();
}, [query]);
useEffect(() => {
ui.tocVisible = share.showTOC;
}, []);
@@ -72,11 +64,9 @@ function SharedSidebar({ share }: Props) {
)}
<ScrollContainer topShadow flex>
<TopSection>
<SearchButton onClick={handleOpenSearch}>
<SearchIcon size={20} />
<SearchLabel>{t("Search")}</SearchLabel>
<Shortcut>{metaDisplay}K</Shortcut>
</SearchButton>
<SearchWrapper>
<StyledSearchPopover shareId={shareId} />
</SearchWrapper>
</TopSection>
<Section>
{share.collectionId ? (
@@ -112,34 +102,14 @@ const TopSection = styled(Flex)`
flex-shrink: 0;
`;
const SearchButton = styled.button`
display: flex;
align-items: center;
gap: 8px;
const SearchWrapper = styled.div`
width: 100%;
padding: 6px 12px;
`;
const StyledSearchPopover = styled(SearchPopover)`
width: 100%;
transition: width 100ms ease-out;
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 SearchLabel = styled.span`
flex-grow: 1;
text-align: left;
`;
const Shortcut = styled.span`
flex-shrink: 0;
font-size: 13px;
`;
export default observer(SharedSidebar);
@@ -265,30 +265,27 @@ function InnerDocumentLink(
};
});
const insertDraftChild = !!(
activeDocument?.isDraft &&
activeDocument?.isActive &&
activeDocument?.parentDocumentId === node.id
);
const nodeChildren = React.useMemo(() => {
const insertDraftDocument =
activeDocument?.isDraft &&
activeDocument?.isActive &&
activeDocument?.parentDocumentId === node.id;
// 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]
);
return collection && insertDraftDocument
? sortNavigationNodes(
[activeDocument?.asNavigationNode, ...node.children],
collection.sort,
false
)
: node.children;
}, [
activeDocument?.isActive,
activeDocument?.isDraft,
activeDocument?.parentDocumentId,
activeDocument?.asNavigationNode,
collection,
node,
]);
const doc = documents.get(node.id);
const title = doc?.title || node.title || t("Untitled");
@@ -7,32 +7,38 @@ export default function useCollectionDocuments(
collection: Collection | undefined,
activeDocument: Document | undefined
) {
const insertDraftDocument = !!(
activeDocument &&
activeDocument.isActive &&
activeDocument.isDraft &&
activeDocument.collectionId === collection?.id &&
!activeDocument.parentDocumentId
const insertDraftDocument = useMemo(
() =>
activeDocument &&
activeDocument.isActive &&
activeDocument.isDraft &&
activeDocument.collectionId === collection?.id &&
!activeDocument.parentDocumentId,
[
activeDocument?.isActive,
activeDocument?.isDraft,
activeDocument?.collectionId,
activeDocument?.parentDocumentId,
collection?.id,
]
);
// 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 draftNavNode
return insertDraftDocument && activeDocument
? sortNavigationNodes(
[draftNavNode, ...collection.sortedDocuments],
[activeDocument.asNavigationNode, ...collection.sortedDocuments],
collection.sort,
false
)
: collection.sortedDocuments;
}, [draftNavNode, collection?.sortedDocuments, collection?.sort]);
}, [
insertDraftDocument,
activeDocument?.asNavigationNode,
collection?.sortedDocuments,
collection?.sort,
]);
}
+30
View File
@@ -0,0 +1,30 @@
import { observer } from "mobx-react";
import { useCallback } from "react";
import { toast } from "sonner";
import { TemplateForm } from "./TemplateForm";
import type Template from "~/models/Template";
type Props = {
template: Template;
onSubmit: () => void;
};
export const TemplateEdit = observer(function TemplateEdit_({
template,
onSubmit,
}: Props) {
const handleSubmit = useCallback(async () => {
try {
await template?.save();
onSubmit?.();
} catch (error) {
toast.error(error.message);
}
}, [template, onSubmit]);
if (!template) {
return null;
}
return <TemplateForm template={template} handleSubmit={handleSubmit} />;
});
+36
View File
@@ -0,0 +1,36 @@
import { observer } from "mobx-react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import Template from "~/models/Template";
import useStores from "~/hooks/useStores";
import { TemplateForm } from "./TemplateForm";
type Props = {
collectionId?: string | null;
onSubmit?: () => void;
};
export const TemplateNew = observer(function TemplateNew_({
collectionId,
onSubmit,
}: Props) {
const { templates } = useStores();
const [template] = useState(
new Template({ title: "", collectionId }, templates)
);
const handleSubmit = useCallback(async () => {
try {
await template.save();
onSubmit?.();
} catch (error) {
toast.error(error.message);
}
}, [template, onSubmit]);
if (!template) {
return null;
}
return <TemplateForm template={template} handleSubmit={handleSubmit} />;
});
+22
View File
@@ -0,0 +1,22 @@
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;
+48 -188
View File
@@ -14,8 +14,6 @@ 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() {
@@ -24,34 +22,13 @@ 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 {
/**
@@ -105,6 +82,20 @@ 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
@@ -218,25 +209,14 @@ export default class FindAndReplaceExtension extends Extension {
}
private scrollToCurrentMatch() {
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",
});
}
const element = window.document.querySelector(
`.${this.options.resultCurrentClassName}`
);
if (element) {
scrollIntoView(element, {
scrollMode: "if-needed",
block: "center",
});
}
}
@@ -404,83 +384,13 @@ export default class FindAndReplaceExtension extends Extension {
});
}
/**
* 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) {
private createDeco(doc: Node) {
this.search(doc);
return this.decorations.length
return this.decorations
? 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;
}
@@ -490,85 +400,35 @@ export default class FindAndReplaceExtension extends Extension {
}
get plugins() {
if (supportsHighlightAPI) {
return [this.highlightAPIPlugin];
}
return [this.decorationPlugin];
}
return [
new Plugin({
key: pluginKey,
state: {
init: () => DecorationSet.empty,
apply: (tr, decorationSet) => {
const action = tr.getMeta(pluginKey);
/** 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;
if (action) {
if (action.open) {
this.open = true;
}
return this.createDeco(tr.doc);
}
this.search(tr.doc);
return generation + 1;
}
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();
if (tr.docChanged) {
return decorationSet.map(tr.mapping, tr.doc);
}
return decorationSet;
},
destroy: () => {
CSS.highlights?.delete("search-results");
CSS.highlights?.delete("search-results-current");
},
props: {
decorations(state) {
return this.getState(state);
},
};
},
});
}
/** 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);
},
},
});
}),
];
}
public widget = ({ readOnly }: WidgetProps) => (
+1 -1
View File
@@ -19,7 +19,7 @@ export default class Suggestion extends Extension {
super(options);
this.openRegex = new RegExp(
`(?:^|\\s|\\(|[\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Hangul}])${escapeRegExp(
`(?:^|\\s|\\()${escapeRegExp(
this.options.trigger
)}(${`[\\p{L}\/\\p{M}\\d${
this.options.allowSpaces ? "\\s{1}" : ""
+1 -1
View File
@@ -944,7 +944,7 @@ const EditorContainer = styled(Styles)<{
a#comment-${props.focusedCommentId}
~ span.component-image
div.image-wrapper {
outline: ${props.theme.commentedImageOutlineDark} solid 2px;
outline: ${props.theme.commentMarkBackground} solid 2px;
}
`}
-3
View File
@@ -61,9 +61,6 @@ export default function codeMenuItems(
: undefined,
tooltip: dictionary.copy,
},
{
name: "separator",
},
{
name: "edit_mermaid",
icon: <EditIcon />,
+2
View File
@@ -32,6 +32,7 @@ export default function useDictionary() {
codeBlock: t("Code block"),
codeCopied: t("Copied to clipboard"),
codeInline: t("Code"),
collapseCode: t("Collapse"),
comment: t("Comment"),
copy: t("Copy"),
createLink: t("Create link"),
@@ -54,6 +55,7 @@ export default function useDictionary() {
replaceImage: t("Replace image"),
em: t("Italic"),
embedInvalidLink: t("Sorry, that link wont work for this embed type"),
expandCode: t("Expand"),
file: t("File attachment"),
pdf: t("Embed PDF"),
enterLink: `${t("Enter a link")}`,
-79
View File
@@ -1,79 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Pagination } from "@shared/constants";
import type Collection from "~/models/Collection";
import type Document from "~/models/Document";
import useStores from "./useStores";
type Params =
| { document: Document; collection?: undefined }
| { collection: Collection; document?: undefined };
/**
* Hook to preload all data needed by the share popover. Returns a `preload`
* function that can be called on hover so the popover renders instantly.
*
* @param params - the document or collection to load share data for.
* @returns preload function, loading state, and reset function.
*/
export default function useShareDataLoader(params: Params) {
const { userMemberships, groupMemberships, memberships } = useStores();
const [loading, setLoading] = useState(false);
const requestedRef = useRef(false);
const requestCountRef = useRef(0);
const entityId = params.document?.id ?? params.collection?.id;
// Reset when the entity changes so preload fires for the new target.
useEffect(() => {
requestedRef.current = false;
setLoading(false);
}, [entityId]);
const preload = useCallback(() => {
if (requestedRef.current) {
return;
}
requestedRef.current = true;
setLoading(true);
const thisRequest = ++requestCountRef.current;
const promises: Promise<unknown>[] = [];
if (params.document) {
const doc = params.document;
promises.push(
doc.share(),
userMemberships.fetchDocumentMemberships({
id: doc.id,
limit: Pagination.defaultLimit,
}),
groupMemberships.fetchAll({ documentId: doc.id })
);
} else {
const col = params.collection;
promises.push(
col.share(),
memberships.fetchAll({ id: col.id }),
groupMemberships.fetchAll({ collectionId: col.id })
);
}
void Promise.all(promises).finally(() => {
if (requestCountRef.current === thisRequest) {
setLoading(false);
}
});
}, [
params.document,
params.collection,
userMemberships,
groupMemberships,
memberships,
]);
const reset = useCallback(() => {
requestedRef.current = false;
}, []);
return { preload, loading, reset };
}
+30
View File
@@ -0,0 +1,30 @@
import { useLayoutEffect, useState } from "react";
/**
* Hook to get the current viewport height, accounting for mobile virtual keyboards.
* Uses the VisualViewport API when available, falling back to window.innerHeight.
*
* @returns The current viewport height in pixels
*/
export default function useViewportHeight(): number | void {
// https://developer.mozilla.org/en-US/docs/Web/API/VisualViewport#browser_compatibility
// Note: No support in Firefox at time of writing, however this mainly exists
// for virtual keyboards on mobile devices, so that's okay.
const [height, setHeight] = useState<number>(
() => window.visualViewport?.height || window.innerHeight
);
useLayoutEffect(() => {
const handleResize = () => {
setHeight(() => window.visualViewport?.height || window.innerHeight);
};
window.visualViewport?.addEventListener("resize", handleResize);
return () => {
window.visualViewport?.removeEventListener("resize", handleResize);
};
}, []);
return height;
}
+7
View File
@@ -0,0 +1,7 @@
import type { MenuSeparator } from "~/types";
export default function separator(): MenuSeparator {
return {
type: "separator",
};
}
-4
View File
@@ -64,10 +64,6 @@ class Team extends Model {
@observable
defaultUserRole: UserRole;
@Field
@observable
guidanceMCP: string | null;
@Field
@observable
preferences: TeamPreferences | null;
@@ -11,7 +11,6 @@ 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";
@@ -34,23 +33,14 @@ 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(() => {
handleOpenChange(false);
}, [handleOpenChange]);
setOpen(false);
}, []);
const handleMouseEnter = useCallback(() => {
void collection.share();
}, [collection]);
if (isMobile) {
return null;
@@ -63,9 +53,9 @@ function ShareButton({ collection }: Props) {
);
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger>
<Button icon={icon} neutral onMouseEnter={preload}>
<Button icon={icon} neutral onMouseEnter={handleMouseEnter}>
{t("Share")}
</Button>
</PopoverTrigger>
@@ -82,7 +72,6 @@ function ShareButton({ collection }: Props) {
collection={collection}
onRequestClose={closePopover}
visible={open}
loading={loading}
/>
</Suspense>
</PopoverContent>
@@ -103,11 +103,6 @@ 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();
@@ -259,13 +254,11 @@ function CommentForm({
const handleMounted = React.useCallback(
(ref) => {
if (autoFocus && ref && !hasFocusedOnMount.current) {
if (!draft) {
ref.focusAtStart();
}
ref.focusAtStart();
hasFocusedOnMount.current = true;
}
},
[autoFocus, draft]
[autoFocus]
);
const presence = animatePresence
+1 -1
View File
@@ -93,7 +93,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
}, [ref]);
React.useEffect(() => {
if (focusedComment && focusedComment.documentId === document.id) {
if (focusedComment) {
const viewingResolved = params.get("resolved") === "";
if (
(focusedComment.isResolved && !viewingResolved) ||
@@ -7,9 +7,7 @@ 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";
@@ -132,16 +130,8 @@ 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(strippedData, title, icon, iconColor);
const result = splitIntoSlides(data, title, icon, iconColor);
const contentSlides = result.filter((s) => s.type === "content");
const hasContent =
contentSlides.length > 0 &&
@@ -154,7 +144,7 @@ function PresentationMode({ title, icon, iconColor, data, onClose }: Props) {
}
return result;
}, [strippedData, title, icon, iconColor]);
}, [data, title, icon, iconColor]);
const totalSlides = slides.length;
@@ -256,7 +246,7 @@ function PresentationMode({ title, icon, iconColor, data, onClose }: Props) {
}
const availableWidth = container.clientWidth - 160;
const availableHeight = container.clientHeight - 160;
const availableHeight = container.clientHeight - 48 - 160;
const scaleX = availableWidth / width;
const scaleY = availableHeight / height;
const newScale = Math.min(scaleX, scaleY, 1.5);
@@ -454,13 +444,8 @@ const TopBar = styled.div<{ $idle: boolean }>`
align-items: center;
justify-content: center;
padding: 16px;
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 1;
position: relative;
opacity: ${(props) => (props.$idle ? 0 : 1)};
pointer-events: ${(props) => (props.$idle ? "none" : "auto")};
transition: opacity 300ms ease;
`;
+8 -19
View File
@@ -10,7 +10,6 @@ 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";
@@ -32,23 +31,14 @@ 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(() => {
handleOpenChange(false);
}, [handleOpenChange]);
setOpen(false);
}, []);
const handleMouseEnter = useCallback(() => {
void document.share();
}, [document]);
if (isMobile) {
return null;
@@ -57,9 +47,9 @@ function ShareButton({ document }: Props) {
const icon = document.isPubliclyShared ? <GlobeIcon /> : undefined;
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger>
<Button icon={icon} neutral onMouseEnter={preload}>
<Button icon={icon} neutral onMouseEnter={handleMouseEnter}>
{t("Share")} {domain && <>&middot; {domain}</>}
</Button>
</PopoverTrigger>
@@ -76,7 +66,6 @@ function ShareButton({ document }: Props) {
document={document}
onRequestClose={closePopover}
visible={open}
loading={loading}
/>
</Suspense>
</PopoverContent>
@@ -89,16 +89,12 @@ function AuthenticationProvider(props: Props) {
// Populate hidden form fields with authentication data
if (formRef.current) {
const createInputs = (obj: Record<string, unknown>, prefix = "") => {
const createInputs = (obj: any, prefix = "") => {
Object.entries(obj).forEach(([key, value]) => {
if (value === undefined || value === null) {
return;
}
const fieldName = prefix ? `${prefix}[${key}]` : key;
if (typeof value === "object" && !Array.isArray(value)) {
createInputs(value as Record<string, unknown>, fieldName);
if (value && typeof value === "object" && !Array.isArray(value)) {
createInputs(value, fieldName);
} else {
// Create hidden input for primitive values
const input = document.createElement("input");
-39
View File
@@ -4,7 +4,6 @@ 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";
@@ -31,18 +30,6 @@ 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]);
@@ -59,7 +46,6 @@ function Features() {
<SettingRow
name={TeamPreference.MCP}
label={t("MCP server")}
border={!team.getPreference(TeamPreference.MCP)}
description={
<>
<Text type="secondary" as="p">
@@ -111,31 +97,6 @@ function Features() {
/>
</SettingRow>
{team.getPreference(TeamPreference.MCP) && (
<SettingRow
name="guidanceMCP"
label={t("Additional guidance")}
description={
<>
<div style={{ marginBottom: 8 }}>
{t(
"You can use these optional instructions to tell MCP clients how to use your knowledge base."
)}
</div>
<Input
id="guidanceMCP"
type="textarea"
rows={6}
value={team.guidanceMCP ?? ""}
maxLength={TeamValidation.maxGuidanceMCPLength}
onChange={handleGuidanceMCPChange}
onBlur={handleGuidanceMCPBlur}
/>
</>
}
/>
)}
<SettingRow
name="answers"
label={t("AI answers")}
+1 -1
View File
@@ -27,7 +27,7 @@ function Integrations() {
const groupedItems = groupBy(
items.filter(
(item) =>
item.group === t("Integrations") &&
item.group === "Integrations" &&
item.enabled &&
item.path !== settingsPath("integrations") &&
item.name.toLowerCase().includes(query.toLowerCase())
-2
View File
@@ -11,7 +11,6 @@ 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";
@@ -271,7 +270,6 @@ function SharedScene() {
<CollectionScene collection={model} />
) : null}
</Layout>
<SharedCommandBar />
<ClickablePadding minHeight="20vh" />
</DocumentContextProvider>
</ThemeProvider>
+1 -1
View File
@@ -618,7 +618,7 @@ export default class DocumentsStore extends Store<Document> {
});
const collection = this.getCollectionForDocument(document);
if (collection) {
collection.removeDocument(document.id);
await collection.refresh();
}
};
+1 -5
View File
@@ -24,11 +24,7 @@ class UnfurlsStore extends Store<Unfurl<any>> {
}): Promise<Unfurl<UnfurlType> | undefined> => {
try {
const protocol = new URL(url).protocol;
if (
protocol !== "http:" &&
protocol !== "https:" &&
protocol !== "mention:"
) {
if (protocol !== "http:" && protocol !== "https:" && protocol !== "mention:") {
return;
}
} catch (_err) {
+1
View File
@@ -0,0 +1 @@
export const runAllPromises = () => new Promise<void>(setImmediate);
-1
View File
@@ -138,7 +138,6 @@ type BaseAction = {
analyticsName?: string;
name: ((context: ActionContext) => React.ReactNode) | React.ReactNode;
section: ((context: ActionContext) => string) | string;
description?: ((context: ActionContext) => string) | string;
shortcut?: string[];
keywords?: string;
/** Higher number is higher in results, default is 0. */
-2
View File
@@ -141,8 +141,6 @@ declare module "styled-components" {
textDiffDeletedBackground: string;
placeholder: string;
commentMarkBackground: string;
commentedImageOutlineLight: string;
commentedImageOutlineDark: string;
sidebarBackground: string;
sidebarHoverBackground: string;
sidebarActiveBackground: string;
+6 -2
View File
@@ -41,7 +41,7 @@
"url": "https://github.com/sponsors/outline"
},
"engines": {
"node": ">=20.12 <21 || 22 || 24"
"node": ">=20.12 <21 || 22"
},
"repository": {
"type": "git",
@@ -83,6 +83,7 @@
"@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",
@@ -111,6 +112,7 @@
"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",
@@ -169,7 +171,7 @@
"markdown-it": "^14.1.0",
"markdown-it-container": "^3.0.0",
"markdown-it-emoji": "^3.0.0",
"mermaid": "11.13.0",
"mermaid": "11.12.1",
"mime-types": "^3.0.1",
"mobx": "^4.15.4",
"mobx-react": "^6.3.1",
@@ -285,6 +287,7 @@
"@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",
@@ -379,6 +382,7 @@
"@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",
+2 -22
View File
@@ -95,20 +95,12 @@ 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,
excludeCredentials: existingPasskeys.map((pk) => ({
id: pk.credentialId,
transports: pk.transports as AuthenticatorTransportFuture[],
})),
// Don't exclude credentials, so we can detect if one is already registered (optional)
authenticatorSelection: {
residentKey: "preferred",
userVerification: "preferred",
@@ -162,7 +154,6 @@ router.post(
}
const { verified, registrationInfo } = verification;
const ZERO_AAGUID = "00000000-0000-0000-0000-000000000000";
if (verified && registrationInfo) {
const { credential, aaguid } = registrationInfo;
@@ -175,7 +166,7 @@ router.post(
const userAgent = ctx.request.get("user-agent");
const transports = body.response.transports || [];
// Check if already exists by credential ID
// Check if already exists
const existing = await UserPasskey.findOne({
where: { credentialId: credentialIdBase64 },
});
@@ -192,17 +183,6 @@ router.post(
aaguid,
});
} else {
// Check if user already has a passkey from the same authenticator
if (aaguid && aaguid !== ZERO_AAGUID) {
const duplicateDevice = await UserPasskey.findOne({
where: { userId: user.id, aaguid },
});
if (duplicateDevice) {
throw ValidationError("You already have a passkey on this device");
}
}
await UserPasskey.createWithCtx(ctx, {
userId: user.id,
credentialId: credentialIdBase64,
-6
View File
@@ -1,6 +0,0 @@
{
"id": "search-postgres",
"name": "PostgreSQL Search",
"priority": 0,
"description": "Full-text search powered by PostgreSQL tsvector."
}
-13
View File
@@ -1,13 +0,0 @@
import { PluginManager, Hook } from "@server/utils/PluginManager";
import config from "../plugin.json";
import PostgresSearchProvider from "./PostgresSearchProvider";
const provider = new PostgresSearchProvider();
PluginManager.add([
{
...config,
type: Hook.SearchProvider,
value: provider,
},
]);
+2 -2
View File
@@ -23,7 +23,7 @@ import {
AuthenticationProvider,
Comment,
} from "@server/models";
import SearchProviderManager from "@server/utils/SearchProviderManager";
import SearchHelper from "@server/models/helpers/SearchHelper";
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 SearchProviderManager.getProvider().searchForUser(user, options);
const { results, total } = await SearchHelper.searchForUser(user, options);
await SearchQuery.create({
userId: user ? user.id : null,
+4 -7
View File
@@ -1,4 +1,5 @@
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";
@@ -25,8 +26,6 @@ type Props = Optional<
| "publishedAt"
| "createdAt"
| "updatedAt"
| "createdById"
| "lastModifiedById"
>
> & {
state?: Buffer;
@@ -60,8 +59,6 @@ export default async function documentCreator(
editorVersion,
publishedAt,
sourceMetadata,
createdById,
lastModifiedById,
}: Props
): Promise<Document> {
const { user } = ctx.state.auth;
@@ -97,7 +94,7 @@ export default async function documentCreator(
: text
? ProsemirrorHelper.toProsemirror(text).toJSON()
: template
? ProsemirrorHelper.replaceTemplateVariables(
? SharedProsemirrorHelper.replaceTemplateVariables(
await DocumentHelper.toJSON(template),
user
)
@@ -112,8 +109,8 @@ export default async function documentCreator(
teamId: user.teamId,
createdAt,
updatedAt: updatedAt ?? createdAt,
lastModifiedById: lastModifiedById ?? createdById ?? user.id,
createdById: createdById ?? user.id,
lastModifiedById: user.id,
createdById: user.id,
templateId,
publishedAt,
importId,
-8
View File
@@ -772,14 +772,6 @@ 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
*/
@@ -1,14 +0,0 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
return queryInterface.addColumn("teams", "guidanceMCP", {
type: Sequelize.TEXT,
allowNull: true,
});
},
down: async (queryInterface) => {
return queryInterface.removeColumn("teams", "guidanceMCP");
},
};
-19
View File
@@ -1,5 +1,4 @@
import { randomString } from "@shared/random";
import { Scope } from "@shared/types";
import { buildApiKey } from "@server/test/factories";
import ApiKey from "./ApiKey";
@@ -111,23 +110,5 @@ describe("#ApiKey", () => {
expect(apiKey.canAccess("/api/documents.create")).toBe(false);
expect(apiKey.canAccess("/api/collections.create")).toBe(false);
});
it("should allow MCP access for scoped API keys", async () => {
const apiKey = await buildApiKey({
name: "Dev",
scope: [Scope.Read],
});
expect(apiKey.canAccess("/mcp")).toBe(true);
expect(apiKey.canAccess("/mcp/")).toBe(true);
});
it("should allow MCP access for unscoped API keys", async () => {
const apiKey = await buildApiKey({
name: "Dev",
});
expect(apiKey.canAccess("/mcp")).toBe(true);
});
});
});
-6
View File
@@ -176,12 +176,6 @@ class ApiKey extends ParanoidModel<
return true;
}
// MCP endpoint access is allowed if the key has any valid scope.
// Fine-grained scope enforcement happens at the tool level.
if (path.startsWith("/mcp")) {
return this.scope.length > 0;
}
return AuthenticationHelper.canAccess(path, this.scope);
};
}
-189
View File
@@ -1,189 +0,0 @@
import { v4 as uuidv4 } from "uuid";
import { MentionType } from "@shared/types";
import { buildComment, buildDocument, buildUser } from "@server/test/factories";
import Comment from "./Comment";
describe("Comment", () => {
describe("toPlainText", () => {
it("should convert simple text to plain text", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const comment = await buildComment({
userId: user.id,
documentId: document.id,
});
const text = comment.toPlainText();
expect(text).toBe("test");
});
it("should convert comment with mention to plain text", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const comment = await Comment.create({
documentId: document.id,
createdById: user.id,
data: {
type: "doc",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: "Hello ",
},
{
type: "mention",
attrs: {
type: MentionType.User,
label: "Jane",
modelId: uuidv4(),
id: uuidv4(),
},
},
],
},
],
},
});
const text = comment.toPlainText();
expect(text).toBe("Hello @Jane");
});
it("should convert comment with document mention to plain text", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const comment = await Comment.create({
documentId: document.id,
createdById: user.id,
data: {
type: "doc",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: "See ",
},
{
type: "mention",
attrs: {
type: MentionType.Document,
label: "My Document",
modelId: uuidv4(),
id: uuidv4(),
},
},
],
},
],
},
});
const text = comment.toPlainText();
expect(text).toBe("See My Document");
});
});
describe("resolve", () => {
it("should resolve the comment", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const comment = await buildComment({
userId: user.id,
documentId: document.id,
});
comment.resolve(user);
expect(comment.isResolved).toBe(true);
expect(comment.resolvedById).toBe(user.id);
expect(comment.resolvedAt).toBeTruthy();
});
it("should throw if already resolved", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const comment = await buildComment({
userId: user.id,
documentId: document.id,
});
comment.resolve(user);
expect(() => comment.resolve(user)).toThrow();
});
it("should throw if comment is a reply", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const parent = await buildComment({
userId: user.id,
documentId: document.id,
});
const reply = await buildComment({
userId: user.id,
documentId: document.id,
parentCommentId: parent.id,
});
expect(() => reply.resolve(user)).toThrow();
});
});
describe("unresolve", () => {
it("should unresolve the comment", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const comment = await buildComment({
userId: user.id,
documentId: document.id,
});
comment.resolve(user);
comment.unresolve();
expect(comment.isResolved).toBe(false);
expect(comment.resolvedById).toBeNull();
expect(comment.resolvedAt).toBeNull();
});
it("should throw if not resolved", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const comment = await buildComment({
userId: user.id,
documentId: document.id,
});
expect(() => comment.unresolve()).toThrow();
});
});
});
+2 -2
View File
@@ -13,7 +13,7 @@ import {
import type { ProsemirrorData, ReactionSummary } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { CommentValidation } from "@shared/validations";
import { commentSchema } from "@server/editor";
import { basicSchema } 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(commentSchema, this.data);
const node = Node.fromJSON(basicSchema, this.data);
return ProsemirrorHelper.toPlainText(node);
}
+1 -50
View File
@@ -1,5 +1,4 @@
import { randomUUID } from "node:crypto";
import { buildTeam, buildCollection, buildAttachment } from "@server/test/factories";
import { buildTeam, buildCollection } from "@server/test/factories";
describe("Team", () => {
describe("collectionIds", () => {
@@ -41,52 +40,4 @@ describe("Team", () => {
expect(team.previousSubdomains?.[1]).toEqual(subdomain);
});
});
describe("publicAvatarUrl", () => {
it("should return null when no avatarUrl is set", async () => {
const team = await buildTeam({ avatarUrl: null });
const result = await team.publicAvatarUrl();
expect(result).toBeNull();
});
it("should return external URL unchanged", async () => {
const url = "https://example.com/logo.png";
const team = await buildTeam({ avatarUrl: url });
const result = await team.publicAvatarUrl();
expect(result).toEqual(url);
});
it("should return signed URL for private-bucket attachment redirect", async () => {
const team = await buildTeam();
const attachment = await buildAttachment({
teamId: team.id,
acl: "private",
});
await team.update({
avatarUrl: `/api/attachments.redirect?id=${attachment.id}`,
});
const result = await team.publicAvatarUrl();
expect(result).toEqual(await attachment.signedUrl);
});
it("should return canonical URL for public-bucket attachment redirect", async () => {
const team = await buildTeam();
const id = randomUUID();
const attachment = await buildAttachment({
id,
teamId: team.id,
key: `avatars/${team.id}/${id}/logo.png`,
acl: "public-read",
});
await team.update({
avatarUrl: `/api/attachments.redirect?id=${attachment.id}`,
});
const result = await team.publicAvatarUrl();
expect(result).toEqual(attachment.canonicalUrl);
});
});
});
-42
View File
@@ -29,7 +29,6 @@ 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";
@@ -58,8 +57,6 @@ export enum TeamFlag {
MarkedSafe = "markedSafe",
}
const avatarRedirectPattern = new RegExp(attachmentRedirectRegex.source, "i");
@Scopes(() => ({
withDomains: {
include: [{ model: TeamDomain }],
@@ -148,37 +145,6 @@ 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;
@@ -221,14 +187,6 @@ 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,6 +2,7 @@ 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";
@@ -972,7 +973,7 @@ describe("ProsemirrorHelper", () => {
},
]);
const images = ProsemirrorHelper.getImages(doc);
const images = SharedProsemirrorHelper.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");
+30 -4
View File
@@ -21,7 +21,6 @@ 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";
@@ -63,7 +62,7 @@ export type MentionAttrs = {
};
@trace()
export class ProsemirrorHelper extends SharedProsemirrorHelper {
export class ProsemirrorHelper {
/**
* Returns the input text as a Y.Doc.
*
@@ -256,6 +255,33 @@ export class ProsemirrorHelper extends SharedProsemirrorHelper {
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
@@ -849,8 +875,8 @@ export class ProsemirrorHelper extends SharedProsemirrorHelper {
doc: Node,
user: User
): Promise<Node> {
const images = ProsemirrorHelper.getImages(doc);
const videos = ProsemirrorHelper.getVideos(doc);
const images = SharedProsemirrorHelper.getImages(doc);
const videos = SharedProsemirrorHelper.getVideos(doc);
const nodes = [...images, ...videos];
if (!nodes.length) {
@@ -4,6 +4,7 @@ import {
SortFilter,
StatusFilter,
} from "@shared/types";
import SearchHelper from "@server/models/helpers/SearchHelper";
import {
buildDocument,
buildDraftDocument,
@@ -13,19 +14,15 @@ import {
buildShare,
buildGroup,
} from "@server/test/factories";
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();
import UserMembership from "../UserMembership";
import GroupMembership from "../GroupMembership";
beforeEach(async () => {
jest.resetAllMocks();
await buildDocument();
});
describe("PostgresSearchProvider", () => {
describe("SearchHelper", () => {
describe("#searchForTeam", () => {
it("should return search results from public collections", async () => {
const team = await buildTeam();
@@ -37,7 +34,7 @@ describe("PostgresSearchProvider", () => {
collectionId: collection.id,
title: "test",
});
const { results } = await provider.searchForTeam(team, {
const { results } = await SearchHelper.searchForTeam(team, {
query: "test",
});
expect(results.length).toBe(1);
@@ -61,7 +58,7 @@ describe("PostgresSearchProvider", () => {
title: "document 2",
}),
]);
const { results } = await provider.searchForTeam(team);
const { results } = await SearchHelper.searchForTeam(team);
expect(results.length).toBe(2);
expect(results.map((r) => r.document.id).sort()).toEqual(
documents.map((doc) => doc.id).sort()
@@ -79,7 +76,7 @@ describe("PostgresSearchProvider", () => {
collectionId: collection.id,
title: "test",
});
const { results } = await provider.searchForTeam(team, {
const { results } = await SearchHelper.searchForTeam(team, {
query: "test",
});
expect(results.length).toBe(0);
@@ -96,7 +93,7 @@ describe("PostgresSearchProvider", () => {
collectionId: collection.id,
title: "test",
});
const { results } = await provider.searchForTeam(team, {
const { results } = await SearchHelper.searchForTeam(team, {
query: "test",
collectionId: collection.id,
});
@@ -125,7 +122,7 @@ describe("PostgresSearchProvider", () => {
includeChildDocuments: true,
});
const { results } = await provider.searchForTeam(team, {
const { results } = await SearchHelper.searchForTeam(team, {
query: "test",
collectionId: collection.id,
share,
@@ -135,7 +132,7 @@ describe("PostgresSearchProvider", () => {
it("should handle no collections", async () => {
const team = await buildTeam();
const { results } = await provider.searchForTeam(team, {
const { results } = await SearchHelper.searchForTeam(team, {
query: "test",
});
expect(results.length).toBe(0);
@@ -151,7 +148,7 @@ describe("PostgresSearchProvider", () => {
collectionId: collection.id,
title: "test with backslash \\",
});
const { results } = await provider.searchForTeam(team, {
const { results } = await SearchHelper.searchForTeam(team, {
query: "test with backslash \\",
});
expect(results.length).toBe(1);
@@ -173,7 +170,7 @@ describe("PostgresSearchProvider", () => {
collectionId: collection.id,
title: "test number 2",
});
const { total } = await provider.searchForTeam(team, {
const { total } = await SearchHelper.searchForTeam(team, {
query: "test",
});
expect(total).toBe(2);
@@ -191,7 +188,7 @@ describe("PostgresSearchProvider", () => {
});
document.title = "change";
await document.save();
const { total } = await provider.searchForTeam(team, {
const { total } = await SearchHelper.searchForTeam(team, {
query: "test number",
});
expect(total).toBe(1);
@@ -209,7 +206,7 @@ describe("PostgresSearchProvider", () => {
});
document.title = "change";
await document.save();
const { total } = await provider.searchForTeam(team, {
const { total } = await SearchHelper.searchForTeam(team, {
query: "title doesn't exist",
});
expect(total).toBe(0);
@@ -237,7 +234,7 @@ describe("PostgresSearchProvider", () => {
deletedAt: new Date(),
title: "test",
});
const { results } = await provider.searchForUser(user, {
const { results } = await SearchHelper.searchForUser(user, {
query: "test",
});
expect(results.length).toBe(1);
@@ -266,7 +263,7 @@ describe("PostgresSearchProvider", () => {
title: "document 2",
}),
]);
const { results } = await provider.searchForUser(user);
const { results } = await SearchHelper.searchForUser(user);
expect(results.length).toBe(2);
expect(results.map((r) => r.document.id).sort()).toEqual(
documents.map((doc) => doc.id).sort()
@@ -294,7 +291,7 @@ describe("PostgresSearchProvider", () => {
title: "document 2",
}),
]);
const { results } = await provider.searchForUser(user, {
const { results } = await SearchHelper.searchForUser(user, {
collectionId: collection.id,
});
expect(results.length).toBe(2);
@@ -342,7 +339,7 @@ describe("PostgresSearchProvider", () => {
title: "document 2 in collection 2",
}),
]);
const { results } = await provider.searchForUser(user, {
const { results } = await SearchHelper.searchForUser(user, {
collectionId: collection1.id,
});
expect(results.length).toBe(2);
@@ -354,7 +351,7 @@ describe("PostgresSearchProvider", () => {
it("should handle no collections", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const { results } = await provider.searchForUser(user, {
const { results } = await SearchHelper.searchForUser(user, {
query: "test",
});
expect(results.length).toBe(0);
@@ -384,7 +381,7 @@ describe("PostgresSearchProvider", () => {
title: "test",
archivedAt: new Date(),
});
const { results } = await provider.searchForUser(user, {
const { results } = await SearchHelper.searchForUser(user, {
query: "test",
statusFilter: [StatusFilter.Draft],
});
@@ -409,7 +406,7 @@ describe("PostgresSearchProvider", () => {
permission: DocumentPermission.Read,
});
const { results } = await provider.searchForUser(user, {
const { results } = await SearchHelper.searchForUser(user, {
query: "test",
statusFilter: [StatusFilter.Published, StatusFilter.Archived],
});
@@ -440,7 +437,7 @@ describe("PostgresSearchProvider", () => {
title: "test",
archivedAt: new Date(),
});
const { results } = await provider.searchForUser(user, {
const { results } = await SearchHelper.searchForUser(user, {
query: "test",
statusFilter: [StatusFilter.Published],
});
@@ -477,7 +474,7 @@ describe("PostgresSearchProvider", () => {
title: "test",
archivedAt: new Date(),
});
const { results } = await provider.searchForUser(user, {
const { results } = await SearchHelper.searchForUser(user, {
query: "test",
statusFilter: [StatusFilter.Archived],
});
@@ -505,7 +502,7 @@ describe("PostgresSearchProvider", () => {
title: "test",
archivedAt: new Date(),
});
const { results } = await provider.searchForUser(user, {
const { results } = await SearchHelper.searchForUser(user, {
query: "test",
statusFilter: [StatusFilter.Archived, StatusFilter.Published],
});
@@ -533,7 +530,7 @@ describe("PostgresSearchProvider", () => {
title: "archived not draft",
archivedAt: new Date(),
});
const { results } = await provider.searchForUser(user, {
const { results } = await SearchHelper.searchForUser(user, {
query: "draft",
statusFilter: [StatusFilter.Published, StatusFilter.Draft],
});
@@ -561,7 +558,7 @@ describe("PostgresSearchProvider", () => {
title: "archived not draft",
archivedAt: new Date(),
});
const { results } = await provider.searchForUser(user, {
const { results } = await SearchHelper.searchForUser(user, {
query: "draft",
statusFilter: [StatusFilter.Draft, StatusFilter.Archived],
});
@@ -587,7 +584,7 @@ describe("PostgresSearchProvider", () => {
collectionId: collection.id,
title: "test number 2",
});
const { total } = await provider.searchForUser(user, {
const { total } = await SearchHelper.searchForUser(user, {
query: "test",
});
expect(total).toBe(2);
@@ -608,7 +605,7 @@ describe("PostgresSearchProvider", () => {
});
document.title = "change";
await document.save();
const { total } = await provider.searchForUser(user, {
const { total } = await SearchHelper.searchForUser(user, {
query: "test number",
});
expect(total).toBe(1);
@@ -629,7 +626,7 @@ describe("PostgresSearchProvider", () => {
});
document.title = "change";
await document.save();
const { total } = await provider.searchForUser(user, {
const { total } = await SearchHelper.searchForUser(user, {
query: "title doesn't exist",
});
expect(total).toBe(0);
@@ -650,7 +647,7 @@ describe("PostgresSearchProvider", () => {
});
document.title = "change";
await document.save();
const { total } = await provider.searchForUser(user, {
const { total } = await SearchHelper.searchForUser(user, {
query: `"test number"`,
});
expect(total).toBe(1);
@@ -671,7 +668,7 @@ describe("PostgresSearchProvider", () => {
});
document.title = "change";
await document.save();
const { total } = await provider.searchForUser(user, {
const { total } = await SearchHelper.searchForUser(user, {
query: "env: ",
});
expect(total).toBe(1);
@@ -684,7 +681,7 @@ describe("PostgresSearchProvider", () => {
const collection = await buildCollection({
userId: otherUser.id,
teamId: team.id,
permission: null,
permission: null, // private collection
});
const document = await buildDocument({
userId: otherUser.id,
@@ -693,6 +690,7 @@ describe("PostgresSearchProvider", () => {
title: "group test document",
});
// Document with no access should not appear in results
await buildDocument({
userId: otherUser.id,
teamId: team.id,
@@ -700,6 +698,7 @@ describe("PostgresSearchProvider", () => {
title: "group test document 2",
});
// Create a group and add the user to it
const group = await buildGroup({
teamId: team.id,
});
@@ -709,13 +708,14 @@ describe("PostgresSearchProvider", () => {
},
});
// Add group membership to the document
await GroupMembership.create({
createdById: otherUser.id,
groupId: group.id,
documentId: document.id,
});
const { results } = await provider.searchForUser(user, {
const { results } = await SearchHelper.searchForUser(user, {
query: "group test",
});
@@ -739,7 +739,7 @@ describe("PostgresSearchProvider", () => {
collectionId: collection.id,
title: "test",
});
const documents = await provider.searchTitlesForUser(user, {
const documents = await SearchHelper.searchTitlesForUser(user, {
query: "test",
});
expect(documents.length).toBe(1);
@@ -774,7 +774,7 @@ describe("PostgresSearchProvider", () => {
collectionId: collection1.id,
title: "test",
});
const documents = await provider.searchTitlesForUser(user, {
const documents = await SearchHelper.searchTitlesForUser(user, {
query: "test",
collectionId: collection.id,
});
@@ -785,7 +785,7 @@ describe("PostgresSearchProvider", () => {
it("should handle no collections", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const documents = await provider.searchTitlesForUser(user, {
const documents = await SearchHelper.searchTitlesForUser(user, {
query: "test",
});
expect(documents.length).toBe(0);
@@ -815,7 +815,7 @@ describe("PostgresSearchProvider", () => {
title: "test",
archivedAt: new Date(),
});
const documents = await provider.searchTitlesForUser(user, {
const documents = await SearchHelper.searchTitlesForUser(user, {
query: "test",
statusFilter: [StatusFilter.Draft],
});
@@ -846,7 +846,7 @@ describe("PostgresSearchProvider", () => {
title: "test",
archivedAt: new Date(),
});
const documents = await provider.searchTitlesForUser(user, {
const documents = await SearchHelper.searchTitlesForUser(user, {
query: "test",
statusFilter: [StatusFilter.Published],
});
@@ -883,7 +883,7 @@ describe("PostgresSearchProvider", () => {
title: "test",
archivedAt: new Date(),
});
const documents = await provider.searchTitlesForUser(user, {
const documents = await SearchHelper.searchTitlesForUser(user, {
query: "test",
statusFilter: [StatusFilter.Archived],
});
@@ -911,7 +911,7 @@ describe("PostgresSearchProvider", () => {
title: "test",
archivedAt: new Date(),
});
const documents = await provider.searchTitlesForUser(user, {
const documents = await SearchHelper.searchTitlesForUser(user, {
query: "test",
statusFilter: [StatusFilter.Archived, StatusFilter.Published],
});
@@ -939,7 +939,7 @@ describe("PostgresSearchProvider", () => {
title: "archived not draft",
archivedAt: new Date(),
});
const documents = await provider.searchTitlesForUser(user, {
const documents = await SearchHelper.searchTitlesForUser(user, {
query: "draft",
statusFilter: [StatusFilter.Published, StatusFilter.Draft],
});
@@ -967,7 +967,7 @@ describe("PostgresSearchProvider", () => {
title: "archived not draft",
archivedAt: new Date(),
});
const documents = await provider.searchTitlesForUser(user, {
const documents = await SearchHelper.searchTitlesForUser(user, {
query: "draft",
statusFilter: [StatusFilter.Draft, StatusFilter.Archived],
});
@@ -981,7 +981,7 @@ describe("PostgresSearchProvider", () => {
const collection = await buildCollection({
userId: otherUser.id,
teamId: team.id,
permission: null,
permission: null, // private collection
});
const document = await buildDocument({
userId: otherUser.id,
@@ -990,6 +990,7 @@ describe("PostgresSearchProvider", () => {
title: "group title test document",
});
// Document with no access should not appear in results
await buildDocument({
userId: otherUser.id,
teamId: team.id,
@@ -997,6 +998,7 @@ describe("PostgresSearchProvider", () => {
title: "group title test document 2",
});
// Create a group and add the user to it
const group = await buildGroup({
teamId: team.id,
});
@@ -1006,13 +1008,14 @@ describe("PostgresSearchProvider", () => {
},
});
// Add group membership to the document
await GroupMembership.create({
createdById: otherUser.id,
groupId: group.id,
documentId: document.id,
});
const documents = await provider.searchTitlesForUser(user, {
const documents = await SearchHelper.searchTitlesForUser(user, {
query: "group title",
});
@@ -1036,7 +1039,7 @@ describe("PostgresSearchProvider", () => {
name: "Other Collection",
});
const results = await provider.searchCollectionsForUser(user, {
const results = await SearchHelper.searchCollectionsForUser(user, {
query: "test",
});
@@ -1058,7 +1061,7 @@ describe("PostgresSearchProvider", () => {
name: "Beta",
});
const results = await provider.searchCollectionsForUser(user);
const results = await SearchHelper.searchCollectionsForUser(user);
expect(results.length).toBe(2);
expect(results[0].id).toBe(collection1.id);
@@ -1093,7 +1096,7 @@ describe("PostgresSearchProvider", () => {
title: "Beta Document",
});
const { results } = await provider.searchForUser(user, {
const { results } = await SearchHelper.searchForUser(user, {
sort: SortFilter.Title,
direction: DirectionFilter.ASC,
});
@@ -1130,7 +1133,7 @@ describe("PostgresSearchProvider", () => {
title: "Beta Document",
});
const { results } = await provider.searchForUser(user, {
const { results } = await SearchHelper.searchForUser(user, {
sort: SortFilter.Title,
direction: DirectionFilter.DESC,
});
@@ -1173,7 +1176,7 @@ describe("PostgresSearchProvider", () => {
updatedAt: new Date("2023-12-01"),
});
const { results } = await provider.searchForUser(user, {
const { results } = await SearchHelper.searchForUser(user, {
sort: SortFilter.CreatedAt,
direction: DirectionFilter.ASC,
});
@@ -1213,7 +1216,7 @@ describe("PostgresSearchProvider", () => {
updatedAt: new Date("2023-06-01"),
});
const { results } = await provider.searchForUser(user);
const { results } = await SearchHelper.searchForUser(user);
expect(results.length).toBe(3);
expect(results[0].document.id).toBe(doc2.id);
@@ -1249,7 +1252,7 @@ describe("PostgresSearchProvider", () => {
updatedAt: new Date("2023-01-01"),
});
const { results } = await provider.searchForUser(user, {
const { results } = await SearchHelper.searchForUser(user, {
query: "search",
});
@@ -1285,7 +1288,7 @@ describe("PostgresSearchProvider", () => {
updatedAt: new Date("2025-12-01"),
});
const { results } = await provider.searchForUser(user, {
const { results } = await SearchHelper.searchForUser(user, {
query: "search",
sort: SortFilter.UpdatedAt,
direction: DirectionFilter.DESC,
@@ -1323,7 +1326,7 @@ describe("PostgresSearchProvider", () => {
});
// Without popularity boost, pure relevance should win
const { results: withoutBoost } = await provider.searchForTeam(team, {
const { results: withoutBoost } = await SearchHelper.searchForTeam(team, {
query: "testing",
usePopularityBoost: false,
});
@@ -1332,7 +1335,7 @@ describe("PostgresSearchProvider", () => {
expect(withoutBoost[0].document.id).toBe(relevantDoc.id);
// With popularity boost, the popular document may rank higher
const { results: withBoost } = await provider.searchForTeam(team, {
const { results: withBoost } = await SearchHelper.searchForTeam(team, {
query: "testing",
usePopularityBoost: true,
});
@@ -1347,28 +1350,22 @@ describe("PostgresSearchProvider", () => {
describe("webSearchQuery", () => {
it("should correctly sanitize query", () => {
expect(PostgresSearchProvider.webSearchQuery("one/two")).toBe(
"one/two:*"
);
expect(PostgresSearchProvider.webSearchQuery("one\\two")).toBe(
"one\\\\two:*"
);
expect(PostgresSearchProvider.webSearchQuery("test''")).toBe("test");
expect(SearchHelper.webSearchQuery("one/two")).toBe("one/two:*");
expect(SearchHelper.webSearchQuery("one\\two")).toBe("one\\\\two:*");
expect(SearchHelper.webSearchQuery("test''")).toBe("test");
});
it("should wildcard unquoted queries", () => {
expect(PostgresSearchProvider.webSearchQuery("test")).toBe("test:*");
expect(PostgresSearchProvider.webSearchQuery("'")).toBe("");
expect(PostgresSearchProvider.webSearchQuery("'quoted'")).toBe(
`"quoted":*`
);
expect(SearchHelper.webSearchQuery("test")).toBe("test:*");
expect(SearchHelper.webSearchQuery("'")).toBe("");
expect(SearchHelper.webSearchQuery("'quoted'")).toBe(`"quoted":*`);
});
it("should wildcard multi-word queries", () => {
expect(PostgresSearchProvider.webSearchQuery("this is a test")).toBe(
expect(SearchHelper.webSearchQuery("this is a test")).toBe(
"this&is&a&test:*"
);
});
it("should not wildcard quoted queries", () => {
expect(PostgresSearchProvider.webSearchQuery(`"this is a test"`)).toBe(
expect(SearchHelper.webSearchQuery(`"this is a test"`)).toBe(
`"this<->is<->a<->test"`
);
});
@@ -11,23 +11,63 @@ import type {
WhereOptions,
} from "sequelize";
import { Op, Sequelize } from "sequelize";
import type { SearchableModel } from "@shared/types";
import { DirectionFilter, SortFilter, StatusFilter } from "@shared/types";
import type { DateFilter } from "@shared/types";
import { DirectionFilter, SortFilter } from "@shared/types";
import { 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 type {
SearchOptions,
SearchResponse,
} from "@server/utils/BaseSearchProvider";
import { BaseSearchProvider } from "@server/utils/BaseSearchProvider";
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;
};
type RankedDocument = Document & {
id: string;
@@ -36,31 +76,24 @@ type RankedDocument = Document & {
};
};
/**
* 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";
export default class SearchHelper {
/**
* 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。!?!?…]`,
@@ -68,7 +101,7 @@ export default class PostgresSearchProvider extends BaseSearchProvider {
);
/**
* 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([
@@ -182,13 +215,13 @@ export default class PostgresSearchProvider extends BaseSearchProvider {
"should",
]);
async searchForTeam(
public static async searchForTeam(
team: Team,
options: SearchOptions = {}
): Promise<SearchResponse> {
const { limit = 15, offset = 0, query } = options;
const where = await PostgresSearchProvider.buildWhere(team, {
const where = await this.buildWhere(team, {
...options,
statusFilter: [...(options.statusFilter || []), StatusFilter.Published],
});
@@ -223,7 +256,7 @@ export default class PostgresSearchProvider extends BaseSearchProvider {
});
}
const findOptions = PostgresSearchProvider.buildFindOptions({
const findOptions = this.buildFindOptions({
query,
sort: options.sort,
direction: options.direction,
@@ -259,7 +292,7 @@ export default class PostgresSearchProvider extends BaseSearchProvider {
],
});
return PostgresSearchProvider.buildResponse({
return this.buildResponse({
query,
results,
documents,
@@ -273,12 +306,12 @@ export default class PostgresSearchProvider extends BaseSearchProvider {
}
}
async searchTitlesForUser(
public static async searchTitlesForUser(
user: User,
options: SearchOptions = {}
): Promise<Document[]> {
const { limit = 15, offset = 0, query, ...rest } = options;
const where = await PostgresSearchProvider.buildWhere(user, rest);
const where = await this.buildWhere(user, rest);
if (query) {
where[Op.and].push({
@@ -346,7 +379,7 @@ export default class PostgresSearchProvider extends BaseSearchProvider {
});
}
async searchCollectionsForUser(
public static async searchCollectionsForUser(
user: User,
options: SearchOptions = {}
): Promise<Collection[]> {
@@ -375,15 +408,15 @@ export default class PostgresSearchProvider extends BaseSearchProvider {
});
}
async searchForUser(
public static async searchForUser(
user: User,
options: SearchOptions = {}
): Promise<SearchResponse> {
const { limit = 15, offset = 0, query } = options;
const where = await PostgresSearchProvider.buildWhere(user, options);
const where = await this.buildWhere(user, options);
const findOptions = PostgresSearchProvider.buildFindOptions({
const findOptions = this.buildFindOptions({
query,
sort: options.sort,
direction: options.direction,
@@ -451,7 +484,7 @@ export default class PostgresSearchProvider extends BaseSearchProvider {
: countQuery,
]);
return PostgresSearchProvider.buildResponse({
return this.buildResponse({
query,
results,
documents,
@@ -465,49 +498,6 @@ export default class PostgresSearchProvider extends BaseSearchProvider {
}
}
/**
* 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,
@@ -529,7 +519,7 @@ export default class PostgresSearchProvider extends BaseSearchProvider {
: `ts_rank("searchVector", to_tsquery('english', :query))`;
attributes.push([Sequelize.literal(rankExpression), "searchRanking"]);
replacements["query"] = PostgresSearchProvider.webSearchQuery(query);
replacements["query"] = this.webSearchQuery(query);
}
// When searching with a query and no explicit sort, prioritize search
@@ -561,10 +551,8 @@ export default class PostgresSearchProvider extends BaseSearchProvider {
private static buildResultContext(document: Document, query: string) {
// Reset regex lastIndex to avoid state issues with global regex
PostgresSearchProvider.QUOTED_QUERY_REGEX.lastIndex = 0;
const quotedQueries = Array.from(
query.matchAll(PostgresSearchProvider.QUOTED_QUERY_REGEX)
);
this.QUOTED_QUERY_REGEX.lastIndex = 0;
const quotedQueries = Array.from(query.matchAll(this.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.
@@ -574,7 +562,7 @@ export default class PostgresSearchProvider extends BaseSearchProvider {
fullMatchRegex.source,
...(quotedQueries.length
? quotedQueries.map((match) => escapeRegExp(match[1]))
: PostgresSearchProvider.removeStopWords(query)
: this.removeStopWords(query)
.trim()
.split(" ")
.map((match) => `\\b${escapeRegExp(match)}\\b`)),
@@ -583,8 +571,8 @@ export default class PostgresSearchProvider extends BaseSearchProvider {
);
// Reset regex lastIndex to avoid state issues with global regex
PostgresSearchProvider.BREAK_CHARS_REGEX.lastIndex = 0;
const breakCharsRegex = PostgresSearchProvider.BREAK_CHARS_REGEX;
this.BREAK_CHARS_REGEX.lastIndex = 0;
const breakCharsRegex = this.BREAK_CHARS_REGEX;
// chop text around the first match, prefer the first full match if possible.
const fullMatchIndex = text.search(fullMatchRegex);
@@ -727,17 +715,15 @@ export default class PostgresSearchProvider extends BaseSearchProvider {
let likelyUrls = getUrls(options.query);
// remove likely urls, and escape the rest of the query.
let limitedQuery = PostgresSearchProvider.escapeQuery(
let limitedQuery = this.escapeQuery(
likelyUrls
.reduce((q, url) => q.replace(url, ""), options.query)
.slice(0, PostgresSearchProvider.maxQueryLength)
.slice(0, this.maxQueryLength)
.trim()
);
// Escape the URLs
likelyUrls = likelyUrls.map((url) =>
PostgresSearchProvider.escapeQuery(url)
);
likelyUrls = likelyUrls.map((url) => this.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(
@@ -799,9 +785,7 @@ export default class PostgresSearchProvider extends BaseSearchProvider {
return {
ranking: result.dataValues.searchRanking,
context: query
? PostgresSearchProvider.buildResultContext(document, query)
: undefined,
context: query ? this.buildResultContext(document, query) : undefined,
document,
};
}),
@@ -810,26 +794,22 @@ export default class PostgresSearchProvider extends BaseSearchProvider {
}
/**
* 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 = PostgresSearchProvider.escapeQuery(
query.slice(0, PostgresSearchProvider.maxQueryLength)
);
let limitedQuery = this.escapeQuery(query.slice(0, this.maxQueryLength));
const quotedSearch =
limitedQuery.startsWith('"') && limitedQuery.endsWith('"');
// Replace single quote characters with &.
// Reset regex lastIndex to avoid state issues with global regex
PostgresSearchProvider.SINGLE_QUOTE_REGEX.lastIndex = 0;
const singleQuotes = limitedQuery.matchAll(
PostgresSearchProvider.SINGLE_QUOTE_REGEX
);
this.SINGLE_QUOTE_REGEX.lastIndex = 0;
const singleQuotes = limitedQuery.matchAll(this.SINGLE_QUOTE_REGEX);
for (const match of singleQuotes) {
if (
@@ -871,9 +851,11 @@ export default class PostgresSearchProvider extends BaseSearchProvider {
}
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) => !PostgresSearchProvider.STOP_WORDS.has(word))
.filter((word) => !this.STOP_WORDS.has(word))
.join(" ");
}
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { ProsemirrorHelper as SharedProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { createContext } from "@server/context";
import { buildProseMirrorDoc, buildUser } from "@server/test/factories";
import { ProsemirrorHelper } from "./ProsemirrorHelper";
@@ -42,7 +43,7 @@ describe("ProsemirrorHelper", () => {
},
]);
const images = ProsemirrorHelper.getImages(doc);
const images = SharedProsemirrorHelper.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");
@@ -1,48 +0,0 @@
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);
});
});
});
+9 -13
View File
@@ -1,7 +1,4 @@
import {
ProsemirrorHelper,
type CommentMark,
} from "@shared/utils/ProsemirrorHelper";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import type { Comment } from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import presentUser from "./user";
@@ -9,23 +6,22 @@ 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, commentMarks }: Options = {}
{ includeAnchorText }: Options = {}
) {
let anchorText: string | undefined;
if (includeAnchorText && comment.document) {
const marks =
commentMarks ??
ProsemirrorHelper.getComments(
DocumentHelper.toProsemirror(comment.document)
);
anchorText = ProsemirrorHelper.getAnchorTextForComment(marks, comment.id);
const commentMarks = ProsemirrorHelper.getComments(
DocumentHelper.toProsemirror(comment.document)
);
anchorText = ProsemirrorHelper.getAnchorTextForComment(
commentMarks,
comment.id
);
}
return {
-1
View File
@@ -20,6 +20,5 @@ 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({
@@ -1,82 +0,0 @@
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();
});
});
@@ -1,139 +0,0 @@
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;
}
}
}
+4 -3
View File
@@ -13,6 +13,7 @@ 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";
@@ -261,9 +262,9 @@ export default abstract class APIImportTask<
}): Promise<ProsemirrorDoc> {
const docNode = ProsemirrorHelper.toProsemirror(doc);
const nodes = [
...ProsemirrorHelper.getImages(docNode),
...ProsemirrorHelper.getVideos(docNode),
...ProsemirrorHelper.getAttachments(docNode),
...SharedProseMirrorHelper.getImages(docNode),
...SharedProseMirrorHelper.getVideos(docNode),
...SharedProseMirrorHelper.getAttachments(docNode),
];
if (!nodes.length) {
+23 -141
View File
@@ -1,155 +1,37 @@
import path from "node:path";
import { FileOperation, User } from "@server/models";
import {
buildFileOperation,
buildUser,
buildTeam,
buildAdmin,
} from "@server/test/factories";
import { FileOperation } from "@server/models";
import { buildFileOperation } 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();
mockHandle(fileOperation);
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,
};
const task = new ImportJSONTask();
const response = await task.perform({
fileOperationId: fileOperation.id,
});
const response = await task.perform(props);
expect(response.collections.size).toEqual(1);
expect(response.documents.size).toEqual(2);
expect(response.attachments.size).toEqual(1);
});
describe("user mapping", () => {
it("should map createdById to an existing user by ID", async () => {
// Ensure a user exists with the fixture's createdById, handling the
// case where it may already exist from a prior test run.
let originalAuthor = await User.findByPk(fixtureCreatedById);
const teamId = originalAuthor?.teamId ?? (await buildTeam()).id;
if (!originalAuthor) {
originalAuthor = await buildUser({
id: fixtureCreatedById,
teamId,
});
}
const admin = await buildAdmin({ teamId });
const fileOperation = await buildFileOperation({
userId: admin.id,
teamId,
});
mockHandle(fileOperation);
const task = new ImportJSONTask();
const response = await task.perform({
fileOperationId: fileOperation.id,
});
for (const document of response.documents.values()) {
expect(document.createdById).toEqual(originalAuthor.id);
expect(document.lastModifiedById).toEqual(originalAuthor.id);
}
});
it("should fall back to email matching when ID does not match", async () => {
const team = await buildTeam();
// User has matching email but a different ID
const originalAuthor = await buildUser({
teamId: team.id,
email: fixtureCreatedByEmail,
});
const admin = await buildAdmin({ teamId: team.id });
const fileOperation = await buildFileOperation({
userId: admin.id,
teamId: team.id,
});
mockHandle(fileOperation);
const task = new ImportJSONTask();
const response = await task.perform({
fileOperationId: fileOperation.id,
});
for (const document of response.documents.values()) {
expect(document.createdById).toEqual(originalAuthor.id);
expect(document.lastModifiedById).toEqual(originalAuthor.id);
}
});
it("should fall back to importing user when no match is found", async () => {
const team = await buildTeam();
const admin = await buildAdmin({ teamId: team.id });
const fileOperation = await buildFileOperation({
userId: admin.id,
teamId: team.id,
});
mockHandle(fileOperation);
const task = new ImportJSONTask();
const response = await task.perform({
fileOperationId: fileOperation.id,
});
for (const document of response.documents.values()) {
expect(document.createdById).toEqual(admin.id);
expect(document.lastModifiedById).toEqual(admin.id);
}
});
it("should not match users from a different team", async () => {
const team = await buildTeam();
const otherTeam = await buildTeam();
// Create user with matching email in a different team
await buildUser({
teamId: otherTeam.id,
email: fixtureCreatedByEmail,
});
const admin = await buildAdmin({ teamId: team.id });
const fileOperation = await buildFileOperation({
userId: admin.id,
teamId: team.id,
});
mockHandle(fileOperation);
const task = new ImportJSONTask();
const response = await task.perform({
fileOperationId: fileOperation.id,
});
for (const document of response.documents.values()) {
expect(document.createdById).toEqual(admin.id);
}
});
});
});
-64
View File
@@ -305,7 +305,6 @@ 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,
@@ -438,13 +437,6 @@ 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),
@@ -465,8 +457,6 @@ 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);
@@ -545,60 +535,6 @@ export default abstract class ImportTask extends BaseTask<Props> {
};
}
/**
* Resolves the original document author to an internal user, using a cache
* to avoid redundant database queries. Attempts to match by user ID first,
* then by email. Both hits and misses are cached.
*
* @param item the document import item containing createdById and createdByEmail.
* @param teamId the team ID to scope the lookup to.
* @param cache a map used to cache resolved user IDs across calls.
* @returns the resolved user ID, or undefined if no match was found.
*/
private async resolveUserId(
item: { createdById?: string; createdByEmail?: string | null },
teamId: string,
cache: Map<string, string | undefined>
): Promise<string | undefined> {
if (item.createdById) {
const cacheKey = `id:${item.createdById}`;
if (cache.has(cacheKey)) {
return cache.get(cacheKey);
}
const user = await User.findOne({
where: { id: item.createdById, teamId },
});
if (user) {
cache.set(cacheKey, user.id);
return user.id;
}
cache.set(cacheKey, undefined);
}
if (item.createdByEmail) {
const email = item.createdByEmail.toLowerCase().trim();
const cacheKey = `email:${email}`;
if (cache.has(cacheKey)) {
return cache.get(cacheKey);
}
const user = await User.findOne({
where: { email, teamId },
});
if (user) {
cache.set(cacheKey, user.id);
if (item.createdById) {
cache.set(`id:${item.createdById}`, user.id);
}
return user.id;
}
cache.set(cacheKey, undefined);
}
return undefined;
}
private async preprocessDocUrlIds(data: StructuredImportData) {
for (const doc of data.documents) {
// check DB only if urlId is present in the input.
+1
View File
@@ -0,0 +1 @@
export { default } from "./comments";
+14 -15
View File
@@ -64,7 +64,7 @@ import {
import AttachmentHelper from "@server/models/helpers/AttachmentHelper";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import SearchProviderManager from "@server/utils/SearchProviderManager";
import SearchHelper from "@server/models/helpers/SearchHelper";
import { TextHelper } from "@server/models/helpers/TextHelper";
import { authorize, cannot } from "@server/policies";
import {
@@ -1015,18 +1015,17 @@ router.post(
collaboratorIds = [userId];
}
const documents =
await SearchProviderManager.getProvider().searchTitlesForUser(user, {
query,
dateFilter,
statusFilter,
collectionId,
collaboratorIds,
offset,
limit,
sort: sort as SortFilter,
direction: direction as DirectionFilter,
});
const documents = await SearchHelper.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);
@@ -1100,7 +1099,7 @@ router.post(
const team = await share.$get("team");
invariant(team, "Share must belong to a team");
response = await SearchProviderManager.getProvider().searchForTeam(team, {
response = await SearchHelper.searchForTeam(team, {
query,
collectionId: collection?.id || document?.collectionId,
share,
@@ -1146,7 +1145,7 @@ router.post(
collaboratorIds = [userId];
}
response = await SearchProviderManager.getProvider().searchForUser(user, {
response = await SearchHelper.searchForUser(user, {
query,
collaboratorIds,
collectionId,
+3 -3
View File
@@ -5,7 +5,7 @@ import { StatusFilter } from "@shared/types";
import auth from "@server/middlewares/authentication";
import validate from "@server/middlewares/validate";
import { Group, User } from "@server/models";
import SearchProviderManager from "@server/utils/SearchProviderManager";
import SearchHelper from "@server/models/helpers/SearchHelper";
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([
SearchProviderManager.getProvider().searchTitlesForUser(actor, {
SearchHelper.searchTitlesForUser(actor, {
query,
offset,
limit,
@@ -74,7 +74,7 @@ router.post(
offset,
limit,
}),
SearchProviderManager.getProvider().searchCollectionsForUser(actor, { query, offset, limit }),
SearchHelper.searchCollectionsForUser(actor, { query, offset, limit }),
]);
ctx.body = {
-3
View File
@@ -1,6 +1,5 @@
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({
@@ -33,8 +32,6 @@ 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({
+1 -3
View File
@@ -340,9 +340,7 @@ export const renderShare = async (ctx: Context, next: Next) => {
(publicBranding && team?.description ? team.description : undefined),
content,
shortcutIcon:
publicBranding && team?.avatarUrl
? (await team.publicAvatarUrl()) ?? undefined
: undefined,
publicBranding && team?.avatarUrl ? team.avatarUrl : undefined,
analytics,
isShare: true,
rootShareId,
-144
View File
@@ -1,12 +1,10 @@
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";
@@ -622,148 +620,6 @@ 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({
+2 -15
View File
@@ -12,7 +12,6 @@ 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";
@@ -23,21 +22,14 @@ 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[], guidance?: string): McpServer {
const instructions = guidance
? `${defaultInstructions}\n\n${guidance}`
: defaultInstructions;
function createMcpServer(scopes: string[]): McpServer {
const server = new McpServer(
{
name: "outline",
@@ -47,11 +39,9 @@ function createMcpServer(scopes: string[], guidance?: string): McpServer {
capabilities: {
tools: {},
},
instructions,
}
);
attachmentTools(server, scopes);
collectionTools(server, scopes);
commentTools(server, scopes);
documentTools(server, scopes);
@@ -78,10 +68,7 @@ router.post(
throw NotFoundError();
}
const server = createMcpServer(
scope ?? [],
user.team.guidanceMCP ?? undefined
);
const server = createMcpServer(scope ?? []);
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
-112
View File
@@ -1,112 +0,0 @@
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);
}
}
)
);
}
}
+3 -36
View File
@@ -4,10 +4,7 @@ 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";
@@ -26,17 +23,10 @@ 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,
commentMarks?: CommentMark[]
) {
const presented = presentComment(comment, {
includeAnchorText: true,
commentMarks,
});
function presentCommentWithText(comment: Comment) {
const presented = presentComment(comment);
return {
...presented,
text: comment.toPlainText(),
@@ -192,25 +182,7 @@ export function commentTools(server: McpServer, scopes: string[]) {
});
}
// 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);
});
const presented = comments.map(presentCommentWithText);
return success(presented);
} catch (err) {
return error(err);
@@ -266,7 +238,6 @@ export function commentTools(server: McpServer, scopes: string[]) {
});
comment.createdBy = user;
comment.document = document!;
const presented = presentCommentWithText(comment);
return {
@@ -321,9 +292,6 @@ 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);
@@ -344,7 +312,6 @@ export function commentTools(server: McpServer, scopes: string[]) {
await comment.saveWithCtx(ctx, status ? { silent: true } : undefined);
comment.document = document!;
const presented = presentCommentWithText(comment);
return {
content: [
+2 -4
View File
@@ -7,6 +7,7 @@ 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";
@@ -21,7 +22,6 @@ 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,8 +93,6 @@ 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;
@@ -111,7 +109,7 @@ export function documentTools(server: McpServer, scopes: string[]) {
}
}
const { results } = await searchProvider.searchForUser(user, {
const { results } = await SearchHelper.searchForUser(user, {
query,
collectionId,
offset: effectiveOffset,
-149
View File
@@ -1,149 +0,0 @@
import type { DateFilter } from "@shared/types";
import type { SearchableModel } from "@shared/types";
import type { DirectionFilter, SortFilter, StatusFilter } from "@shared/types";
import type Collection from "@server/models/Collection";
import type Comment from "@server/models/Comment";
import type Document from "@server/models/Document";
import type Share from "@server/models/Share";
import type Team from "@server/models/Team";
import type User from "@server/models/User";
export interface SearchResponse {
results: {
/** The search ranking, for sorting results. */
ranking: number;
/** A snippet of contextual text around the search result. */
context?: string;
/** The document result. */
document: Document;
}[];
/** The total number of results for the search query without pagination. */
total: number;
}
export interface SearchOptions {
/** The query limit for pagination. */
limit?: number;
/** The query offset for pagination. */
offset?: number;
/** The text to search for. */
query?: string;
/** Limit results to a collection. Authorization is presumed to have been done before passing to this provider. */
collectionId?: string | null;
/** Limit results to a shared document. */
share?: Share;
/** Limit results to a date range. */
dateFilter?: DateFilter;
/** Status of the documents to return. */
statusFilter?: StatusFilter[];
/** Limit results to a list of documents. */
documentIds?: string[];
/** Limit results to a list of users that collaborated on the document. */
collaboratorIds?: string[];
/** The minimum number of words to be returned in the contextual snippet. */
snippetMinWords?: number;
/** The maximum number of words to be returned in the contextual snippet. */
snippetMaxWords?: number;
/** The field to sort results by. */
sort?: SortFilter;
/** The sort direction. */
direction?: DirectionFilter;
/** Whether to boost results by popularity score. Defaults to true. */
usePopularityBoost?: boolean;
}
/**
* Abstract base class for search providers. Implementations handle full-text
* search, title search, collection search, and index management.
*/
export abstract class BaseSearchProvider {
/** Unique identifier for this provider, matched against `SEARCH_PROVIDER` env var. */
abstract id: string;
/**
* Perform a full-text search scoped to a user's accessible documents.
*
* @param user - the user performing the search.
* @param options - search options.
* @returns search results with ranking and context.
*/
abstract searchForUser(
user: User,
options?: SearchOptions
): Promise<SearchResponse>;
/**
* Perform a full-text search scoped to a team (used for shared document search).
*
* @param team - the team to search within.
* @param options - search options.
* @returns search results with ranking and context.
*/
abstract searchForTeam(
team: Team,
options?: SearchOptions
): Promise<SearchResponse>;
/**
* Search document titles for a user (used for link suggestions, quick search).
*
* @param user - the user performing the search.
* @param options - search options.
* @returns matching documents.
*/
abstract searchTitlesForUser(
user: User,
options?: SearchOptions
): Promise<Document[]>;
/**
* Search collections for a user.
*
* @param user - the user performing the search.
* @param options - search options.
* @returns matching collections.
*/
abstract searchCollectionsForUser(
user: User,
options?: SearchOptions
): Promise<Collection[]>;
/**
* Index or re-index a searchable item. For providers that rely on database
* triggers (e.g. PostgreSQL tsvector), this may be a no-op.
*
* @param model - the type of model being indexed.
* @param item - the model instance to index.
*/
abstract index(
model: SearchableModel,
item: Document | Collection | Comment
): Promise<void>;
/**
* Remove an item from the search index.
*
* @param model - the type of model being removed.
* @param id - the id of the item to remove.
* @param teamId - the team id the item belongs to.
*/
abstract remove(
model: SearchableModel,
id: string,
teamId: string
): Promise<void>;
/**
* Update metadata for an indexed item without re-indexing the full content.
* Useful for permission changes, moves, archive/unarchive.
*
* @param model - the type of model being updated.
* @param id - the id of the item to update.
* @param metadata - the metadata fields to update.
*/
abstract updateMetadata(
model: SearchableModel,
id: string,
metadata: Record<string, unknown>
): Promise<void>;
}
-104
View File
@@ -395,109 +395,5 @@ 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);
});
});
});
+2 -119
View File
@@ -6,6 +6,7 @@ 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";
@@ -54,7 +55,7 @@ export class DocumentConverter {
// Extract title from first H1 heading
let title = "";
const headings = ProsemirrorHelper.getHeadings(doc);
const headings = SharedProsemirrorHelper.getHeadings(doc);
if (headings.length > 0 && headings[0].level === 1) {
title = headings[0].title;
doc = ProsemirrorHelper.removeFirstHeading(doc);
@@ -147,30 +148,6 @@ 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));
}
}
}
});
}
@@ -467,98 +444,4 @@ 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;
}
}
+5 -9
View File
@@ -11,7 +11,6 @@ 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,
@@ -30,7 +29,6 @@ export enum Hook {
EmailTemplate = "emailTemplate",
IssueProvider = "issueProvider",
Processor = "processor",
SearchProvider = "searchProvider",
Task = "task",
UnfurlProvider = "unfurl",
Uninstall = "uninstall",
@@ -47,7 +45,6 @@ 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 };
@@ -109,10 +106,9 @@ 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();
@@ -143,9 +139,9 @@ export class PluginManager {
glob
.sync(path.join(rootDir, "plugins/*/server/!(*.test|schema).[jt]s"))
.forEach((filePath: string) =>
require(path.join(process.cwd(), filePath))
);
.forEach((filePath: string) => {
require(path.join(process.cwd(), filePath));
});
this.loaded = true;
}
-49
View File
@@ -1,49 +0,0 @@
import env from "@server/env";
import Logger from "@server/logging/Logger";
import type { BaseSearchProvider } from "./BaseSearchProvider";
import { Hook, PluginManager } from "./PluginManager";
/**
* Manages selection and caching of the active search provider based on the
* `SEARCH_PROVIDER` environment variable.
*/
export default class SearchProviderManager {
private static cachedProvider: BaseSearchProvider | undefined;
/**
* Returns the active search provider. The provider is determined by matching
* `SEARCH_PROVIDER` env var against registered `Hook.SearchProvider` plugins.
*
* @returns the active search provider instance.
* @throws if no matching provider is found.
*/
public static getProvider(): BaseSearchProvider {
if (this.cachedProvider) {
return this.cachedProvider;
}
const providerId = env.SEARCH_PROVIDER;
const plugins = PluginManager.getHooks(Hook.SearchProvider);
for (const plugin of plugins) {
if (plugin.value.id === providerId) {
this.cachedProvider = plugin.value;
Logger.debug("plugins", `Using search provider: ${plugin.value.id}`);
return this.cachedProvider;
}
}
throw new Error(
`Search provider "${providerId}" not found. Available providers: ${plugins
.map((p) => p.value.id)
.join(", ")}`
);
}
/**
* Reset the cached provider. Useful for testing.
*/
public static reset(): void {
this.cachedProvider = undefined;
}
}
-120
View File
@@ -1,120 +0,0 @@
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");
});
});

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