Compare commits

..

1 Commits

Author SHA1 Message Date
codegen-sh[bot] be050f48c3 Fix: Pasting over inline code block breaks markdown conversion (#8825) 2025-03-28 21:25:40 +00:00
81 changed files with 1847 additions and 2142 deletions
+1 -1
View File
@@ -65,7 +65,7 @@
],
"padding-line-between-statements": ["error", { "blankLine": "always", "prev": "*", "next": "export" }],
"lines-between-class-members": ["error", "always", { "exceptAfterSingleLine": true }],
"lodash/import-scope": ["error", "method"],
"lodash/import-scope": ["warn", "method"],
"import/no-named-as-default": "off",
"import/no-named-as-default-member": "off",
"import/newline-after-import": 2,
+3 -5
View File
@@ -11,7 +11,7 @@ env:
DATABASE_URL: postgres://postgres:password@localhost:5432/outline_test
REDIS_URL: redis://127.0.0.1:6379
URL: http://localhost:3000
NODE_OPTIONS: --max-old-space-size=8192
NODE_OPTIONS: --max-old-space-size=8000
SECRET_KEY: F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B
UTILS_SECRET: 123456
SLACK_VERIFICATION_TOKEN: 123456
@@ -63,7 +63,6 @@ jobs:
runs-on: ubuntu-latest
outputs:
server: ${{ steps.filter.outputs.server }}
app: ${{ steps.filter.outputs.app }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v2
@@ -82,7 +81,7 @@ jobs:
- 'yarn.lock'
test:
needs: [build, changes]
needs: build
if: ${{ needs.changes.outputs.app == 'true' }}
runs-on: ubuntu-latest
strategy:
@@ -144,7 +143,7 @@ jobs:
yarn test --maxWorkers=2 $TESTFILES
bundle-size:
needs: [build, types, changes]
needs: [build, types]
if: ${{ needs.changes.outputs.app == 'true' }}
runs-on: ubuntu-latest
steps:
@@ -162,4 +161,3 @@ jobs:
with:
key: ${{ secrets.RELATIVE_CI_KEY }}
token: ${{ secrets.GITHUB_TOKEN }}
webpackStatsFile: ./build/app/webpack-stats.json
+19 -179
View File
@@ -3,32 +3,25 @@ name: Docker
on:
push:
tags:
- "v*"
- 'v*'
env:
IMAGE_NAME: outlinewiki/outline
BASE_IMAGE_NAME: outlinewiki/outline-base
jobs:
build-arm:
runs-on: ubicloud-standard-8-arm
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker base meta
id: base_meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.BASE_IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
@@ -36,177 +29,24 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push base image
id: base_build
uses: docker/build-push-action@v6
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile.base
labels: ${{ steps.base_meta.outputs.labels }}
tags: ${{ env.BASE_IMAGE_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
push: true
tags: ${{ env.BASE_IMAGE_NAME }}:latest
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Extract version
id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
- name: Build and push
id: build
uses: docker/build-push-action@v6
- name: Build and push main image
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ env.IMAGE_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
build-args: |
BASE_IMAGE=${{ env.BASE_IMAGE_NAME }}@${{ steps.base_build.outputs.digest }}
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-linux-arm64
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
build-amd:
runs-on: ubicloud-standard-8
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker base meta
id: base_meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.BASE_IMAGE_NAME }}
push: true
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Login to Docker Hub
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@v6
with:
context: .
file: Dockerfile.base
labels: ${{ steps.base_meta.outputs.labels }}
tags: ${{ env.BASE_IMAGE_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/amd64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Build and push
id: build
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ env.IMAGE_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/amd64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
build-args: |
BASE_IMAGE=${{ env.BASE_IMAGE_NAME }}@${{ steps.base_build.outputs.digest }}
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-linux-amd64
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubicloud-standard-8
needs:
- build-amd
- build-arm
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
${{ env.IMAGE_NAME }}:latest
${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
+2 -3
View File
@@ -1,6 +1,5 @@
ARG APP_PATH=/opt/outline
ARG BASE_IMAGE=outlinewiki/outline-base
FROM ${BASE_IMAGE} AS base
FROM outlinewiki/outline-base AS base
ARG APP_PATH
WORKDIR $APP_PATH
@@ -31,7 +30,7 @@ RUN addgroup --gid 1001 nodejs && \
adduser --uid 1001 --ingroup nodejs nodejs && \
chown -R nodejs:nodejs $APP_PATH/build && \
mkdir -p /var/lib/outline && \
chown -R nodejs:nodejs /var/lib/outline
chown -R nodejs:nodejs /var/lib/outline
ENV FILE_STORAGE_LOCAL_ROOT_DIR=/var/lib/outline/data
RUN mkdir -p "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
+1 -4
View File
@@ -1,14 +1,11 @@
ARG APP_PATH=/opt/outline
FROM node:20 AS deps
FROM node:20-slim AS deps
ARG APP_PATH
WORKDIR $APP_PATH
COPY ./package.json ./yarn.lock ./
COPY ./patches ./patches
RUN apt-get update && apt-get install -y cmake
ENV NODE_OPTIONS="--max-old-space-size=24000"
RUN yarn install --no-optional --frozen-lockfile --network-timeout 1000000 && \
yarn cache clean
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.83.0
Licensed Work: Outline 0.82.0
The Licensed Work is (c) 2025 General Outline, Inc.
Additional Use Grant: You may make use of the Licensed Work, provided that
you may not use the Licensed Work for a Document
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
Licensed Work by creating teams and documents
controlled by such third parties.
Change Date: 2029-04-11
Change Date: 2029-02-15
Change License: Apache License, Version 2.0
+1 -27
View File
@@ -29,7 +29,6 @@ import {
PadlockIcon,
GlobeIcon,
LogoutIcon,
CaseSensitiveIcon,
} from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
@@ -511,25 +510,6 @@ export const copyDocumentAsMarkdown = createAction({
},
});
export const copyDocumentAsPlainText = createAction({
name: ({ t }) => t("Copy as text"),
section: ActiveDocumentSection,
keywords: "clipboard",
icon: <CaseSensitiveIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
perform: ({ stores, activeDocumentId, t }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (document) {
copy(document.toPlainText());
toast.success(t("Text copied to clipboard"));
}
},
});
export const copyDocumentShareLink = createAction({
name: ({ t }) => t("Copy public link"),
section: ActiveDocumentSection,
@@ -575,12 +555,7 @@ export const copyDocument = createAction({
section: ActiveDocumentSection,
icon: <CopyIcon />,
keywords: "clipboard",
children: [
copyDocumentLink,
copyDocumentShareLink,
copyDocumentAsMarkdown,
copyDocumentAsPlainText,
],
children: [copyDocumentLink, copyDocumentShareLink, copyDocumentAsMarkdown],
});
export const duplicateDocument = createAction({
@@ -1230,7 +1205,6 @@ export const rootDocumentActions = [
copyDocumentLink,
copyDocumentShareLink,
copyDocumentAsMarkdown,
copyDocumentAsPlainText,
starDocument,
unstarDocument,
publishDocument,
+2 -1
View File
@@ -5,6 +5,7 @@ import { Redirect } from "react-router-dom";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { changeLanguage } from "~/utils/language";
import { logoutPath } from "~/utils/routeHelpers";
import LoadingIndicator from "./LoadingIndicator";
type Props = {
@@ -32,7 +33,7 @@ const Authenticated = ({ children }: Props) => {
}
void auth.logout(true);
return <Redirect to="/" />;
return <Redirect to={logoutPath()} />;
};
export default observer(Authenticated);
+1 -14
View File
@@ -1,13 +1,7 @@
import { AnimatePresence } from "framer-motion";
import { observer } from "mobx-react";
import * as React from "react";
import {
Switch,
Route,
useLocation,
matchPath,
Redirect,
} from "react-router-dom";
import { Switch, Route, useLocation, matchPath } from "react-router-dom";
import { TeamPreference } from "@shared/types";
import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
import Layout from "~/components/Layout";
@@ -16,7 +10,6 @@ import Sidebar from "~/components/Sidebar";
import SidebarRight from "~/components/Sidebar/Right";
import SettingsSidebar from "~/components/Sidebar/Settings";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
@@ -55,7 +48,6 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
const can = usePolicy(ui.activeDocumentId);
const canCollection = usePolicy(ui.activeCollectionId);
const team = useCurrentTeam();
const [spendPostLoginPath] = usePostLoginPath();
const goToSearch = (ev: KeyboardEvent) => {
if (!ev.metaKey && !ev.ctrlKey) {
@@ -80,11 +72,6 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
return <ErrorSuspended />;
}
const postLoginPath = spendPostLoginPath();
if (postLoginPath) {
return <Redirect to={postLoginPath} />;
}
const sidebar = (
<Fade>
<Switch>
+1 -5
View File
@@ -2,11 +2,6 @@ import React from "react";
import styled from "styled-components";
import { fadeIn } from "~/styles/animations";
/**
* Fade in animation for a component.
*
* @param timing - The duration of the fade in animation, default is 250ms.
*/
const Fade = styled.span<{ timing?: number | string }>`
animation: ${fadeIn} ${(props) => props.timing || "250ms"} ease-in-out;
`;
@@ -22,6 +17,7 @@ type Props = {
*/
export const ConditionalFade = ({ animate, children }: Props) => {
const [isAnimated] = React.useState(animate);
return isAnimated ? <Fade>{children}</Fade> : <>{children}</>;
};
+141 -147
View File
@@ -1,5 +1,4 @@
import { m } from "framer-motion";
import { observer } from "mobx-react";
import * as React from "react";
import { Portal } from "react-portal";
import styled from "styled-components";
@@ -9,7 +8,6 @@ import useEventListener from "~/hooks/useEventListener";
import useKeyDown from "~/hooks/useKeyDown";
import useMobile from "~/hooks/useMobile";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import useStores from "~/hooks/useStores";
import LoadingIndicator from "../LoadingIndicator";
import { CARD_MARGIN } from "./Components";
import HoverPreviewDocument from "./HoverPreviewDocument";
@@ -25,9 +23,9 @@ const POINTER_WIDTH = 22;
type Props = {
/** The HTML element that is being hovered over, or null if none. */
element: HTMLElement | null;
/** ID of the unfurl that will be shown in the hover preview. */
unfurlId: string | null;
/** Whether the preview data is being loaded. */
/** Data to be previewed */
data: Record<string, any> | null;
/** Whether the preview data is being loaded */
dataLoading: boolean;
/** A callback on close of the hover preview. */
onClose: () => void;
@@ -38,155 +36,151 @@ enum Direction {
DOWN,
}
const HoverPreviewDesktop = observer(
({ element, unfurlId, dataLoading, onClose }: Props) => {
const { unfurls } = useStores();
const [isVisible, setVisible] = React.useState(false);
const timerClose = React.useRef<ReturnType<typeof setTimeout>>();
const cardRef = React.useRef<HTMLDivElement | null>(null);
const { cardLeft, cardTop, pointerLeft, pointerTop, pointerDir } =
useHoverPosition({
cardRef,
element,
isVisible,
});
const data = unfurlId ? unfurls.get(unfurlId)?.data : undefined;
function HoverPreviewDesktop({ element, data, dataLoading, onClose }: Props) {
const [isVisible, setVisible] = React.useState(false);
const timerClose = React.useRef<ReturnType<typeof setTimeout>>();
const cardRef = React.useRef<HTMLDivElement | null>(null);
const { cardLeft, cardTop, pointerLeft, pointerTop, pointerDir } =
useHoverPosition({
cardRef,
element,
isVisible,
});
const closePreview = React.useCallback(() => {
setVisible(false);
onClose();
}, [onClose]);
const closePreview = React.useCallback(() => {
setVisible(false);
onClose();
}, [onClose]);
const stopCloseTimer = React.useCallback(() => {
if (timerClose.current) {
clearTimeout(timerClose.current);
timerClose.current = undefined;
const stopCloseTimer = React.useCallback(() => {
if (timerClose.current) {
clearTimeout(timerClose.current);
timerClose.current = undefined;
}
}, []);
const startCloseTimer = React.useCallback(() => {
timerClose.current = setTimeout(closePreview, DELAY_CLOSE);
}, [closePreview]);
// Open and close the preview when the element changes.
React.useEffect(() => {
if (element && data && !dataLoading) {
setVisible(true);
} else {
startCloseTimer();
}
}, [startCloseTimer, element, data, dataLoading]);
// Close the preview on Escape, scroll, or click outside.
useOnClickOutside(cardRef, closePreview);
useKeyDown("Escape", closePreview);
useEventListener("scroll", closePreview, window, { capture: true });
// Ensure that the preview stays open while the user is hovering over the card.
React.useEffect(() => {
const card = cardRef.current;
if (isVisible) {
if (card) {
card.addEventListener("mouseenter", stopCloseTimer);
card.addEventListener("mouseleave", startCloseTimer);
}
}, []);
const startCloseTimer = React.useCallback(() => {
timerClose.current = setTimeout(closePreview, DELAY_CLOSE);
}, [closePreview]);
// Open and close the preview when the element changes.
React.useEffect(() => {
if (element && data && !dataLoading) {
setVisible(true);
} else {
startCloseTimer();
}
}, [startCloseTimer, element, data, dataLoading]);
// Close the preview on Escape, scroll, or click outside.
useOnClickOutside(cardRef, closePreview);
useKeyDown("Escape", closePreview);
useEventListener("scroll", closePreview, window, { capture: true });
// Ensure that the preview stays open while the user is hovering over the card.
React.useEffect(() => {
const card = cardRef.current;
if (isVisible) {
if (card) {
card.addEventListener("mouseenter", stopCloseTimer);
card.addEventListener("mouseleave", startCloseTimer);
}
}
return () => {
if (card) {
card.removeEventListener("mouseenter", stopCloseTimer);
card.removeEventListener("mouseleave", startCloseTimer);
}
stopCloseTimer();
};
}, [element, startCloseTimer, isVisible, stopCloseTimer]);
if (dataLoading) {
return <LoadingIndicator />;
}
if (!data) {
return null;
}
return () => {
if (card) {
card.removeEventListener("mouseenter", stopCloseTimer);
card.removeEventListener("mouseleave", startCloseTimer);
}
return (
<Portal>
<Position top={cardTop} left={cardLeft} aria-hidden>
{isVisible ? (
<Animate
initial={{ opacity: 0, y: -20, pointerEvents: "none" }}
animate={{
opacity: 1,
y: 0,
transitionEnd: { pointerEvents: "auto" },
}}
>
{data.type === UnfurlResourceType.Mention ? (
<HoverPreviewMention
ref={cardRef}
name={data.name}
avatarUrl={data.avatarUrl}
color={data.color}
lastActive={data.lastActive}
email={data.email}
/>
) : data.type === UnfurlResourceType.Document ? (
<HoverPreviewDocument
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
summary={data.summary}
lastActivityByViewer={data.lastActivityByViewer}
/>
) : data.type === UnfurlResourceType.Issue ? (
<HoverPreviewIssue
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
description={data.description}
author={data.author}
labels={data.labels}
state={data.state}
createdAt={data.createdAt}
/>
) : data.type === UnfurlResourceType.PR ? (
<HoverPreviewPullRequest
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
description={data.description}
author={data.author}
createdAt={data.createdAt}
state={data.state}
/>
) : (
<HoverPreviewLink
ref={cardRef}
url={data.url}
thumbnailUrl={data.thumbnailUrl}
title={data.title}
description={data.description}
/>
)}
<Pointer
top={pointerTop}
left={pointerLeft}
direction={pointerDir}
/>
</Animate>
) : null}
</Position>
</Portal>
);
stopCloseTimer();
};
}, [element, startCloseTimer, isVisible, stopCloseTimer]);
if (dataLoading) {
return <LoadingIndicator />;
}
);
function HoverPreview({ element, unfurlId, dataLoading, ...rest }: Props) {
if (!data) {
return null;
}
return (
<Portal>
<Position top={cardTop} left={cardLeft} aria-hidden>
{isVisible ? (
<Animate
initial={{ opacity: 0, y: -20, pointerEvents: "none" }}
animate={{
opacity: 1,
y: 0,
transitionEnd: { pointerEvents: "auto" },
}}
>
{data.type === UnfurlResourceType.Mention ? (
<HoverPreviewMention
ref={cardRef}
name={data.name}
avatarUrl={data.avatarUrl}
color={data.color}
lastActive={data.lastActive}
email={data.email}
/>
) : data.type === UnfurlResourceType.Document ? (
<HoverPreviewDocument
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
summary={data.summary}
lastActivityByViewer={data.lastActivityByViewer}
/>
) : data.type === UnfurlResourceType.Issue ? (
<HoverPreviewIssue
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
description={data.description}
author={data.author}
labels={data.labels}
state={data.state}
createdAt={data.createdAt}
/>
) : data.type === UnfurlResourceType.PR ? (
<HoverPreviewPullRequest
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
description={data.description}
author={data.author}
createdAt={data.createdAt}
state={data.state}
/>
) : (
<HoverPreviewLink
ref={cardRef}
url={data.url}
thumbnailUrl={data.thumbnailUrl}
title={data.title}
description={data.description}
/>
)}
<Pointer
top={pointerTop}
left={pointerLeft}
direction={pointerDir}
/>
</Animate>
) : null}
</Position>
</Portal>
);
}
function HoverPreview({ element, data, dataLoading, ...rest }: Props) {
const isMobile = useMobile();
if (isMobile) {
return null;
@@ -196,7 +190,7 @@ function HoverPreview({ element, unfurlId, dataLoading, ...rest }: Props) {
<HoverPreviewDesktop
{...rest}
element={element}
unfurlId={unfurlId}
data={data}
dataLoading={dataLoading}
/>
);
+1 -6
View File
@@ -45,10 +45,6 @@ export const NativeInput = styled.input<{
${ellipsis()}
${undraggableOnDesktop()}
&[readOnly] {
color: ${s("textSecondary")};
}
&:disabled,
&::placeholder {
color: ${s("placeholder")};
@@ -130,14 +126,13 @@ export interface Props
React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>,
"prefix"
> {
type?: "text" | "email" | "checkbox" | "search" | "textarea" | "password";
type?: "text" | "email" | "checkbox" | "search" | "textarea";
labelHidden?: boolean;
label?: string;
flex?: boolean;
short?: boolean;
margin?: string | number;
error?: string;
rows?: number;
/** Optional component that appears inside the input before the textarea and any icon */
prefix?: React.ReactNode;
/** Optional icon that appears inside the input before the textarea */
+26
View File
@@ -0,0 +1,26 @@
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import { s } from "@shared/styles";
import Flex from "~/components/Flex";
type Props = {
children?: React.ReactNode;
label: React.ReactNode | string;
};
const Labeled: React.FC<Props> = ({ label, children, ...props }: Props) => (
<Flex column {...props}>
<Label>{label}</Label>
{children}
</Flex>
);
export const Label = styled(Flex)`
font-weight: 500;
padding-bottom: 4px;
display: inline-block;
color: ${s("text")};
`;
export default observer(Labeled);
+6 -20
View File
@@ -54,8 +54,6 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
const [hasPointerMoved, setPointerMoved] = React.useState(false);
const isSmallerThanMinimum = width < minWidth;
const hoverTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
const handleDrag = React.useCallback(
(event: MouseEvent) => {
// suppresses text selection
@@ -116,10 +114,6 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
const handlePointerActivity = React.useCallback(() => {
if (ui.sidebarIsClosed) {
// clear the timeout when mouse exits
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
setHovering(document.hasFocus());
setPointerMoved(true);
}
@@ -128,20 +122,12 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
const handlePointerLeave = React.useCallback(
(ev) => {
if (hasPointerMoved) {
// clear any previous timeout
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
// add a short delay when mouse exits the sidebar before closing
hoverTimeoutRef.current = setTimeout(() => {
setHovering(
document.hasFocus() &&
ev.pageX < width &&
ev.pageY < window.innerHeight &&
ev.pageY > 0
);
}, 500);
setHovering(
document.hasFocus() &&
ev.pageX < width &&
ev.pageY < window.innerHeight &&
ev.pageY > 0
);
}
},
[width, hasPointerMoved]
+3 -2
View File
@@ -645,11 +645,12 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
"section" in item ? item.section?.({ t }) : undefined;
const response = (
<React.Fragment key={`${index}-${item.name}`}>
<>
{currentHeading !== previousHeading && (
<Header key={currentHeading}>{currentHeading}</Header>
)}
<ListItem
key={index}
onPointerMove={handlePointerMove}
onPointerDown={handlePointerDown}
>
@@ -658,7 +659,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
onClick: () => handleClickItem(item),
})}
</ListItem>
</React.Fragment>
</>
);
previousHeading = currentHeading;
+13 -20
View File
@@ -4,9 +4,9 @@ import { EditorView } from "prosemirror-view";
import * as React from "react";
import Extension from "@shared/editor/lib/Extension";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import stores from "~/stores";
import HoverPreview from "~/components/HoverPreview";
import env from "~/env";
import { client } from "~/utils/ApiClient";
interface HoverPreviewsOptions {
/** Delay before the target is considered "hovered" and callback is triggered. */
@@ -16,11 +16,11 @@ interface HoverPreviewsOptions {
export default class HoverPreviews extends Extension {
state: {
activeLinkElement: HTMLElement | null;
unfurlId: string | null;
data: Record<string, any> | null;
dataLoading: boolean;
} = observable({
activeLinkElement: null,
unfurlId: null,
data: null,
dataLoading: false,
});
@@ -62,25 +62,19 @@ export default class HoverPreviews extends Extension {
);
if (url) {
const transformedUrl = url.startsWith("/")
? env.URL + url
: url;
this.state.dataLoading = true;
const unfurl = await stores.unfurls.fetchUnfurl({
url: transformedUrl,
documentId,
});
if (unfurl) {
try {
const data = await client.post("/urls.unfurl", {
url: url.startsWith("/") ? env.URL + url : url,
documentId,
});
this.state.activeLinkElement = element;
this.state.unfurlId = transformedUrl;
} else {
this.state.data = data;
} catch (err) {
this.state.activeLinkElement = null;
} finally {
this.state.dataLoading = false;
}
this.state.dataLoading = false;
}
}),
this.options.delay
@@ -107,11 +101,10 @@ export default class HoverPreviews extends Extension {
widget = () => (
<HoverPreview
element={this.state.activeLinkElement}
unfurlId={this.state.unfurlId}
data={this.state.data}
dataLoading={this.state.dataLoading}
onClose={action(() => {
this.state.activeLinkElement = null;
this.state.unfurlId = null;
})}
/>
);
+1 -1
View File
@@ -88,7 +88,7 @@ export default class PasteHandler extends Extension {
// If the users selection is currently in a code block then paste
// as plain text, ignore all formatting and HTML content.
if (isInCode(state, { inclusive: true })) {
if (isInCode(state)) {
event.preventDefault();
view.dispatch(state.tr.insertText(text));
return true;
-19
View File
@@ -17,7 +17,6 @@ import {
NavigationNodeType,
NotificationEventType,
} from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import Storage from "@shared/utils/Storage";
import { isRTL } from "@shared/utils/rtl";
import slugify from "@shared/utils/slugify";
@@ -664,24 +663,6 @@ export default class Document extends ArchivableModel implements Searchable {
return markdown;
};
/**
* Returns the plain text representation of the document derived from the ProseMirror data.
*
* @returns The plain text representation of the document as a string.
*/
toPlainText = () => {
const extensionManager = new ExtensionManager(withComments(richExtensions));
const schema = new Schema({
nodes: extensionManager.nodes,
marks: extensionManager.marks,
});
const text = ProsemirrorHelper.toPlainText(
Node.fromJSON(schema, this.data),
schema
);
return text;
};
download = (contentType: ExportContentType) =>
client.post(
`/documents.export`,
-3
View File
@@ -15,9 +15,6 @@ class Import extends Model {
/** The name of the import. */
name: string;
/** Descriptive error message when the import errors out. */
error: string | null;
/** The current state of the import. */
@Field
@observable
-18
View File
@@ -1,18 +0,0 @@
import { observable } from "mobx";
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import Model from "./base/Model";
class Unfurl<UnfurlType extends UnfurlResourceType> extends Model {
static modelName = "Unfurl";
@observable
type: UnfurlType;
@observable
data: UnfurlResponse[UnfurlType];
@observable
fetchedAt: string;
}
export default Unfurl;
@@ -16,7 +16,6 @@ import { useDocumentContext } from "~/components/DocumentContext";
import Facepile from "~/components/Facepile";
import Fade from "~/components/Fade";
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
import useBoolean from "~/hooks/useBoolean";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import usePersistedState from "~/hooks/usePersistedState";
@@ -64,7 +63,7 @@ function CommentThread({
const history = useHistory();
const location = useLocation();
const sidebarContext = useLocationSidebarContext();
const [autoFocus, setAutoFocusOn, setAutoFocusOff] = useBoolean(thread.isNew);
const [autoFocus, setAutoFocus] = React.useState(thread.isNew);
const can = usePolicy(document);
@@ -157,9 +156,9 @@ function CommentThread({
React.useEffect(() => {
if (!focused && autoFocus) {
setAutoFocusOff();
setAutoFocus(false);
}
}, [focused, autoFocus, setAutoFocusOff]);
}, [focused, autoFocus]);
React.useEffect(() => {
if (focused) {
@@ -274,7 +273,7 @@ function CommentThread({
)}
</ResizingHeightContainer>
{!focused && !recessed && !draft && canReply && (
<Reply onClick={setAutoFocusOn}>{t("Reply")}</Reply>
<Reply onClick={() => setAutoFocus(true)}>{t("Reply")}</Reply>
)}
</Thread>
);
+8 -1
View File
@@ -2,7 +2,7 @@ import { observer } from "mobx-react";
import { HomeIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Switch, Route } from "react-router-dom";
import { Switch, Route, Redirect } from "react-router-dom";
import styled from "styled-components";
import { s } from "@shared/styles";
import { Action } from "~/components/Actions";
@@ -18,6 +18,7 @@ import Tab from "~/components/Tab";
import Tabs from "~/components/Tabs";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
import { usePinnedDocuments } from "~/hooks/usePinnedDocuments";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
@@ -28,10 +29,16 @@ function Home() {
const team = useCurrentTeam();
const user = useCurrentUser();
const { t } = useTranslation();
const [spendPostLoginPath] = usePostLoginPath();
const userId = user?.id;
const { pins, count } = usePinnedDocuments("home");
const can = usePolicy(team);
const postLoginPath = spendPostLoginPath();
if (postLoginPath) {
return <Redirect to={postLoginPath} />;
}
return (
<Scene
icon={<HomeIcon />}
+8 -15
View File
@@ -9,7 +9,7 @@ import { s } from "@shared/styles";
import { AttachmentPreset } from "@shared/types";
import { AttachmentValidation } from "@shared/validations";
import RootStore from "~/stores/RootStore";
import ButtonLarge from "~/components/ButtonLarge";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import LoadingIndicator from "~/components/LoadingIndicator";
import Modal from "~/components/Modal";
@@ -95,14 +95,8 @@ class ImageUpload extends React.Component<RootStore & Props> {
renderCropping() {
const { ui, submitText } = this.props;
return (
<Modal
onRequestClose={this.handleClose}
fullscreen={false}
title={<>&nbsp;</>}
isOpen
>
<Modal isOpen onRequestClose={this.handleClose} title="">
<Flex auto column align="center" justify="center">
{this.isUploading && <LoadingIndicator />}
<AvatarEditorContainer>
@@ -128,14 +122,9 @@ class ImageUpload extends React.Component<RootStore & Props> {
defaultValue="1"
onChange={this.handleZoom}
/>
<br />
<ButtonLarge
fullwidth
onClick={this.handleCrop}
disabled={this.isUploading}
>
<CropButton onClick={this.handleCrop} disabled={this.isUploading}>
{this.isUploading ? "Uploading…" : submitText}
</ButtonLarge>
</CropButton>
</Flex>
</Modal>
);
@@ -191,4 +180,8 @@ const RangeInput = styled.input`
}
`;
const CropButton = styled(Button)`
width: 300px;
`;
export default withStores(ImageUpload);
@@ -15,7 +15,6 @@ import Time from "~/components/Time";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { ImportMenu } from "~/menus/ImportMenu";
import isCloudHosted from "~/utils/isCloudHosted";
type Props = {
/** Import that's displayed as list item. */
@@ -30,10 +29,6 @@ export const ImportListItem = observer(({ importModel }: Props) => {
const showProgress =
importModel.state !== ImportState.Canceled &&
importModel.state !== ImportState.Errored;
const showErrorInfo =
!isCloudHosted &&
importModel.state === ImportState.Errored &&
!!importModel.error;
const stateMap = React.useMemo(
() => ({
@@ -119,12 +114,6 @@ export const ImportListItem = observer(({ importModel }: Props) => {
subtitle={
<>
{stateMap[importModel.state]}&nbsp;&nbsp;
{showErrorInfo && (
<>
{importModel.error}
{`. ${t("Check server logs for more details.")}`}&nbsp;&nbsp;
</>
)}
{t(`{{userName}} requested`, {
userName:
user.id === importModel.createdBy.id
@@ -0,0 +1,36 @@
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { FileOperationFormat } from "@shared/types";
import useStores from "~/hooks/useStores";
import DropToImport from "./DropToImport";
import HelpDisclosure from "./HelpDisclosure";
function ImportNotionDialog() {
const { t } = useTranslation();
const { dialogs } = useStores();
return (
<>
<HelpDisclosure title={<Trans>Where do I find the file?</Trans>}>
<Trans
defaults="In Notion, click <em>Settings & Members</em> in the left sidebar and open Settings. Look for the Export section, and click <em>Export all workspace content</em>. Choose <em>HTML</em> as the format for the best data compatability."
components={{
em: <strong />,
}}
/>
</HelpDisclosure>
<DropToImport
onSubmit={dialogs.closeAllModals}
format={FileOperationFormat.Notion}
>
<>
{t(
`Drag and drop the zip file from Notion's HTML export option, or click to upload`
)}
</>
</DropToImport>
</>
);
}
export default ImportNotionDialog;
+1 -1
View File
@@ -306,7 +306,7 @@ export default class AuthStore extends Store<Team> {
// if this logout was forced from an authenticated route then
// save the current path so we can go back there once signed in
if (savePath) {
setPostLoginPath(window.location.pathname + window.location.search);
setPostLoginPath(window.location.pathname);
}
if (tryRevokingToken) {
-3
View File
@@ -26,7 +26,6 @@ import SharesStore from "./SharesStore";
import StarsStore from "./StarsStore";
import SubscriptionsStore from "./SubscriptionsStore";
import UiStore from "./UiStore";
import UnfurlsStore from "./UnfurlsStore";
import UserMembershipsStore from "./UserMembershipsStore";
import UsersStore from "./UsersStore";
import ViewsStore from "./ViewsStore";
@@ -56,7 +55,6 @@ export default class RootStore {
searches: SearchesStore;
shares: SharesStore;
ui: UiStore;
unfurls: UnfurlsStore;
stars: StarsStore;
subscriptions: SubscriptionsStore;
users: UsersStore;
@@ -87,7 +85,6 @@ export default class RootStore {
this.registerStore(SharesStore);
this.registerStore(StarsStore);
this.registerStore(SubscriptionsStore);
this.registerStore(UnfurlsStore);
this.registerStore(UsersStore);
this.registerStore(ViewsStore);
this.registerStore(FileOperationsStore);
-85
View File
@@ -1,85 +0,0 @@
import { subMinutes } from "date-fns";
import { action } from "mobx";
import { UnfurlResourceType } from "@shared/types";
import Unfurl from "~/models/Unfurl";
import { client } from "~/utils/ApiClient";
import Logger from "~/utils/Logger";
import RootStore from "./RootStore";
import Store from "./base/Store";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
class UnfurlsStore extends Store<Unfurl<any>> {
actions = []; // no default actions allowed for unfurls.
constructor(rootStore: RootStore) {
super(rootStore, Unfurl);
}
fetchUnfurl = async <UnfurlType extends UnfurlResourceType>({
url,
documentId,
}: {
url: string;
documentId?: string;
}): Promise<Unfurl<UnfurlType> | undefined> => {
const unfurl = this.get(url);
if (unfurl) {
this.refetch({ unfurl: unfurl as Unfurl<UnfurlType>, documentId });
return unfurl;
}
return this.unfurl<UnfurlType>({ url, documentId });
};
private refetch = <UnfurlType extends UnfurlResourceType>({
unfurl,
documentId,
}: {
unfurl: Unfurl<UnfurlType>;
documentId?: string;
}) => {
const fiveMinutesAgo = subMinutes(new Date(), 5);
if (new Date(unfurl.fetchedAt) < fiveMinutesAgo) {
void this.unfurl({ url: unfurl.id, documentId });
}
};
@action
private unfurl = async <UnfurlType extends UnfurlResourceType>({
url,
documentId,
}: {
url: string;
documentId?: string;
}): Promise<Unfurl<UnfurlType> | undefined> => {
try {
this.isFetching = true;
const data = await client.post("/urls.unfurl", {
url,
documentId,
});
// unfurls can succeed with no data.
if (!data) {
return;
}
return this.add({
id: url,
type: data.type,
fetchedAt: new Date().toISOString(),
data,
} as Unfurl<UnfurlType>);
} catch (err) {
Logger.error(`Failed to unfurl url ${url}`, err);
return;
} finally {
this.isFetching = false;
}
};
}
export default UnfurlsStore;
-1
View File
@@ -1,6 +1,5 @@
import commandScore from "command-score";
import invariant from "invariant";
// eslint-disable-next-line lodash/import-scope
import type { ObjectIterateeCustom } from "lodash";
import deburr from "lodash/deburr";
import filter from "lodash/filter";
+3 -5
View File
@@ -49,10 +49,8 @@ export function redirectTo(url: string) {
/**
* Check if the path is a valid path for redirect after login.
*
* @param input A path potentially including query string
* @param path
* @returns boolean indicating if the path is a valid redirect
*/
export const isAllowedLoginRedirect = (input: string) => {
const path = input.split("?")[0];
return !["/", "/create", "/home", "/logout", "/auth/"].includes(path);
};
export const isAllowedLoginRedirect = (path: string) =>
!["/", "/create", "/home", "/logout", "/auth/"].includes(path);
+17 -17
View File
@@ -48,11 +48,11 @@
"> 0.25%, not dead"
],
"dependencies": {
"@aws-sdk/client-s3": "3.782.0",
"@aws-sdk/lib-storage": "3.782.0",
"@aws-sdk/s3-presigned-post": "3.782.0",
"@aws-sdk/s3-request-presigner": "3.782.0",
"@aws-sdk/signature-v4-crt": "^3.782.0",
"@aws-sdk/client-s3": "3.774.0",
"@aws-sdk/lib-storage": "3.774.0",
"@aws-sdk/s3-presigned-post": "3.774.0",
"@aws-sdk/s3-request-presigner": "3.774.0",
"@aws-sdk/signature-v4-crt": "^3.774.0",
"@babel/core": "^7.26.10",
"@babel/plugin-proposal-decorators": "^7.25.9",
"@babel/plugin-transform-class-properties": "^7.25.9",
@@ -89,7 +89,7 @@
"@sentry/node": "^7.120.3",
"@sentry/react": "^7.120.3",
"@tanstack/react-table": "^8.20.6",
"@tanstack/react-virtual": "^3.13.6",
"@tanstack/react-virtual": "^3.11.3",
"@tippyjs/react": "^4.2.6",
"@types/form-data": "^2.5.2",
"@types/mailparser": "^3.4.5",
@@ -141,7 +141,7 @@
"jszip": "^3.10.1",
"katex": "^0.16.21",
"kbar": "0.1.0-beta.41",
"koa": "^2.16.1",
"koa": "^2.15.4",
"koa-body": "^6.0.1",
"koa-compress": "^5.1.1",
"koa-helmet": "^6.1.0",
@@ -153,7 +153,7 @@
"koa-useragent": "^4.1.0",
"lodash": "^4.17.21",
"mailparser": "^3.7.2",
"mammoth": "^1.9.0",
"mammoth": "^1.8.0",
"markdown-it": "^13.0.2",
"markdown-it-container": "^3.0.0",
"markdown-it-emoji": "^2.0.0",
@@ -183,10 +183,10 @@
"prosemirror-dropcursor": "^1.8.1",
"prosemirror-gapcursor": "^1.3.2",
"prosemirror-history": "^1.4.1",
"prosemirror-inputrules": "^1.5.0",
"prosemirror-inputrules": "^1.4.0",
"prosemirror-keymap": "^1.2.2",
"prosemirror-markdown": "^1.13.2",
"prosemirror-model": "^1.25.0",
"prosemirror-markdown": "^1.13.1",
"prosemirror-model": "^1.24.0",
"prosemirror-schema-list": "^1.4.1",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.6.4",
@@ -232,7 +232,7 @@
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",
"socket.io-redis": "^6.1.1",
"sonner": "^1.7.4",
"sonner": "^1.7.1",
"stoppable": "^1.1.0",
"string-replace-to-array": "^2.1.1",
"styled-components": "^5.3.11",
@@ -248,7 +248,7 @@
"uuid": "^8.3.2",
"validator": "13.12.0",
"vaul": "^1.1.2",
"vite": "^5.4.17",
"vite": "^5.4.15",
"vite-plugin-pwa": "^0.20.3",
"winston": "^3.17.0",
"ws": "^7.5.10",
@@ -263,7 +263,7 @@
"@babel/cli": "^7.27.0",
"@babel/preset-typescript": "^7.27.0",
"@faker-js/faker": "^8.4.1",
"@relative-ci/agent": "^4.3.0",
"@relative-ci/agent": "^4.2.14",
"@testing-library/react": "^12.0.0",
"@types/addressparser": "^1.0.3",
"@types/body-scroll-lock": "^3.1.2",
@@ -296,7 +296,7 @@
"@types/markdown-it-emoji": "^2.0.4",
"@types/mime-types": "^2.1.4",
"@types/natural-sort": "^0.0.24",
"@types/node": "20.17.30",
"@types/node": "20.17.27",
"@types/node-fetch": "^2.6.9",
"@types/nodemailer": "^6.4.17",
"@types/passport-oauth2": "^1.4.17",
@@ -377,5 +377,5 @@
"rollup": "^4.5.1",
"prismjs": "1.30.0"
},
"version": "0.83.0"
}
"version": "0.82.0"
}
+1 -1
View File
@@ -94,7 +94,7 @@ if (env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET) {
/** Default user and team names metadata */
let userName = profile.username;
let teamName;
let teamName = "Wiki";
let userAvatarUrl: string = `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.png`;
let teamAvatarUrl: string | undefined = undefined;
let subdomain = slugifyDomain(domain);
@@ -1,8 +1,6 @@
import { APIResponseError, APIErrorCode } from "@notionhq/client";
import { ImportTaskInput, ImportTaskOutput } from "@shared/schema";
import { IntegrationService, ProsemirrorDoc } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import Logger from "@server/logging/Logger";
import { Integration } from "@server/models";
import ImportTask from "@server/models/ImportTask";
import APIImportTask, {
@@ -41,10 +39,7 @@ export default class NotionAPIImportTask extends APIImportTask<IntegrationServic
importTask.input.map(async (item) => this.processPage({ item, client }))
);
// Filter out any null results (from pages/databases that couldn't be accessed)
const validParsedPages = parsedPages.filter(Boolean) as ParsePageOutput[];
const taskOutput: ImportTaskOutput = validParsedPages.map((parsedPage) => ({
const taskOutput: ImportTaskOutput = parsedPages.map((parsedPage) => ({
externalId: parsedPage.externalId,
title: parsedPage.title,
emoji: parsedPage.emoji,
@@ -55,7 +50,7 @@ export default class NotionAPIImportTask extends APIImportTask<IntegrationServic
}));
const childTasksInput: ImportTaskInput<IntegrationService.Notion> =
validParsedPages.flatMap((parsedPage) =>
parsedPages.flatMap((parsedPage) =>
parsedPage.children.map((childPage) => ({
type: childPage.type,
externalId: childPage.externalId,
@@ -93,55 +88,36 @@ export default class NotionAPIImportTask extends APIImportTask<IntegrationServic
}: {
item: ImportTaskInput<IntegrationService.Notion>[number];
client: NotionClient;
}): Promise<ParsePageOutput | null> {
}): Promise<ParsePageOutput> {
const collectionExternalId = item.collectionExternalId ?? item.externalId;
try {
// Convert Notion database to an empty page with "pages in database" as its children.
if (item.type === PageType.Database) {
const { pages, ...databaseInfo } = await client.fetchDatabase(
item.externalId
);
return {
...databaseInfo,
externalId: item.externalId,
content: ProsemirrorHelper.getEmptyDocument() as ProsemirrorDoc,
collectionExternalId,
children: pages.map((page) => ({
type: page.type,
externalId: page.id,
})),
};
}
const { blocks, ...pageInfo } = await client.fetchPage(item.externalId);
// Convert Notion database to an empty page with "pages in database" as its children.
if (item.type === PageType.Database) {
const { pages, ...databaseInfo } = await client.fetchDatabase(
item.externalId
);
return {
...pageInfo,
...databaseInfo,
externalId: item.externalId,
content: NotionConverter.page({ children: blocks } as NotionPage),
content: ProsemirrorHelper.getEmptyDocument() as ProsemirrorDoc,
collectionExternalId,
children: this.parseChildPages(blocks),
children: pages.map((page) => ({
type: page.type,
externalId: page.id,
})),
};
} catch (error) {
if (error instanceof APIResponseError) {
// Skip this page/database if it's not found or not accessible
if (
error.code === APIErrorCode.ObjectNotFound ||
error.code === APIErrorCode.Unauthorized
) {
Logger.warn(
`Skipping Notion ${
item.type === PageType.Database ? "database" : "page"
} ${item.externalId} - Error code: ${error.code} - ${error.message}`
);
return null;
}
}
// Re-throw other errors to be handled by the parent try/catch
throw error;
}
const { blocks, ...pageInfo } = await client.fetchPage(item.externalId);
return {
...pageInfo,
externalId: item.externalId,
content: NotionConverter.page({ children: blocks } as NotionPage),
collectionExternalId,
children: this.parseChildPages(blocks),
};
}
/**
@@ -1,109 +0,0 @@
import { Server } from "@hocuspocus/server";
import WebSocket from "ws";
import EDITOR_VERSION from "@shared/editor/version";
import { sleep } from "@server/utils/timers";
import { ConnectionLimitExtension } from "./ConnectionLimitExtension";
import { EditorVersionExtension } from "./EditorVersionExtension";
jest.mock("@server/env", () => ({
COLLABORATION_MAX_CLIENTS_PER_DOCUMENT: 2,
}));
describe("ConnectionLimitExtension", () => {
let server: typeof Server;
let extension: ConnectionLimitExtension;
const port = 12345;
const url = `ws://localhost:${port}`;
const documentName = "test";
beforeEach(async () => {
extension = new ConnectionLimitExtension();
server = Server.configure({
port,
extensions: [extension, new EditorVersionExtension()],
});
await server.listen();
});
afterEach(async () => {
await server.destroy();
});
const getConnections = () =>
extension.connectionsByDocument.get(documentName)?.size ?? 0;
const createWebSocket = (editorVersion = EDITOR_VERSION) =>
new Promise<WebSocket>((resolve, reject) => {
const ws = new WebSocket(
`${url}/${documentName}?editorVersion=${editorVersion}`
);
ws.on("open", () => resolve(ws));
ws.on("error", reject);
});
it("should allow connections within limit", async () => {
const ws1 = await createWebSocket();
const ws2 = await createWebSocket();
expect(ws1.readyState).toBe(WebSocket.OPEN);
expect(ws2.readyState).toBe(WebSocket.OPEN);
expect(getConnections()).toBe(2);
ws1.close();
ws2.close();
await sleep(250);
expect(getConnections()).toBe(0);
});
it("should close connections exceeding limit", async () => {
const ws1 = await createWebSocket();
const ws2 = await createWebSocket();
const ws3 = await createWebSocket();
await sleep(250);
expect(ws3.readyState).toBe(WebSocket.CLOSED);
expect(ws2.readyState).toBe(WebSocket.OPEN);
expect(ws1.readyState).toBe(WebSocket.OPEN);
expect(getConnections()).toBe(2);
ws1.close();
ws2.close();
await sleep(250);
expect(getConnections()).toBe(0);
});
it("should handle connections closed by other extensions", async () => {
const ws1 = await createWebSocket();
// Create a connection that will be closed by the EditorVersionExtension
const ws2 = await createWebSocket("1.0.0");
ws1.close();
ws2.close();
await sleep(250);
expect(getConnections()).toBe(0);
});
it("should allow new connection after disconnect", async () => {
const ws1 = await createWebSocket();
const ws2 = await createWebSocket();
ws1.close();
await sleep(250);
expect(getConnections()).toBe(1);
const ws3 = await createWebSocket();
expect(ws3.readyState).toBe(WebSocket.OPEN);
expect(getConnections()).toBe(2);
ws2.close();
ws3.close();
await sleep(250);
expect(getConnections()).toBe(0);
});
});
@@ -1,10 +1,8 @@
import {
Extension,
connectedPayload,
onConnectPayload,
onDisconnectPayload,
} from "@hocuspocus/server";
import pluralize from "pluralize";
import { TooManyConnections } from "@shared/collaboration/CloseEvents";
import env from "@server/env";
import Logger from "@server/logging/Logger";
@@ -16,7 +14,7 @@ export class ConnectionLimitExtension implements Extension {
/**
* Map of documentId -> connection count
*/
public connectionsByDocument: Map<string, Set<string>> = new Map();
connectionsByDocument: Map<string, Set<string>> = new Map();
/**
* On disconnect hook
@@ -36,30 +34,23 @@ export class ConnectionLimitExtension implements Extension {
}
}
const connectionCount = connections?.size ?? 0;
Logger.debug(
"multiplayer",
`${connectionCount} ${pluralize(
"connection",
connectionCount
)} to "${documentName}"`
`${connections?.size} connections to "${documentName}"`
);
return Promise.resolve();
}
/**
* onConnect hook is called when a new connection has been established.
* This is where we can check if the document has reached the maximum number of
* connections and reject the connection if it has.
* On connect hook
*
* @param data The onConnect payload
* @returns Promise, resolving will allow the connection, rejecting will drop.
* @param data The connect payload
* @returns Promise, resolving will allow the connection, rejecting will drop it
*/
onConnect({ documentName }: withContext<onConnectPayload>) {
onConnect({ documentName, socketId }: withContext<onConnectPayload>) {
const connections =
this.connectionsByDocument.get(documentName) || new Set();
if (connections?.size >= env.COLLABORATION_MAX_CLIENTS_PER_DOCUMENT) {
Logger.info(
"multiplayer",
@@ -70,30 +61,12 @@ export class ConnectionLimitExtension implements Extension {
return Promise.reject(TooManyConnections);
}
return Promise.resolve();
}
/**
* Connected hook is called after a new connection has been established.
* We can safely update the connection count for the document.
*
* @param data The onConnect payload
* @returns Promise
*/
connected({ documentName, socketId }: withContext<connectedPayload>) {
const connections =
this.connectionsByDocument.get(documentName) || new Set();
connections.add(socketId);
this.connectionsByDocument.set(documentName, connections);
const connectionCount = connections.size ?? 0;
Logger.debug(
"multiplayer",
`${connectionCount} ${pluralize(
"connection",
connectionCount
)} to "${documentName}"`
`${connections.size} connections to "${documentName}"`
);
return Promise.resolve();
@@ -264,6 +264,7 @@ describe("accountProvisioner", () => {
avatarUrl: faker.internet.avatar(),
},
team: {
name: existingTeam.name,
avatarUrl: existingTeam.avatarUrl,
subdomain: faker.internet.domainWord(),
},
@@ -307,6 +308,7 @@ describe("accountProvisioner", () => {
avatarUrl: faker.internet.avatar(),
},
team: {
name: team.name,
avatarUrl: team.avatarUrl,
subdomain: faker.internet.domainWord(),
},
+1 -2
View File
@@ -43,7 +43,7 @@ type Props = {
*/
teamId?: string;
/** The displayed name of the team */
name?: string;
name: string;
/** The domain name from the email of the user logging in */
domain?: string;
/** The preferred subdomain to provision for the team if not yet created */
@@ -92,7 +92,6 @@ async function accountProvisioner({
try {
result = await teamProvisioner({
name: "Wiki",
...teamParams,
authenticationProvider: authenticationProviderParams,
ip,
+9 -2
View File
@@ -3,6 +3,7 @@ import slugify from "slugify";
import { RESERVED_SUBDOMAINS } from "@shared/utils/domains";
import { traceFunction } from "@server/logging/tracing";
import { Team, Event } from "@server/models";
import { generateAvatarUrl } from "@server/utils/avatars";
type Props = {
/** The displayed name of the team */
@@ -28,14 +29,20 @@ type Props = {
async function teamCreator({
name,
domain,
subdomain,
avatarUrl,
authenticationProviders,
ip,
transaction,
}: Props): Promise<Team> {
if (!avatarUrl?.startsWith("http")) {
avatarUrl = null;
// If the service did not provide a logo/avatar then we attempt to generate
// one via ClearBit, or fallback to colored initials in worst case scenario
if (!avatarUrl || !avatarUrl.startsWith("http")) {
avatarUrl = await generateAvatarUrl({
domain,
id: subdomain,
});
}
const team = await Team.create(
+1 -1
View File
@@ -192,7 +192,7 @@ describe("userProvisioner", () => {
it("should prefer isAdmin argument over defaultUserRole", async () => {
const team = await buildTeam({
defaultUserRole: UserRole.Viewer,
defaultUserRole: "viewer",
});
const authenticationProviders = await team.$get("authenticationProviders");
const authenticationProvider = authenticationProviders[0];
@@ -86,8 +86,7 @@ export default class DocumentPublishedOrUpdatedEmail extends BaseEmail<
}
return;
},
30,
10000
30
);
}
@@ -1,37 +0,0 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async transaction => {
await queryInterface.addColumn(
"imports",
"error",
{
type: Sequelize.STRING,
allowNull: true,
},
{ transaction }
);
await queryInterface.addColumn(
"import_tasks",
"error",
{
type: Sequelize.STRING,
allowNull: true,
},
{ transaction }
);
});
},
async down(queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async transaction => {
await queryInterface.removeColumn("imports", "error", { transaction });
await queryInterface.removeColumn("import_tasks", "error", {
transaction,
});
});
},
};
-29
View File
@@ -11,7 +11,6 @@ import {
buildUser,
buildGuestUser,
} from "@server/test/factories";
import Collection from "./Collection";
import UserMembership from "./UserMembership";
beforeEach(() => {
@@ -79,34 +78,6 @@ describe("#delete", () => {
expect(newDocument?.deletedAt).toBeTruthy();
});
test("should soft delete archived document in an archived collection", async () => {
const user = await buildUser();
const collection = await buildCollection({
archivedAt: new Date(),
createdById: user.id,
teamId: user.teamId,
});
const document = await buildDocument({
archivedAt: new Date(),
collectionId: collection.id,
userId: user.id,
teamId: user.teamId,
});
await collection.addDocumentToStructure(document, 0);
await document.delete(user);
const [newDocument, newCollection] = await Promise.all([
Document.findByPk(document.id, {
paranoid: false,
}),
Collection.findByPk(collection.id),
]);
expect(newDocument?.lastModifiedById).toEqual(user.id);
expect(newDocument?.deletedAt).toBeTruthy();
expect(newCollection?.documentStructure).toEqual([]);
});
it("should delete draft without collection", async () => {
const user = await buildUser();
const document = await buildDraftDocument();
+5 -13
View File
@@ -1112,26 +1112,18 @@ class Document extends ArchivableModel<
// Delete a document, archived or otherwise.
delete = (user: User) =>
this.sequelize.transaction(async (transaction: Transaction) => {
let deleted = false;
if (!this.template && this.collectionId) {
const collection = await Collection.findByPk(this.collectionId!, {
if (!this.archivedAt && !this.template && this.collectionId) {
// delete any children and remove from the document structure
const collection = await Collection.findByPk(this.collectionId, {
transaction,
lock: transaction.LOCK.UPDATE,
paranoid: false,
});
if (!this.archivedAt || (this.archivedAt && collection?.archivedAt)) {
await collection?.deleteDocument(this, { transaction });
deleted = true;
}
}
if (!deleted) {
await collection?.deleteDocument(this, { transaction });
} else {
await this.destroy({
transaction,
});
deleted = true;
}
this.lastModifiedById = user.id;
-3
View File
@@ -60,9 +60,6 @@ class Import<T extends ImportableIntegrationService> extends ParanoidModel<
@Column(DataType.INTEGER)
documentCount: number;
@Column
error: string | null;
// associations
@BelongsTo(() => Integration, "integrationId")
-3
View File
@@ -45,9 +45,6 @@ class ImportTask<T extends ImportableIntegrationService> extends IdModel<
@Column(DataType.JSONB)
output: ImportTaskOutput | null;
@Column
error: string | null;
// associations
@BelongsTo(() => Import, "importId")
@@ -222,13 +222,6 @@ describe("NotificationHelper", () => {
documentId: document.id,
});
const deletedUser = await buildUser({ teamId: document.teamId });
await buildSubscription({
userId: deletedUser.id,
documentId: document.id,
});
await deletedUser.destroy();
const recipients =
await NotificationHelper.getDocumentNotificationRecipients({
document,
@@ -201,7 +201,6 @@ export default class NotificationHelper {
include: [
{
association: "user",
required: true,
},
],
});
+43 -2
View File
@@ -3,13 +3,14 @@ import compact from "lodash/compact";
import flatten from "lodash/flatten";
import isEqual from "lodash/isEqual";
import uniq from "lodash/uniq";
import { Node, DOMSerializer, Fragment } from "prosemirror-model";
import { Node, DOMSerializer, Fragment, Mark } from "prosemirror-model";
import * as React from "react";
import { renderToString } from "react-dom/server";
import styled, { ServerStyleSheet, ThemeProvider } from "styled-components";
import { prosemirrorToYDoc } from "y-prosemirror";
import * as Y from "yjs";
import EditorContainer from "@shared/editor/components/Styles";
import embeds from "@shared/editor/embeds";
import GlobalStyles from "@shared/styles/globals";
import light from "@shared/styles/theme";
import { MentionType, ProsemirrorData } from "@shared/types";
@@ -60,7 +61,47 @@ export class ProsemirrorHelper {
);
}
const node = parser.parse(input);
let node = parser.parse(input);
// in the editor embeds are created at runtime by converting links into
// embeds where they match.Because we're converting to a CRDT structure on
// the server we need to mimic this behavior.
function urlsToEmbeds(node: Node): Node {
if (node.type.name === "paragraph") {
for (const textNode of node.content.content) {
for (const embed of embeds) {
if (
textNode.text &&
textNode.marks.some(
(m: Mark) =>
m.type.name === "link" && m.attrs.href === textNode.text
) &&
embed.matcher(textNode.text)
) {
return schema.nodes.embed.createAndFill({
href: textNode.text,
}) as Node;
}
}
}
}
if (node.content) {
const contentAsArray =
node.content instanceof Fragment
? node.content.content
: node.content;
// @ts-expect-error content
node.content = Fragment.fromArray(contentAsArray.map(urlsToEmbeds));
}
return node;
}
if (node) {
node = urlsToEmbeds(node);
}
return node ? prosemirrorToYDoc(node, fieldName) : new Y.Doc();
}
-1
View File
@@ -11,7 +11,6 @@ export default function presentImport(
service: importModel.service,
state: importModel.state,
documentCount: importModel.documentCount,
error: importModel.error,
createdBy: presentUser(importModel.createdBy),
createdById: importModel.createdById,
createdAt: importModel.createdAt,
@@ -1,85 +1,44 @@
import { Op } from "sequelize";
import { GroupUser } from "@server/models";
import {
CollectionGroupEvent,
CollectionUserEvent,
DocumentGroupEvent,
DocumentUserEvent,
Event,
} from "@server/types";
import CollectionSubscriptionRemoveUserTask from "../tasks/CollectionSubscriptionRemoveUserTask";
import DocumentSubscriptionRemoveUserTask from "../tasks/DocumentSubscriptionRemoveUserTask";
import { DocumentGroupEvent, DocumentUserEvent, Event } from "@server/types";
import DocumentSubscriptionTask from "../tasks/DocumentSubscriptionTask";
import BaseProcessor from "./BaseProcessor";
type ReceivedEvent =
| CollectionUserEvent
| CollectionGroupEvent
| DocumentUserEvent
| DocumentGroupEvent;
export default class DocumentSubscriptionProcessor extends BaseProcessor {
static applicableEvents: Event["name"][] = [
"collections.remove_user",
"collections.remove_group",
"documents.remove_user",
"documents.remove_group",
];
async perform(event: ReceivedEvent) {
async perform(event: DocumentUserEvent | DocumentGroupEvent) {
switch (event.name) {
case "collections.remove_user": {
await CollectionSubscriptionRemoveUserTask.schedule(event);
return;
}
case "collections.remove_group":
return this.handleRemoveGroupFromCollection(event);
case "documents.remove_user": {
await DocumentSubscriptionRemoveUserTask.schedule(event);
await DocumentSubscriptionTask.schedule(event);
return;
}
case "documents.remove_group":
return this.handleRemoveGroupFromDocument(event);
return this.handleGroup(event);
default:
}
}
private async handleRemoveGroupFromCollection(event: CollectionGroupEvent) {
private async handleGroup(event: DocumentGroupEvent) {
await GroupUser.findAllInBatches<GroupUser>(
{
where: {
groupId: event.modelId,
userId: {
[Op.ne]: event.actorId,
},
},
batchLimit: 10,
},
async (groupUsers) => {
await Promise.all(
groupUsers.map((groupUser) =>
CollectionSubscriptionRemoveUserTask.schedule({
...event,
name: "collections.remove_user",
userId: groupUser.userId,
})
)
);
}
);
}
private async handleRemoveGroupFromDocument(event: DocumentGroupEvent) {
await GroupUser.findAllInBatches<GroupUser>(
{
where: {
groupId: event.modelId,
},
batchLimit: 10,
},
async (groupUsers) => {
await Promise.all(
groupUsers.map((groupUser) =>
DocumentSubscriptionRemoveUserTask.schedule({
DocumentSubscriptionTask.schedule({
...event,
name: "documents.remove_user",
userId: groupUser.userId,
@@ -6,6 +6,7 @@ import ExportJSONTask from "../tasks/ExportJSONTask";
import ExportMarkdownZipTask from "../tasks/ExportMarkdownZipTask";
import ImportJSONTask from "../tasks/ImportJSONTask";
import ImportMarkdownZipTask from "../tasks/ImportMarkdownZipTask";
import ImportNotionTask from "../tasks/ImportNotionTask";
import BaseProcessor from "./BaseProcessor";
export default class FileOperationCreatedProcessor extends BaseProcessor {
@@ -24,6 +25,11 @@ export default class FileOperationCreatedProcessor extends BaseProcessor {
fileOperationId: event.modelId,
});
break;
case FileOperationFormat.Notion:
await ImportNotionTask.schedule({
fileOperationId: event.modelId,
});
break;
case FileOperationFormat.JSON:
await ImportJSONTask.schedule({
fileOperationId: event.modelId,
+59 -101
View File
@@ -3,12 +3,7 @@ import chunk from "lodash/chunk";
import keyBy from "lodash/keyBy";
import truncate from "lodash/truncate";
import { Fragment, Node } from "prosemirror-model";
import {
CreateOptions,
CreationAttributes,
Transaction,
UniqueConstraintError,
} from "sequelize";
import { CreateOptions, CreationAttributes, Transaction } from "sequelize";
import { v4 as uuidv4 } from "uuid";
import { randomElement } from "@shared/random";
import { ImportInput, ImportTaskInput } from "@shared/schema";
@@ -54,46 +49,33 @@ export default abstract class ImportsProcessor<
* @param event The import event
*/
public async perform(event: ImportEvent) {
try {
await sequelize.transaction(async (transaction) => {
const importModel = await Import.findByPk<Import<T>>(event.modelId, {
rejectOnEmpty: true,
paranoid: false,
transaction,
lock: transaction.LOCK.UPDATE,
});
if (
!this.canProcess(importModel) ||
importModel.state === ImportState.Errored ||
importModel.state === ImportState.Canceled
) {
return;
}
switch (event.name) {
case "imports.create":
return this.onCreation(importModel, transaction);
case "imports.processed":
return this.onProcessed(importModel, transaction);
case "imports.delete":
return this.onDeletion(importModel, event, transaction);
}
await sequelize.transaction(async (transaction) => {
const importModel = await Import.findByPk<Import<T>>(event.modelId, {
rejectOnEmpty: true,
paranoid: false,
transaction,
lock: transaction.LOCK.UPDATE,
});
} catch (err) {
if (event.name !== "imports.delete" && err instanceof Error) {
const importModel = await Import.findByPk<Import<T>>(event.modelId, {
rejectOnEmpty: true,
paranoid: false,
});
importModel.error = truncate(err.message, { length: 255 });
await importModel.save();
if (
!this.canProcess(importModel) ||
importModel.state === ImportState.Errored ||
importModel.state === ImportState.Canceled
) {
return;
}
throw err; // throw error for retry.
}
switch (event.name) {
case "imports.create":
return this.onCreation(importModel, transaction);
case "imports.processed":
return this.onProcessed(importModel, transaction);
case "imports.delete":
return this.onDeletion(importModel, event, transaction);
}
});
}
public async onFailed(event: ImportEvent) {
@@ -159,59 +141,44 @@ export default abstract class ImportsProcessor<
* @returns Promise that resolves when mapping and persistence is completed.
*/
private async onProcessed(importModel: Import<T>, transaction: Transaction) {
try {
const { collections } = await this.createCollectionsAndDocuments({
importModel,
transaction,
});
const { collections } = await this.createCollectionsAndDocuments({
importModel,
transaction,
});
// Once all collections and documents are created, update collection's document structure.
// This ensures the root documents have the whole subtree available in the structure.
for (const collection of collections) {
await Document.unscoped().findAllInBatches<Document>(
{
where: { parentDocumentId: null, collectionId: collection.id },
order: [
["createdAt", "DESC"],
["id", "ASC"],
],
transaction,
},
async (documents) => {
for (const document of documents) {
await collection.addDocumentToStructure(document, 0, {
save: false,
silent: true,
transaction,
});
}
}
);
await collection.save({ silent: true, transaction });
}
importModel.state = ImportState.Completed;
importModel.error = null; // unset any error from previous attempts.
await importModel.saveWithCtx(
createContext({
user: importModel.createdBy,
// Once all collections and documents are created, update collection's document structure.
// This ensures the root documents have the whole subtree available in the structure.
for (const collection of collections) {
await Document.unscoped().findAllInBatches<Document>(
{
where: { parentDocumentId: null, collectionId: collection.id },
order: [
["createdAt", "DESC"],
["id", "ASC"],
],
transaction,
})
);
} catch (err) {
if (err instanceof UniqueConstraintError) {
Logger.error(
"ImportsProcessor persistence failed due to unique constraint error",
err,
{
fields: err.fields,
},
async (documents) => {
for (const document of documents) {
await collection.addDocumentToStructure(document, 0, {
save: false,
silent: true,
transaction,
});
}
);
}
}
);
throw err;
await collection.save({ silent: true, transaction });
}
importModel.state = ImportState.Completed;
await importModel.saveWithCtx(
createContext({
user: importModel.createdBy,
transaction,
})
);
}
/**
@@ -323,15 +290,6 @@ export default abstract class ImportsProcessor<
const output = outputMap[externalId];
// Skip this item if it has no output (likely due to an error during processing)
if (!output) {
Logger.debug(
"processor",
`Skipping item with no output: ${externalId}`
);
continue;
}
const collectionItem = importInput[externalId];
const attachments = await Attachment.findAll({
@@ -472,7 +430,7 @@ export default abstract class ImportsProcessor<
importInput: Record<string, ImportInput<any>[number]>;
actorId: string;
}): ProsemirrorDoc {
// special case when the doc content is empty.
// special case when the doc content is empty
if (!content.content.length) {
return content;
}
+13 -25
View File
@@ -1,6 +1,5 @@
import { JobOptions } from "bull";
import chunk from "lodash/chunk";
import truncate from "lodash/truncate";
import uniqBy from "lodash/uniqBy";
import { Fragment, Node } from "prosemirror-model";
import { Transaction, WhereOptions } from "sequelize";
@@ -64,29 +63,20 @@ export default abstract class APIImportTask<
return;
}
try {
switch (importTask.state) {
case ImportTaskState.Created: {
importTask.state = ImportTaskState.InProgress;
importTask = await importTask.save();
return await this.onProcess(importTask);
}
case ImportTaskState.InProgress:
return await this.onProcess(importTask);
case ImportTaskState.Completed:
return await this.onCompletion(importTask);
default:
}
} catch (err) {
if (err instanceof Error) {
importTask.error = truncate(err.message, { length: 255 });
await importTask.save();
switch (importTask.state) {
case ImportTaskState.Created: {
importTask.state = ImportTaskState.InProgress;
importTask = await importTask.save();
return await this.onProcess(importTask);
}
throw err; // throw error for retry.
case ImportTaskState.InProgress:
return await this.onProcess(importTask);
case ImportTaskState.Completed:
return await this.onCompletion(importTask);
default:
}
}
@@ -118,7 +108,6 @@ export default abstract class APIImportTask<
await importTask.save({ transaction });
const associatedImport = importTask.import;
associatedImport.error = importTask.error; // copy error from ImportTask that caused the failure.
associatedImport.state = ImportState.Errored;
await associatedImport.saveWithCtx(
createContext({
@@ -166,11 +155,10 @@ export default abstract class APIImportTask<
importTask.output = taskOutputWithReplacements;
importTask.state = ImportTaskState.Completed;
importTask.error = null; // unset any error from previous attempts.
await importTask.save({ transaction });
const associatedImport = importTask.import;
associatedImport.documentCount += taskOutputWithReplacements.length;
associatedImport.documentCount += importTask.input.length;
await associatedImport.saveWithCtx(
createContext({
user: associatedImport.createdBy,
@@ -1,52 +0,0 @@
import { Transaction } from "sequelize";
import { SubscriptionType } from "@shared/types";
import { createContext } from "@server/context";
import Logger from "@server/logging/Logger";
import { Collection, Subscription, User } from "@server/models";
import { can } from "@server/policies";
import { sequelize } from "@server/storage/database";
import { CollectionUserEvent } from "@server/types";
import BaseTask from "./BaseTask";
export default class CollectionSubscriptionRemoveUserTask extends BaseTask<CollectionUserEvent> {
public async perform(event: CollectionUserEvent) {
const user = await User.findByPk(event.userId);
if (!user) {
return;
}
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(event.collectionId);
if (can(user, "read", collection)) {
Logger.debug(
"task",
`Skip unsubscribing user ${user.id} as they have permission to the collection ${event.collectionId} through other means`
);
return;
}
await sequelize.transaction(async (transaction) => {
const subscription = await Subscription.findOne({
where: {
userId: user.id,
collectionId: event.collectionId,
event: SubscriptionType.Document,
},
transaction,
lock: Transaction.LOCK.UPDATE,
});
await subscription?.destroyWithCtx(
createContext({
user,
authType: event.authType,
ip: event.ip,
transaction,
})
);
});
}
}
@@ -8,11 +8,11 @@ import { sequelize } from "@server/storage/database";
import { DocumentUserEvent } from "@server/types";
import BaseTask from "./BaseTask";
export default class DocumentSubscriptionRemoveUserTask extends BaseTask<DocumentUserEvent> {
export default class DocumentSubscriptionTask extends BaseTask<DocumentUserEvent> {
public async perform(event: DocumentUserEvent) {
const user = await User.findByPk(event.userId);
if (!user) {
if (!user || event.name !== "documents.remove_user") {
return;
}
@@ -56,13 +56,11 @@ export default class ErrorTimedOutImportsTask extends BaseTask<Props> {
await sequelize.transaction(async (transaction) => {
importTask.state = ImportTaskState.Errored;
importTask.error = "Timed out";
await importTask.save({ transaction });
// this import could have been seen before in another import_task.
if (!importsErrored[associatedImport.id]) {
associatedImport.state = ImportState.Errored;
associatedImport.error = "Timed out";
await associatedImport.save({ transaction });
importsErrored[associatedImport.id] = true;
}
@@ -0,0 +1,87 @@
/* eslint-disable @typescript-eslint/no-empty-function */
import path from "path";
import { FileOperation } from "@server/models";
import { buildFileOperation } from "@server/test/factories";
import ImportNotionTask from "./ImportNotionTask";
describe("ImportNotionTask", () => {
it("should import successfully from a Markdown export", async () => {
const fileOperation = await buildFileOperation();
Object.defineProperty(fileOperation, "handle", {
get() {
return {
path: path.resolve(
__dirname,
"..",
"..",
"test",
"fixtures",
"notion-markdown.zip"
),
cleanup: async () => {},
};
},
});
jest.spyOn(FileOperation, "findByPk").mockResolvedValue(fileOperation);
const props = {
fileOperationId: fileOperation.id,
};
const task = new ImportNotionTask();
const response = await task.perform(props);
expect(response.collections.size).toEqual(2);
expect(response.documents.size).toEqual(6);
expect(response.attachments.size).toEqual(1);
// Check that the image url was replaced in the text with a redirect
const attachments = Array.from(response.attachments.values());
const documents = Array.from(response.documents.values());
expect(documents.map((d) => d.text).join("")).toContain(
attachments[0].redirectUrl
);
});
it("should import successfully from a HTML export", async () => {
const fileOperation = await buildFileOperation();
Object.defineProperty(fileOperation, "handle", {
get() {
return {
path: path.resolve(
__dirname,
"..",
"..",
"test",
"fixtures",
"notion-html.zip"
),
cleanup: async () => {},
};
},
});
jest.spyOn(FileOperation, "findByPk").mockResolvedValue(fileOperation);
const props = {
fileOperationId: fileOperation.id,
};
const task = new ImportNotionTask();
const response = await task.perform(props);
expect(response.collections.size).toEqual(2);
expect(response.documents.size).toEqual(6);
expect(response.attachments.size).toEqual(4);
// Check that the image url was replaced in the text with a redirect
const attachments = Array.from(response.attachments.values());
const attachment = attachments.find((att) =>
att.key.endsWith("Screen_Shot_2022-04-21_at_2.23.26_PM.png")
);
const documents = Array.from(response.documents.values());
expect(documents.map((d) => d.text).join("")).toContain(
attachment?.redirectUrl
);
});
});
+354
View File
@@ -0,0 +1,354 @@
import path from "path";
import fs from "fs-extra";
import compact from "lodash/compact";
import escapeRegExp from "lodash/escapeRegExp";
import mime from "mime-types";
import { v4 as uuidv4 } from "uuid";
import documentImporter from "@server/commands/documentImporter";
import { createContext } from "@server/context";
import Logger from "@server/logging/Logger";
import { FileOperation, User } from "@server/models";
import { sequelize } from "@server/storage/database";
import ImportHelper, { FileTreeNode } from "@server/utils/ImportHelper";
import ImportTask, { StructuredImportData } from "./ImportTask";
export default class ImportNotionTask extends ImportTask {
public async parseData(
dirPath: string,
fileOperation: FileOperation
): Promise<StructuredImportData> {
const tree = await ImportHelper.toFileTree(dirPath);
if (!tree) {
throw new Error("Could not find valid content in zip file");
}
// New Notion exports have a single folder with the name of the export, we must skip this
// folder and go directly to the children.
let parsed;
if (
tree.children.length === 1 &&
tree.children[0].children.find((child) => child.title === "index")
) {
parsed = await this.parseFileTree(
fileOperation,
tree.children[0].children.filter((child) => child.title !== "index")
);
} else {
parsed = await this.parseFileTree(fileOperation, tree.children);
}
if (parsed.documents.length === 0 && parsed.collections.length === 1) {
const collection = parsed.collections[0];
const collectionId = uuidv4();
if (collection.description) {
parsed.documents.push({
title: collection.name,
icon: collection.icon,
color: collection.color,
path: "",
text: String(collection.description),
id: collection.id,
externalId: collection.externalId,
mimeType: "text/html",
collectionId,
});
}
collection.name = "Notion";
collection.icon = undefined;
collection.color = undefined;
collection.externalId = undefined;
collection.description = undefined;
collection.id = collectionId;
}
return parsed;
}
/**
* Converts the file structure from zipAsFileTree into documents,
* collections, and attachments.
*
* @param fileOperation The file operation
* @param tree An array of FileTreeNode representing root files in the zip
* @returns A StructuredImportData object
*/
private async parseFileTree(
fileOperation: FileOperation,
tree: FileTreeNode[]
): Promise<StructuredImportData> {
const user = await User.findByPk(fileOperation.userId, {
rejectOnEmpty: true,
});
const output: StructuredImportData = {
collections: [],
documents: [],
attachments: [],
};
const parseNodeChildren = async (
children: FileTreeNode[],
collectionId: string,
parentDocumentId?: string
): Promise<void> => {
await Promise.all(
children.map(async (child) => {
// Ignore the CSV's for databases upfront
if (child.path.endsWith(".csv")) {
return;
}
const id = uuidv4();
const match = child.title.match(this.NotionUUIDRegex);
const name = child.title.replace(this.NotionUUIDRegex, "");
const externalId = match ? match[0].trim() : undefined;
// If it's not a text file we're going to treat it as an attachment.
const mimeType = mime.lookup(child.name);
const isDocument =
mimeType === "text/markdown" ||
mimeType === "text/plain" ||
mimeType === "text/html";
// If it's not a document and not a folder, treat it as an attachment
if (!isDocument && mimeType) {
output.attachments.push({
id,
name: child.name,
path: child.path,
mimeType,
buffer: () => fs.readFile(child.path),
externalId,
});
return;
}
Logger.debug("task", `Processing ${name} as ${mimeType}`);
const { title, icon, text } = await sequelize.transaction(
async (transaction) =>
documentImporter({
mimeType: mimeType || "text/markdown",
fileName: name,
content:
child.children.length > 0
? ""
: await fs.readFile(child.path, "utf8"),
user,
ctx: createContext({ user, transaction }),
})
);
const existingDocumentIndex = output.documents.findIndex(
(doc) => doc.externalId === externalId
);
const existingDocument = output.documents[existingDocumentIndex];
// If there is an existing document with the same externalId that means
// we've already parsed either a folder or a file referencing the same
// document, as such we should merge.
if (existingDocument) {
if (existingDocument.text === "") {
output.documents[existingDocumentIndex].text = text;
}
await parseNodeChildren(
child.children,
collectionId,
existingDocument.id
);
} else {
output.documents.push({
id,
title,
icon,
text,
collectionId,
parentDocumentId,
path: child.path,
mimeType: mimeType || "text/markdown",
externalId,
});
await parseNodeChildren(child.children, collectionId, id);
}
})
);
};
const replaceInternalLinksAndImages = (text: string) => {
// Find if there are any images in this document
const imagesInText = this.parseImages(text);
for (const image of imagesInText) {
const name = path.basename(image.src);
const attachment = output.attachments.find(
(att) =>
att.path.endsWith(image.src) ||
encodeURI(att.path).endsWith(image.src)
);
if (!attachment) {
if (!image.src.startsWith("http")) {
Logger.info(
"task",
`Could not find referenced attachment with name ${name} and src ${image.src}`
);
}
} else {
text = text.replace(
new RegExp(escapeRegExp(image.src), "g"),
`<<${attachment.id}>>`
);
}
}
// With Notion's HTML import, images sometimes come wrapped in anchor tags
// This isn't supported in Outline's editor, so we need to strip them.
text = text.replace(/\[!\[([^[]+)]/g, "![]");
// Find if there are any links in this document pointing to other documents
const internalLinksInText = this.parseInternalLinks(text);
// For each link update to the standardized format of <<documentId>>
// instead of a relative or absolute URL within the original zip file.
for (const link of internalLinksInText) {
const doc = output.documents.find(
(doc) => doc.externalId === link.externalId
);
if (!doc) {
Logger.info(
"task",
`Could not find referenced document with externalId ${link.externalId}`
);
} else {
text = text.replace(link.href, `<<${doc.id}>>`);
}
}
return text;
};
// All nodes in the root level should become collections
for (const node of tree) {
const match = node.title.match(this.NotionUUIDRegex);
const name = node.title.replace(this.NotionUUIDRegex, "");
const externalId = match ? match[0].trim() : undefined;
const mimeType = mime.lookup(node.name);
const existingCollectionIndex = output.collections.findIndex(
(collection) => collection.externalId === externalId
);
const existingCollection = output.collections[existingCollectionIndex];
const collectionId = existingCollection?.id || uuidv4();
let description;
// Root level docs become the descriptions of collections
if (
mimeType === "text/markdown" ||
mimeType === "text/plain" ||
mimeType === "text/html"
) {
const { text } = await sequelize.transaction(async (transaction) =>
documentImporter({
mimeType,
fileName: name,
content: await fs.readFile(node.path, "utf8"),
user,
ctx: createContext({ user, transaction }),
})
);
description = text;
} else if (node.children.length > 0) {
await parseNodeChildren(node.children, collectionId);
} else {
Logger.debug("task", `Unhandled file in zip: ${node.path}`, {
fileOperationId: fileOperation.id,
});
continue;
}
if (existingCollectionIndex !== -1) {
if (description) {
output.collections[existingCollectionIndex].description = description;
}
} else {
output.collections.push({
id: collectionId,
name,
description,
externalId,
});
}
}
for (const document of output.documents) {
document.text = replaceInternalLinksAndImages(document.text);
}
for (const collection of output.collections) {
if (typeof collection.description === "string") {
collection.description = replaceInternalLinksAndImages(
collection.description
);
}
}
return output;
}
/**
* Extracts internal links from a markdown document, taking into account the
* externalId of the document, which is part of the link title.
*
* @param text The markdown text to parse
* @returns An array of internal links
*/
private parseInternalLinks(
text: string
): { title: string; href: string; externalId: string }[] {
return compact(
[...text.matchAll(this.NotionLinkRegex)].map((match) => ({
title: match[1],
href: match[2],
externalId: match[3],
}))
);
}
/**
* Extracts images from the markdown document
*
* @param text The markdown text to parse
* @returns An array of internal links
*/
private parseImages(text: string): { alt: string; src: string }[] {
return compact(
[...text.matchAll(this.ImageRegex)].map((match) => ({
alt: match[1],
src: match[2],
}))
);
}
/**
* Regex to find markdown images of all types
*/
private ImageRegex =
/!\[(?<alt>[^\][]*?)]\((?<filename>[^\][]*?)(?=“|\))“?(?<title>[^\][”]+)?”?\)/g;
/**
* Regex to find markdown links containing ID's that look like UUID's with the
* "-"'s removed, Notion's externalId format.
*/
private NotionLinkRegex = /\[([^[]+)]\((.*?([0-9a-fA-F]{32})\..*?)\)/g;
/**
* Regex to find Notion document UUID's in the title of a document.
*/
private NotionUUIDRegex =
/\s([0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}|[0-9a-fA-F]{32})$/;
}
@@ -597,7 +597,6 @@ router.post(
createdById: user.id,
},
transaction,
hooks: false,
});
}
@@ -2,7 +2,6 @@ import queryString from "query-string";
import { v4 as uuidv4 } from "uuid";
import { randomElement } from "@shared/random";
import { NotificationEventType } from "@shared/types";
import NotificationSettingsHelper from "@server/models/helpers/NotificationSettingsHelper";
import {
buildCollection,
buildDocument,
@@ -698,40 +697,3 @@ describe("#notifications.update_all", () => {
expect(body.data.total).toBe(2);
});
});
describe("#notifications.unsubscribe", () => {
it("should allow unsubscribe with valid token", async () => {
const user = await buildUser();
const token = NotificationSettingsHelper.unsubscribeToken(
user.id,
NotificationEventType.UpdateDocument
);
const res = await server.get(
`/api/notifications.unsubscribe?userId=${user.id}&token=${token}&eventType=documents.update&follow=true`,
{
redirect: "manual",
}
);
expect(res.status).toBe(302);
expect(res.headers.get("location")).toContain(
"/settings/notifications?success"
);
const events = (await user.reload()).notificationSettings;
expect(events).not.toContain("documents.update");
});
it("should not allow unsubscribe with invalid token", async () => {
const user = await buildUser();
const res = await server.get(
`/api/notifications.unsubscribe?userId=${user.id}&token=invalid-token&eventType=documents.update&follow=true`,
{
redirect: "manual",
}
);
expect(res.status).toBe(302);
expect(res.headers.get("location")).toContain("?notice=invalid-auth");
});
});
@@ -53,7 +53,7 @@ const handleUnsubscribe = async (
});
user.setNotificationEventType(eventType, false);
await user.save({ transaction });
await user.save();
ctx.redirect(`${user.team.url}/settings/notifications?success`);
};
Binary file not shown.
Binary file not shown.
+5 -5
View File
@@ -18,14 +18,11 @@ export class CacheHelper {
* @param key Cache key
* @param callback Callback to get the data if not found in cache
* @param expiry Cache data expiry in seconds
* @param lockTimeout Lock timeout in milliseconds
* @returns The data from cache or the result of the callback
*/
public static async getDataOrSet<T>(
key: string,
callback: () => Promise<T | undefined>,
expiry: number,
lockTimeout: number = MutexLock.defaultLockTimeout
expiry?: number
): Promise<T | undefined> {
let cache = await this.getData<T>(key);
@@ -38,7 +35,10 @@ export class CacheHelper {
const lockKey = `lock:${key}`;
try {
try {
lock = await MutexLock.lock.acquire([lockKey], lockTimeout);
lock = await MutexLock.lock.acquire(
[lockKey],
MutexLock.defaultLockTimeout
);
} catch (err) {
Logger.error(`Could not acquire lock for ${key}`, err);
}
+9
View File
@@ -0,0 +1,9 @@
import { generateAvatarUrl } from "./avatars";
it("should return clearbit url if available", async () => {
const url = await generateAvatarUrl({
id: "google",
domain: "google.com",
});
expect(url).toBe("https://logo.clearbit.com/google.com");
});
+28
View File
@@ -0,0 +1,28 @@
import crypto from "crypto";
import fetch from "./fetch";
export async function generateAvatarUrl({
id,
domain,
}: {
id: string;
domain?: string;
}) {
// attempt to get logo from Clearbit API. If one doesn't exist then
// fall back to using tiley to generate a placeholder logo
const hash = crypto.createHash("sha256");
hash.update(id);
let cbResponse, cbUrl;
if (domain) {
cbUrl = `https://logo.clearbit.com/${domain}`;
try {
cbResponse = await fetch(cbUrl);
} catch (err) {
// okay
}
}
return cbUrl && cbResponse && cbResponse.status === 200 ? cbUrl : null;
}
-87
View File
@@ -1,87 +0,0 @@
import {
buildCollection,
buildDocument,
buildStar,
buildTeam,
buildUser,
} from "@server/test/factories";
import { collectionIndexing, starIndexing } from "./indexing";
describe("collectionIndexing", () => {
it("should generate index for collections without index", async () => {
const team = await buildTeam();
const collections = await Promise.all([
buildCollection({
teamId: team.id,
}),
buildCollection({
teamId: team.id,
}),
]);
// Set index to null to simulate no index
collections[0].index = null;
collections[1].index = null;
await collections[0].save({ hooks: false });
await collections[1].save({ hooks: false });
const result = await collectionIndexing(team.id, {});
expect(Object.keys(result).length).toBe(2);
expect(result[collections[0].id]).toBeTruthy();
expect(result[collections[1].id]).toBeTruthy();
});
it("should maintain existing indices", async () => {
const team = await buildTeam();
const collection = await buildCollection({
teamId: team.id,
index: "a1",
});
const result = await collectionIndexing(team.id, {});
expect(result[collection.id]).toBe("a1");
});
});
describe("starIndexing", () => {
it("should generate index for stars without index", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const document = await buildDocument();
const stars = await Promise.all([
buildStar({
userId: user.id,
documentId: document.id,
}),
buildStar({
userId: user.id,
documentId: document.id,
}),
]);
// Set index to null to simulate no index
stars[0].index = null;
stars[1].index = null;
await stars[0].save({ hooks: false });
await stars[1].save({ hooks: false });
const result = await starIndexing(user.id);
expect(Object.keys(result).length).toBe(2);
expect(result[stars[0].id]).toBeTruthy();
expect(result[stars[1].id]).toBeTruthy();
});
it("should maintain existing indices", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const document = await buildDocument();
const star = await buildStar({
userId: user.id,
documentId: document.id,
index: "a1",
});
const result = await starIndexing(user.id);
expect(result[star.id]).toBe("a1");
});
});
+5 -5
View File
@@ -10,8 +10,10 @@ export async function collectionIndexing(
const collections = await Collection.findAll({
where: {
teamId,
// no point in maintaining index of deleted collections.
deletedAt: null,
},
attributes: ["id", "index", "name", "teamId"],
attributes: ["id", "index", "name"],
transaction,
});
@@ -24,9 +26,7 @@ export async function collectionIndexing(
for (const collection of sortable) {
if (collection.index === null) {
collection.index = fractionalIndex(previousIndex, null);
promises.push(
collection.save({ fields: ["index"], silent: true, transaction })
);
promises.push(collection.save({ fields: ["index"], transaction })); // save only index to prevent overwriting other unfetched fields.
}
previousIndex = collection.index;
@@ -67,7 +67,7 @@ export async function starIndexing(userId: string) {
for (const star of sortable) {
if (star.index === null) {
star.index = fractionalIndex(previousIndex, null);
promises.push(star.save({ silent: true }));
promises.push(star.save());
}
previousIndex = star.index;
-4
View File
@@ -10,10 +10,6 @@ type Props = {
captureEvents?: "all" | "pointer" | "click";
};
/**
* EventBoundary is a component that prevents events from propagating to parent elements.
* This is useful for preventing clicks or other interactions from bubbling up the DOM tree.
*/
const EventBoundary: React.FC<Props> = ({
children,
className,
-12
View File
@@ -5,26 +5,14 @@ type JustifyValues = CSSProperties["justifyContent"];
type AlignValues = CSSProperties["alignItems"];
/**
* Flex is a styled component that provides a flexible box layout with convenient props.
* It simplifies the use of flexbox CSS properties with a clean, declarative API.
*/
const Flex = styled.div<{
/** Makes the component grow to fill available space */
auto?: boolean;
/** Changes flex direction to column */
column?: boolean;
/** Sets the align-items CSS property */
align?: AlignValues;
/** Sets the justify-content CSS property */
justify?: JustifyValues;
/** Enables flex-wrap */
wrap?: boolean;
/** Controls flex-shrink behavior */
shrink?: boolean;
/** Reverses the direction (row-reverse or column-reverse) */
reverse?: boolean;
/** Sets gap between flex items in pixels */
gap?: number;
}>`
display: flex;
-5
View File
@@ -11,11 +11,6 @@ type Props = {
className?: string;
};
/**
* Squircle is a component that renders a square with rounded corners (squircle shape).
* It's commonly used for app icons, avatars, and other UI elements where a softer
* square shape is desired.
*/
const Squircle: React.FC<Props> = ({
color,
size = 28,
-4
View File
@@ -313,10 +313,6 @@ width: 100%;
background: ${props.theme.mentionHoverBackground};
}
&[data-type="user"] {
gap: 0;
}
&.mention-user::before {
content: "@";
}
+3 -5
View File
@@ -28,10 +28,9 @@ export function getCellAttrs(dom: HTMLElement | string): Attrs {
const widthAttr = dom.getAttribute("data-colwidth");
const widths =
widthAttr && /^\d+(,\d+)*$/.test(widthAttr)
? widthAttr.split(",").map(Number)
? widthAttr.split(",").map((s) => Number(s))
: null;
const colspan = Number(dom.getAttribute("colspan") || 1);
return {
colspan,
rowspan: Number(dom.getAttribute("rowspan") || 1),
@@ -64,11 +63,10 @@ export function setCellAttrs(node: Node): Attrs {
}
if (node.attrs.colwidth) {
if (isBrowser) {
attrs["data-colwidth"] = node.attrs.colwidth.map(parseInt).join(",");
attrs["data-colwidth"] = node.attrs.colwidth.join(",");
} else {
attrs.style =
(attrs.style ?? "") +
`min-width: ${parseInt(node.attrs.colwidth[0])}px;`;
(attrs.style ?? "") + `min-width: ${node.attrs.colwidth}px;`;
}
}
+65 -2
View File
@@ -21,10 +21,9 @@ export default class Code extends Mark {
get schema(): MarkSpec {
return {
excludes: "mention placeholder highlight",
excludes: "mention placeholder highlight em strong",
parseDOM: [{ tag: "code", preserveWhitespace: true }],
toDOM: () => ["code", { class: "inline", spellCheck: "false" }],
code: true,
};
}
@@ -100,6 +99,7 @@ export default class Code extends Mark {
return false;
}
// Check if we're pasting inside backticks
const start = from - 1;
const end = to + 1;
if (
@@ -119,6 +119,69 @@ export default class Code extends Mark {
return true;
}
// Check if we're pasting over an existing inline code block
if (isInCode(state, { onlyMark: true })) {
// Get the range of the current code mark
const marks = state.doc.resolve(from).marks();
const codeMark = marks.find(
(mark) => mark.type === state.schema.marks.code_inline
);
if (codeMark) {
// Find the start and end of the code mark
let codeStart = from;
let codeEnd = to;
// Find the start of the code mark
for (let i = from; i > 0; i--) {
const resolvedPos = state.doc.resolve(i);
const marksAtPos = resolvedPos.marks();
const hasCodeMark = marksAtPos.some(
(mark) => mark.type === state.schema.marks.code_inline
);
if (!hasCodeMark) {
codeStart = i + 1;
break;
}
if (i === 1) {
codeStart = 1;
}
}
// Find the end of the code mark
for (let i = to; i < state.doc.nodeSize - 2; i++) {
const resolvedPos = state.doc.resolve(i);
const marksAtPos = resolvedPos.marks();
const hasCodeMark = marksAtPos.some(
(mark) => mark.type === state.schema.marks.code_inline
);
if (!hasCodeMark) {
codeEnd = i;
break;
}
if (i === state.doc.nodeSize - 3) {
codeEnd = state.doc.nodeSize - 2;
}
}
// Replace the content within the code mark
view.dispatch(
state.tr
.replaceRange(from, to, slice)
.addMark(
from,
from + slice.size,
state.schema.marks.code_inline.create()
)
);
return true;
}
}
return false;
},
+5 -7
View File
@@ -32,11 +32,6 @@ export default class Mention extends Node {
}
get schema(): NodeSpec {
const toPlainText = (node: ProsemirrorNode) =>
node.attrs.type === MentionType.User
? `@${node.attrs.label}`
: node.attrs.label;
return {
attrs: {
type: {
@@ -93,9 +88,12 @@ export default class Mention extends Node {
"data-actorid": node.attrs.actorId,
"data-url": `mention://${node.attrs.id}/${node.attrs.type}/${node.attrs.modelId}`,
},
toPlainText(node),
String(node.attrs.label),
],
toPlainText,
toPlainText: (node) =>
node.attrs.type === MentionType.User
? `@${node.attrs.label}`
: node.attrs.label,
};
}
+3 -17
View File
@@ -7,8 +7,6 @@ type Options = {
onlyBlock?: boolean;
/** Only check if the selection is inside a code mark. */
onlyMark?: boolean;
/** If true then code must contain entire selection */
inclusive?: boolean;
};
/**
@@ -22,29 +20,17 @@ export function isInCode(state: EditorState, options?: Options): boolean {
const { nodes, marks } = state.schema;
if (!options?.onlyMark) {
if (
nodes.code_block &&
isNodeActive(nodes.code_block, undefined, {
inclusive: options?.inclusive,
})(state)
) {
if (nodes.code_block && isNodeActive(nodes.code_block)(state)) {
return true;
}
if (
nodes.code_fence &&
isNodeActive(nodes.code_fence, undefined, {
inclusive: options?.inclusive,
})(state)
) {
if (nodes.code_fence && isNodeActive(nodes.code_fence)(state)) {
return true;
}
}
if (!options?.onlyBlock) {
if (marks.code_inline) {
return isMarkActive(marks.code_inline, undefined, {
inclusive: options?.inclusive,
})(state);
return isMarkActive(marks.code_inline)(state);
}
}
+1 -4
View File
@@ -6,8 +6,6 @@ import { getMarksBetween } from "./getMarksBetween";
type Options = {
/** Only return match if the range and attrs is exact */
exact?: boolean;
/** If true then mark must contain entire selection */
inclusive?: boolean;
};
/**
@@ -42,8 +40,7 @@ export const isMarkActive =
Object.keys(attrs).every(
(key) => mark.attrs[key] === attrs[key]
)) &&
(!options?.exact || (start === from && end === to)) &&
(!options?.inclusive || (start <= from && end >= to))
(!options?.exact || (start === from && end === to))
);
}
+10 -34
View File
@@ -3,55 +3,31 @@ import { EditorState } from "prosemirror-state";
import { Primitive } from "utility-types";
import { findParentNode } from "./findParentNode";
type Options = {
/** Only return match if the range and attrs is exact */
exact?: boolean;
/** If true then node must contain entire selection */
inclusive?: boolean;
};
/**
* Checks if a node is active in the current selection or not.
*
* @param type The node type to check.
* @param attrs The attributes to check.
* @param options The options to use.
* @returns A function that checks if a node is active in the current selection or not.
*/
export const isNodeActive =
(type: NodeType, attrs?: Record<string, Primitive>, options?: Options) =>
(state: EditorState): boolean => {
(type: NodeType, attrs: Record<string, Primitive> = {}) =>
(state: EditorState) => {
if (!type) {
return false;
}
const { from, to } = state.selection;
const nodeWithPos = findParentNode(
(node) =>
node.type === type &&
(!attrs ||
Object.keys(attrs).every((key) => node.attrs[key] === attrs[key]))
)(state.selection);
const nodeAfter = state.selection.$from.nodeAfter;
let node = nodeAfter?.type === type ? nodeAfter : undefined;
if (!nodeWithPos) {
return false;
if (!node) {
const parent = findParentNode((n) => n.type === type)(state.selection);
node = parent?.node;
}
if (options?.inclusive) {
// Check if the node's position contains the entire selection
return (
nodeWithPos.pos <= from &&
nodeWithPos.pos + nodeWithPos.node.nodeSize >= to
);
if (!Object.keys(attrs).length || !node) {
return !!node;
}
if (options?.exact) {
// Check if node's range exactly matches selection
return (
nodeWithPos.pos === from &&
nodeWithPos.pos + nodeWithPos.node.nodeSize === to
);
}
return true;
return node.hasMarkup(type, { ...node.attrs, ...attrs });
};
+24 -16
View File
@@ -1,5 +1,16 @@
import { useState, useLayoutEffect } from "react";
const defaultRect = {
top: 0,
left: 0,
bottom: 0,
right: 0,
x: 0,
y: 0,
width: 0,
height: 0,
};
/**
* A hook that returns the size of an element or ref.
*
@@ -8,11 +19,19 @@ import { useState, useLayoutEffect } from "react";
*/
export function useComponentSize(
input: HTMLElement | null | React.RefObject<HTMLElement | null>
) {
): DOMRect | typeof defaultRect {
const element = input instanceof HTMLElement ? input : input?.current;
const [size, setSize] = useState<DOMRect | undefined>(
() => element?.getBoundingClientRect() || new DOMRect()
);
const [size, setSize] = useState(() => element?.getBoundingClientRect());
useLayoutEffect(() => {
const sizeObserver = new ResizeObserver(() => {
element?.dispatchEvent(new CustomEvent("resize"));
});
if (element) {
sizeObserver.observe(element);
}
return () => sizeObserver.disconnect();
}, [element]);
useLayoutEffect(() => {
const handleResize = () => {
@@ -36,7 +55,6 @@ export function useComponentSize(
window.addEventListener("click", handleResize);
window.addEventListener("resize", handleResize);
element?.addEventListener("resize", handleResize);
handleResize();
return () => {
window.removeEventListener("click", handleResize);
@@ -45,15 +63,5 @@ export function useComponentSize(
};
});
useLayoutEffect(() => {
const sizeObserver = new ResizeObserver(() => {
element?.dispatchEvent(new CustomEvent("resize"));
});
if (element) {
sizeObserver.observe(element);
}
return () => sizeObserver.disconnect();
}, [element]);
return size ?? new DOMRect();
return size ?? defaultRect;
}
+3 -2
View File
@@ -57,8 +57,6 @@
"Download document": "Download document",
"Copy as Markdown": "Copy as Markdown",
"Markdown copied to clipboard": "Markdown copied to clipboard",
"Copy as text": "Copy as text",
"Text copied to clipboard": "Text copied to clipboard",
"Copy public link": "Copy public link",
"Link copied to clipboard": "Link copied to clipboard",
"Copy link": "Copy link",
@@ -905,6 +903,9 @@
"{{ count }} document imported_plural": "{{ count }} documents imported",
"You can import a zip file that was previously exported from an Outline installation collections, documents, and images will be imported. In Outline, open <em>Export</em> in the Settings sidebar and click on <em>Export Data</em>.": "You can import a zip file that was previously exported from an Outline installation collections, documents, and images will be imported. In Outline, open <em>Export</em> in the Settings sidebar and click on <em>Export Data</em>.",
"Drag and drop the zip file from the Markdown export option in {{appName}}, or click to upload": "Drag and drop the zip file from the Markdown export option in {{appName}}, or click to upload",
"Where do I find the file?": "Where do I find the file?",
"In Notion, click <em>Settings & Members</em> in the left sidebar and open Settings. Look for the Export section, and click <em>Export all workspace content</em>. Choose <em>HTML</em> as the format for the best data compatability.": "In Notion, click <em>Settings & Members</em> in the left sidebar and open Settings. Look for the Export section, and click <em>Export all workspace content</em>. Choose <em>HTML</em> as the format for the best data compatability.",
"Drag and drop the zip file from Notion's HTML export option, or click to upload": "Drag and drop the zip file from Notion's HTML export option, or click to upload",
"Last active": "Last active",
"Guest": "Guest",
"Shared by": "Shared by",
+758 -743
View File
File diff suppressed because it is too large Load Diff