mirror of
https://github.com/outline/outline.git
synced 2026-06-14 03:45:00 +03:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bab2729669 | |||
| 90f9721b40 | |||
| dc474573c6 | |||
| a3910ce6d1 | |||
| f9476770ce | |||
| 2e018e74b8 | |||
| a11ab56117 | |||
| 66e4ec32ed | |||
| bde9d5fbf4 | |||
| 70bb878a8c | |||
| 4237377d47 | |||
| a30f6b717b | |||
| 1edc23c5ae | |||
| ff6ec3a5b8 | |||
| 52c2729490 | |||
| 82f4281a02 | |||
| 12b6e30e3a | |||
| 567ca7e3f1 | |||
| 97c3ea7da8 | |||
| 4af2b032dd | |||
| c52d9a850d | |||
| 588e5bc17f | |||
| a2bd0edd82 | |||
| ca0f0638c9 | |||
| f13e6a3691 | |||
| dcb7b86df8 | |||
| 45c6e72c6d | |||
| a51456deb3 |
@@ -1,7 +1,13 @@
|
|||||||
import { AnimatePresence } from "framer-motion";
|
import { AnimatePresence } from "framer-motion";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Switch, Route, useLocation, matchPath } from "react-router-dom";
|
import {
|
||||||
|
Switch,
|
||||||
|
Route,
|
||||||
|
useLocation,
|
||||||
|
matchPath,
|
||||||
|
Redirect,
|
||||||
|
} from "react-router-dom";
|
||||||
import { TeamPreference } from "@shared/types";
|
import { TeamPreference } from "@shared/types";
|
||||||
import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
|
import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
|
||||||
import Layout from "~/components/Layout";
|
import Layout from "~/components/Layout";
|
||||||
@@ -10,6 +16,7 @@ import Sidebar from "~/components/Sidebar";
|
|||||||
import SidebarRight from "~/components/Sidebar/Right";
|
import SidebarRight from "~/components/Sidebar/Right";
|
||||||
import SettingsSidebar from "~/components/Sidebar/Settings";
|
import SettingsSidebar from "~/components/Sidebar/Settings";
|
||||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||||
|
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
|
||||||
import usePolicy from "~/hooks/usePolicy";
|
import usePolicy from "~/hooks/usePolicy";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
import history from "~/utils/history";
|
import history from "~/utils/history";
|
||||||
@@ -48,6 +55,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
|||||||
const can = usePolicy(ui.activeDocumentId);
|
const can = usePolicy(ui.activeDocumentId);
|
||||||
const canCollection = usePolicy(ui.activeCollectionId);
|
const canCollection = usePolicy(ui.activeCollectionId);
|
||||||
const team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
const [spendPostLoginPath] = usePostLoginPath();
|
||||||
|
|
||||||
const goToSearch = (ev: KeyboardEvent) => {
|
const goToSearch = (ev: KeyboardEvent) => {
|
||||||
if (!ev.metaKey && !ev.ctrlKey) {
|
if (!ev.metaKey && !ev.ctrlKey) {
|
||||||
@@ -72,6 +80,11 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
|||||||
return <ErrorSuspended />;
|
return <ErrorSuspended />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const postLoginPath = spendPostLoginPath();
|
||||||
|
if (postLoginPath) {
|
||||||
|
return <Redirect to={postLoginPath} />;
|
||||||
|
}
|
||||||
|
|
||||||
const sidebar = (
|
const sidebar = (
|
||||||
<Fade>
|
<Fade>
|
||||||
<Switch>
|
<Switch>
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import React from "react";
|
|||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import { fadeIn } from "~/styles/animations";
|
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 }>`
|
const Fade = styled.span<{ timing?: number | string }>`
|
||||||
animation: ${fadeIn} ${(props) => props.timing || "250ms"} ease-in-out;
|
animation: ${fadeIn} ${(props) => props.timing || "250ms"} ease-in-out;
|
||||||
`;
|
`;
|
||||||
@@ -17,7 +22,6 @@ type Props = {
|
|||||||
*/
|
*/
|
||||||
export const ConditionalFade = ({ animate, children }: Props) => {
|
export const ConditionalFade = ({ animate, children }: Props) => {
|
||||||
const [isAnimated] = React.useState(animate);
|
const [isAnimated] = React.useState(animate);
|
||||||
|
|
||||||
return isAnimated ? <Fade>{children}</Fade> : <>{children}</>;
|
return isAnimated ? <Fade>{children}</Fade> : <>{children}</>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -645,12 +645,11 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
|||||||
"section" in item ? item.section?.({ t }) : undefined;
|
"section" in item ? item.section?.({ t }) : undefined;
|
||||||
|
|
||||||
const response = (
|
const response = (
|
||||||
<>
|
<React.Fragment key={`${index}-${item.name}`}>
|
||||||
{currentHeading !== previousHeading && (
|
{currentHeading !== previousHeading && (
|
||||||
<Header key={currentHeading}>{currentHeading}</Header>
|
<Header key={currentHeading}>{currentHeading}</Header>
|
||||||
)}
|
)}
|
||||||
<ListItem
|
<ListItem
|
||||||
key={index}
|
|
||||||
onPointerMove={handlePointerMove}
|
onPointerMove={handlePointerMove}
|
||||||
onPointerDown={handlePointerDown}
|
onPointerDown={handlePointerDown}
|
||||||
>
|
>
|
||||||
@@ -659,7 +658,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
|||||||
onClick: () => handleClickItem(item),
|
onClick: () => handleClickItem(item),
|
||||||
})}
|
})}
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
|
|
||||||
previousHeading = currentHeading;
|
previousHeading = currentHeading;
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export default class PasteHandler extends Extension {
|
|||||||
|
|
||||||
// If the users selection is currently in a code block then paste
|
// If the users selection is currently in a code block then paste
|
||||||
// as plain text, ignore all formatting and HTML content.
|
// as plain text, ignore all formatting and HTML content.
|
||||||
if (isInCode(state)) {
|
if (isInCode(state, { inclusive: true })) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
view.dispatch(state.tr.insertText(text));
|
view.dispatch(state.tr.insertText(text));
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ class Import extends Model {
|
|||||||
/** The name of the import. */
|
/** The name of the import. */
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
|
/** Descriptive error message when the import errors out. */
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
/** The current state of the import. */
|
/** The current state of the import. */
|
||||||
@Field
|
@Field
|
||||||
@observable
|
@observable
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { useDocumentContext } from "~/components/DocumentContext";
|
|||||||
import Facepile from "~/components/Facepile";
|
import Facepile from "~/components/Facepile";
|
||||||
import Fade from "~/components/Fade";
|
import Fade from "~/components/Fade";
|
||||||
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
|
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
|
||||||
|
import useBoolean from "~/hooks/useBoolean";
|
||||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||||
import usePersistedState from "~/hooks/usePersistedState";
|
import usePersistedState from "~/hooks/usePersistedState";
|
||||||
@@ -63,7 +64,7 @@ function CommentThread({
|
|||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const sidebarContext = useLocationSidebarContext();
|
const sidebarContext = useLocationSidebarContext();
|
||||||
const [autoFocus, setAutoFocus] = React.useState(thread.isNew);
|
const [autoFocus, setAutoFocusOn, setAutoFocusOff] = useBoolean(thread.isNew);
|
||||||
|
|
||||||
const can = usePolicy(document);
|
const can = usePolicy(document);
|
||||||
|
|
||||||
@@ -156,9 +157,9 @@ function CommentThread({
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!focused && autoFocus) {
|
if (!focused && autoFocus) {
|
||||||
setAutoFocus(false);
|
setAutoFocusOff();
|
||||||
}
|
}
|
||||||
}, [focused, autoFocus]);
|
}, [focused, autoFocus, setAutoFocusOff]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (focused) {
|
if (focused) {
|
||||||
@@ -273,7 +274,7 @@ function CommentThread({
|
|||||||
)}
|
)}
|
||||||
</ResizingHeightContainer>
|
</ResizingHeightContainer>
|
||||||
{!focused && !recessed && !draft && canReply && (
|
{!focused && !recessed && !draft && canReply && (
|
||||||
<Reply onClick={() => setAutoFocus(true)}>{t("Reply")}…</Reply>
|
<Reply onClick={setAutoFocusOn}>{t("Reply")}…</Reply>
|
||||||
)}
|
)}
|
||||||
</Thread>
|
</Thread>
|
||||||
);
|
);
|
||||||
|
|||||||
+1
-8
@@ -2,7 +2,7 @@ import { observer } from "mobx-react";
|
|||||||
import { HomeIcon } from "outline-icons";
|
import { HomeIcon } from "outline-icons";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Switch, Route, Redirect } from "react-router-dom";
|
import { Switch, Route } from "react-router-dom";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import { s } from "@shared/styles";
|
import { s } from "@shared/styles";
|
||||||
import { Action } from "~/components/Actions";
|
import { Action } from "~/components/Actions";
|
||||||
@@ -18,7 +18,6 @@ import Tab from "~/components/Tab";
|
|||||||
import Tabs from "~/components/Tabs";
|
import Tabs from "~/components/Tabs";
|
||||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||||
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
|
|
||||||
import { usePinnedDocuments } from "~/hooks/usePinnedDocuments";
|
import { usePinnedDocuments } from "~/hooks/usePinnedDocuments";
|
||||||
import usePolicy from "~/hooks/usePolicy";
|
import usePolicy from "~/hooks/usePolicy";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
@@ -29,16 +28,10 @@ function Home() {
|
|||||||
const team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
const user = useCurrentUser();
|
const user = useCurrentUser();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [spendPostLoginPath] = usePostLoginPath();
|
|
||||||
const userId = user?.id;
|
const userId = user?.id;
|
||||||
const { pins, count } = usePinnedDocuments("home");
|
const { pins, count } = usePinnedDocuments("home");
|
||||||
const can = usePolicy(team);
|
const can = usePolicy(team);
|
||||||
|
|
||||||
const postLoginPath = spendPostLoginPath();
|
|
||||||
if (postLoginPath) {
|
|
||||||
return <Redirect to={postLoginPath} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Scene
|
<Scene
|
||||||
icon={<HomeIcon />}
|
icon={<HomeIcon />}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import Time from "~/components/Time";
|
|||||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
import { ImportMenu } from "~/menus/ImportMenu";
|
import { ImportMenu } from "~/menus/ImportMenu";
|
||||||
|
import isCloudHosted from "~/utils/isCloudHosted";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/** Import that's displayed as list item. */
|
/** Import that's displayed as list item. */
|
||||||
@@ -29,6 +30,10 @@ export const ImportListItem = observer(({ importModel }: Props) => {
|
|||||||
const showProgress =
|
const showProgress =
|
||||||
importModel.state !== ImportState.Canceled &&
|
importModel.state !== ImportState.Canceled &&
|
||||||
importModel.state !== ImportState.Errored;
|
importModel.state !== ImportState.Errored;
|
||||||
|
const showErrorInfo =
|
||||||
|
!isCloudHosted &&
|
||||||
|
importModel.state === ImportState.Errored &&
|
||||||
|
!!importModel.error;
|
||||||
|
|
||||||
const stateMap = React.useMemo(
|
const stateMap = React.useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -114,6 +119,12 @@ export const ImportListItem = observer(({ importModel }: Props) => {
|
|||||||
subtitle={
|
subtitle={
|
||||||
<>
|
<>
|
||||||
{stateMap[importModel.state]} •
|
{stateMap[importModel.state]} •
|
||||||
|
{showErrorInfo && (
|
||||||
|
<>
|
||||||
|
{importModel.error}
|
||||||
|
{`. ${t("Check server logs for more details.")}`} •
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{t(`{{userName}} requested`, {
|
{t(`{{userName}} requested`, {
|
||||||
userName:
|
userName:
|
||||||
user.id === importModel.createdBy.id
|
user.id === importModel.createdBy.id
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
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;
|
|
||||||
+10
-10
@@ -48,11 +48,11 @@
|
|||||||
"> 0.25%, not dead"
|
"> 0.25%, not dead"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "3.774.0",
|
"@aws-sdk/client-s3": "3.777.0",
|
||||||
"@aws-sdk/lib-storage": "3.774.0",
|
"@aws-sdk/lib-storage": "3.777.0",
|
||||||
"@aws-sdk/s3-presigned-post": "3.774.0",
|
"@aws-sdk/s3-presigned-post": "3.777.0",
|
||||||
"@aws-sdk/s3-request-presigner": "3.774.0",
|
"@aws-sdk/s3-request-presigner": "3.777.0",
|
||||||
"@aws-sdk/signature-v4-crt": "^3.774.0",
|
"@aws-sdk/signature-v4-crt": "^3.775.0",
|
||||||
"@babel/core": "^7.26.10",
|
"@babel/core": "^7.26.10",
|
||||||
"@babel/plugin-proposal-decorators": "^7.25.9",
|
"@babel/plugin-proposal-decorators": "^7.25.9",
|
||||||
"@babel/plugin-transform-class-properties": "^7.25.9",
|
"@babel/plugin-transform-class-properties": "^7.25.9",
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
"@sentry/node": "^7.120.3",
|
"@sentry/node": "^7.120.3",
|
||||||
"@sentry/react": "^7.120.3",
|
"@sentry/react": "^7.120.3",
|
||||||
"@tanstack/react-table": "^8.20.6",
|
"@tanstack/react-table": "^8.20.6",
|
||||||
"@tanstack/react-virtual": "^3.11.3",
|
"@tanstack/react-virtual": "^3.13.6",
|
||||||
"@tippyjs/react": "^4.2.6",
|
"@tippyjs/react": "^4.2.6",
|
||||||
"@types/form-data": "^2.5.2",
|
"@types/form-data": "^2.5.2",
|
||||||
"@types/mailparser": "^3.4.5",
|
"@types/mailparser": "^3.4.5",
|
||||||
@@ -185,8 +185,8 @@
|
|||||||
"prosemirror-history": "^1.4.1",
|
"prosemirror-history": "^1.4.1",
|
||||||
"prosemirror-inputrules": "^1.4.0",
|
"prosemirror-inputrules": "^1.4.0",
|
||||||
"prosemirror-keymap": "^1.2.2",
|
"prosemirror-keymap": "^1.2.2",
|
||||||
"prosemirror-markdown": "^1.13.1",
|
"prosemirror-markdown": "^1.13.2",
|
||||||
"prosemirror-model": "^1.24.0",
|
"prosemirror-model": "^1.25.0",
|
||||||
"prosemirror-schema-list": "^1.4.1",
|
"prosemirror-schema-list": "^1.4.1",
|
||||||
"prosemirror-state": "^1.4.3",
|
"prosemirror-state": "^1.4.3",
|
||||||
"prosemirror-tables": "^1.6.4",
|
"prosemirror-tables": "^1.6.4",
|
||||||
@@ -248,7 +248,7 @@
|
|||||||
"uuid": "^8.3.2",
|
"uuid": "^8.3.2",
|
||||||
"validator": "13.12.0",
|
"validator": "13.12.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"vite": "^5.4.15",
|
"vite": "^5.4.16",
|
||||||
"vite-plugin-pwa": "^0.20.3",
|
"vite-plugin-pwa": "^0.20.3",
|
||||||
"winston": "^3.17.0",
|
"winston": "^3.17.0",
|
||||||
"ws": "^7.5.10",
|
"ws": "^7.5.10",
|
||||||
@@ -263,7 +263,7 @@
|
|||||||
"@babel/cli": "^7.27.0",
|
"@babel/cli": "^7.27.0",
|
||||||
"@babel/preset-typescript": "^7.27.0",
|
"@babel/preset-typescript": "^7.27.0",
|
||||||
"@faker-js/faker": "^8.4.1",
|
"@faker-js/faker": "^8.4.1",
|
||||||
"@relative-ci/agent": "^4.2.14",
|
"@relative-ci/agent": "^4.3.0",
|
||||||
"@testing-library/react": "^12.0.0",
|
"@testing-library/react": "^12.0.0",
|
||||||
"@types/addressparser": "^1.0.3",
|
"@types/addressparser": "^1.0.3",
|
||||||
"@types/body-scroll-lock": "^3.1.2",
|
"@types/body-scroll-lock": "^3.1.2",
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { APIResponseError, APIErrorCode } from "@notionhq/client";
|
||||||
import { ImportTaskInput, ImportTaskOutput } from "@shared/schema";
|
import { ImportTaskInput, ImportTaskOutput } from "@shared/schema";
|
||||||
import { IntegrationService, ProsemirrorDoc } from "@shared/types";
|
import { IntegrationService, ProsemirrorDoc } from "@shared/types";
|
||||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||||
|
import Logger from "@server/logging/Logger";
|
||||||
import { Integration } from "@server/models";
|
import { Integration } from "@server/models";
|
||||||
import ImportTask from "@server/models/ImportTask";
|
import ImportTask from "@server/models/ImportTask";
|
||||||
import APIImportTask, {
|
import APIImportTask, {
|
||||||
@@ -39,7 +41,10 @@ export default class NotionAPIImportTask extends APIImportTask<IntegrationServic
|
|||||||
importTask.input.map(async (item) => this.processPage({ item, client }))
|
importTask.input.map(async (item) => this.processPage({ item, client }))
|
||||||
);
|
);
|
||||||
|
|
||||||
const taskOutput: ImportTaskOutput = parsedPages.map((parsedPage) => ({
|
// 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) => ({
|
||||||
externalId: parsedPage.externalId,
|
externalId: parsedPage.externalId,
|
||||||
title: parsedPage.title,
|
title: parsedPage.title,
|
||||||
emoji: parsedPage.emoji,
|
emoji: parsedPage.emoji,
|
||||||
@@ -50,7 +55,7 @@ export default class NotionAPIImportTask extends APIImportTask<IntegrationServic
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const childTasksInput: ImportTaskInput<IntegrationService.Notion> =
|
const childTasksInput: ImportTaskInput<IntegrationService.Notion> =
|
||||||
parsedPages.flatMap((parsedPage) =>
|
validParsedPages.flatMap((parsedPage) =>
|
||||||
parsedPage.children.map((childPage) => ({
|
parsedPage.children.map((childPage) => ({
|
||||||
type: childPage.type,
|
type: childPage.type,
|
||||||
externalId: childPage.externalId,
|
externalId: childPage.externalId,
|
||||||
@@ -88,36 +93,55 @@ export default class NotionAPIImportTask extends APIImportTask<IntegrationServic
|
|||||||
}: {
|
}: {
|
||||||
item: ImportTaskInput<IntegrationService.Notion>[number];
|
item: ImportTaskInput<IntegrationService.Notion>[number];
|
||||||
client: NotionClient;
|
client: NotionClient;
|
||||||
}): Promise<ParsePageOutput> {
|
}): Promise<ParsePageOutput | null> {
|
||||||
const collectionExternalId = item.collectionExternalId ?? item.externalId;
|
const collectionExternalId = item.collectionExternalId ?? item.externalId;
|
||||||
|
|
||||||
// Convert Notion database to an empty page with "pages in database" as its children.
|
try {
|
||||||
if (item.type === PageType.Database) {
|
// Convert Notion database to an empty page with "pages in database" as its children.
|
||||||
const { pages, ...databaseInfo } = await client.fetchDatabase(
|
if (item.type === PageType.Database) {
|
||||||
item.externalId
|
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);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...databaseInfo,
|
...pageInfo,
|
||||||
externalId: item.externalId,
|
externalId: item.externalId,
|
||||||
content: ProsemirrorHelper.getEmptyDocument() as ProsemirrorDoc,
|
content: NotionConverter.page({ children: blocks } as NotionPage),
|
||||||
collectionExternalId,
|
collectionExternalId,
|
||||||
children: pages.map((page) => ({
|
children: this.parseChildPages(blocks),
|
||||||
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),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
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,8 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
Extension,
|
Extension,
|
||||||
|
connectedPayload,
|
||||||
onConnectPayload,
|
onConnectPayload,
|
||||||
onDisconnectPayload,
|
onDisconnectPayload,
|
||||||
} from "@hocuspocus/server";
|
} from "@hocuspocus/server";
|
||||||
|
import pluralize from "pluralize";
|
||||||
import { TooManyConnections } from "@shared/collaboration/CloseEvents";
|
import { TooManyConnections } from "@shared/collaboration/CloseEvents";
|
||||||
import env from "@server/env";
|
import env from "@server/env";
|
||||||
import Logger from "@server/logging/Logger";
|
import Logger from "@server/logging/Logger";
|
||||||
@@ -14,7 +16,7 @@ export class ConnectionLimitExtension implements Extension {
|
|||||||
/**
|
/**
|
||||||
* Map of documentId -> connection count
|
* Map of documentId -> connection count
|
||||||
*/
|
*/
|
||||||
connectionsByDocument: Map<string, Set<string>> = new Map();
|
public connectionsByDocument: Map<string, Set<string>> = new Map();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* On disconnect hook
|
* On disconnect hook
|
||||||
@@ -34,23 +36,30 @@ export class ConnectionLimitExtension implements Extension {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const connectionCount = connections?.size ?? 0;
|
||||||
Logger.debug(
|
Logger.debug(
|
||||||
"multiplayer",
|
"multiplayer",
|
||||||
`${connections?.size} connections to "${documentName}"`
|
`${connectionCount} ${pluralize(
|
||||||
|
"connection",
|
||||||
|
connectionCount
|
||||||
|
)} to "${documentName}"`
|
||||||
);
|
);
|
||||||
|
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* On connect hook
|
* 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.
|
||||||
*
|
*
|
||||||
* @param data The connect payload
|
* @param data The onConnect payload
|
||||||
* @returns Promise, resolving will allow the connection, rejecting will drop it
|
* @returns Promise, resolving will allow the connection, rejecting will drop.
|
||||||
*/
|
*/
|
||||||
onConnect({ documentName, socketId }: withContext<onConnectPayload>) {
|
onConnect({ documentName }: withContext<onConnectPayload>) {
|
||||||
const connections =
|
const connections =
|
||||||
this.connectionsByDocument.get(documentName) || new Set();
|
this.connectionsByDocument.get(documentName) || new Set();
|
||||||
|
|
||||||
if (connections?.size >= env.COLLABORATION_MAX_CLIENTS_PER_DOCUMENT) {
|
if (connections?.size >= env.COLLABORATION_MAX_CLIENTS_PER_DOCUMENT) {
|
||||||
Logger.info(
|
Logger.info(
|
||||||
"multiplayer",
|
"multiplayer",
|
||||||
@@ -61,12 +70,30 @@ export class ConnectionLimitExtension implements Extension {
|
|||||||
return Promise.reject(TooManyConnections);
|
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);
|
connections.add(socketId);
|
||||||
this.connectionsByDocument.set(documentName, connections);
|
this.connectionsByDocument.set(documentName, connections);
|
||||||
|
const connectionCount = connections.size ?? 0;
|
||||||
|
|
||||||
Logger.debug(
|
Logger.debug(
|
||||||
"multiplayer",
|
"multiplayer",
|
||||||
`${connections.size} connections to "${documentName}"`
|
`${connectionCount} ${pluralize(
|
||||||
|
"connection",
|
||||||
|
connectionCount
|
||||||
|
)} to "${documentName}"`
|
||||||
);
|
);
|
||||||
|
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import slugify from "slugify";
|
|||||||
import { RESERVED_SUBDOMAINS } from "@shared/utils/domains";
|
import { RESERVED_SUBDOMAINS } from "@shared/utils/domains";
|
||||||
import { traceFunction } from "@server/logging/tracing";
|
import { traceFunction } from "@server/logging/tracing";
|
||||||
import { Team, Event } from "@server/models";
|
import { Team, Event } from "@server/models";
|
||||||
import { generateAvatarUrl } from "@server/utils/avatars";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/** The displayed name of the team */
|
/** The displayed name of the team */
|
||||||
@@ -29,20 +28,14 @@ type Props = {
|
|||||||
|
|
||||||
async function teamCreator({
|
async function teamCreator({
|
||||||
name,
|
name,
|
||||||
domain,
|
|
||||||
subdomain,
|
subdomain,
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
authenticationProviders,
|
authenticationProviders,
|
||||||
ip,
|
ip,
|
||||||
transaction,
|
transaction,
|
||||||
}: Props): Promise<Team> {
|
}: Props): Promise<Team> {
|
||||||
// If the service did not provide a logo/avatar then we attempt to generate
|
if (!avatarUrl?.startsWith("http")) {
|
||||||
// one via ClearBit, or fallback to colored initials in worst case scenario
|
avatarUrl = null;
|
||||||
if (!avatarUrl || !avatarUrl.startsWith("http")) {
|
|
||||||
avatarUrl = await generateAvatarUrl({
|
|
||||||
domain,
|
|
||||||
id: subdomain,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const team = await Team.create(
|
const team = await Team.create(
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -60,6 +60,9 @@ class Import<T extends ImportableIntegrationService> extends ParanoidModel<
|
|||||||
@Column(DataType.INTEGER)
|
@Column(DataType.INTEGER)
|
||||||
documentCount: number;
|
documentCount: number;
|
||||||
|
|
||||||
|
@Column
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
// associations
|
// associations
|
||||||
|
|
||||||
@BelongsTo(() => Integration, "integrationId")
|
@BelongsTo(() => Integration, "integrationId")
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ class ImportTask<T extends ImportableIntegrationService> extends IdModel<
|
|||||||
@Column(DataType.JSONB)
|
@Column(DataType.JSONB)
|
||||||
output: ImportTaskOutput | null;
|
output: ImportTaskOutput | null;
|
||||||
|
|
||||||
|
@Column
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
// associations
|
// associations
|
||||||
|
|
||||||
@BelongsTo(() => Import, "importId")
|
@BelongsTo(() => Import, "importId")
|
||||||
|
|||||||
@@ -222,6 +222,13 @@ describe("NotificationHelper", () => {
|
|||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const deletedUser = await buildUser({ teamId: document.teamId });
|
||||||
|
await buildSubscription({
|
||||||
|
userId: deletedUser.id,
|
||||||
|
documentId: document.id,
|
||||||
|
});
|
||||||
|
await deletedUser.destroy();
|
||||||
|
|
||||||
const recipients =
|
const recipients =
|
||||||
await NotificationHelper.getDocumentNotificationRecipients({
|
await NotificationHelper.getDocumentNotificationRecipients({
|
||||||
document,
|
document,
|
||||||
|
|||||||
@@ -201,6 +201,7 @@ export default class NotificationHelper {
|
|||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
association: "user",
|
association: "user",
|
||||||
|
required: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export default function presentImport(
|
|||||||
service: importModel.service,
|
service: importModel.service,
|
||||||
state: importModel.state,
|
state: importModel.state,
|
||||||
documentCount: importModel.documentCount,
|
documentCount: importModel.documentCount,
|
||||||
|
error: importModel.error,
|
||||||
createdBy: presentUser(importModel.createdBy),
|
createdBy: presentUser(importModel.createdBy),
|
||||||
createdById: importModel.createdById,
|
createdById: importModel.createdById,
|
||||||
createdAt: importModel.createdAt,
|
createdAt: importModel.createdAt,
|
||||||
|
|||||||
@@ -1,44 +1,85 @@
|
|||||||
import { Op } from "sequelize";
|
|
||||||
import { GroupUser } from "@server/models";
|
import { GroupUser } from "@server/models";
|
||||||
import { DocumentGroupEvent, DocumentUserEvent, Event } from "@server/types";
|
import {
|
||||||
import DocumentSubscriptionTask from "../tasks/DocumentSubscriptionTask";
|
CollectionGroupEvent,
|
||||||
|
CollectionUserEvent,
|
||||||
|
DocumentGroupEvent,
|
||||||
|
DocumentUserEvent,
|
||||||
|
Event,
|
||||||
|
} from "@server/types";
|
||||||
|
import CollectionSubscriptionRemoveUserTask from "../tasks/CollectionSubscriptionRemoveUserTask";
|
||||||
|
import DocumentSubscriptionRemoveUserTask from "../tasks/DocumentSubscriptionRemoveUserTask";
|
||||||
import BaseProcessor from "./BaseProcessor";
|
import BaseProcessor from "./BaseProcessor";
|
||||||
|
|
||||||
|
type ReceivedEvent =
|
||||||
|
| CollectionUserEvent
|
||||||
|
| CollectionGroupEvent
|
||||||
|
| DocumentUserEvent
|
||||||
|
| DocumentGroupEvent;
|
||||||
|
|
||||||
export default class DocumentSubscriptionProcessor extends BaseProcessor {
|
export default class DocumentSubscriptionProcessor extends BaseProcessor {
|
||||||
static applicableEvents: Event["name"][] = [
|
static applicableEvents: Event["name"][] = [
|
||||||
|
"collections.remove_user",
|
||||||
|
"collections.remove_group",
|
||||||
"documents.remove_user",
|
"documents.remove_user",
|
||||||
"documents.remove_group",
|
"documents.remove_group",
|
||||||
];
|
];
|
||||||
|
|
||||||
async perform(event: DocumentUserEvent | DocumentGroupEvent) {
|
async perform(event: ReceivedEvent) {
|
||||||
switch (event.name) {
|
switch (event.name) {
|
||||||
|
case "collections.remove_user": {
|
||||||
|
await CollectionSubscriptionRemoveUserTask.schedule(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "collections.remove_group":
|
||||||
|
return this.handleRemoveGroupFromCollection(event);
|
||||||
|
|
||||||
case "documents.remove_user": {
|
case "documents.remove_user": {
|
||||||
await DocumentSubscriptionTask.schedule(event);
|
await DocumentSubscriptionRemoveUserTask.schedule(event);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "documents.remove_group":
|
case "documents.remove_group":
|
||||||
return this.handleGroup(event);
|
return this.handleRemoveGroupFromDocument(event);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleGroup(event: DocumentGroupEvent) {
|
private async handleRemoveGroupFromCollection(event: CollectionGroupEvent) {
|
||||||
await GroupUser.findAllInBatches<GroupUser>(
|
await GroupUser.findAllInBatches<GroupUser>(
|
||||||
{
|
{
|
||||||
where: {
|
where: {
|
||||||
groupId: event.modelId,
|
groupId: event.modelId,
|
||||||
userId: {
|
|
||||||
[Op.ne]: event.actorId,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
batchLimit: 10,
|
batchLimit: 10,
|
||||||
},
|
},
|
||||||
async (groupUsers) => {
|
async (groupUsers) => {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
groupUsers.map((groupUser) =>
|
groupUsers.map((groupUser) =>
|
||||||
DocumentSubscriptionTask.schedule({
|
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({
|
||||||
...event,
|
...event,
|
||||||
name: "documents.remove_user",
|
name: "documents.remove_user",
|
||||||
userId: groupUser.userId,
|
userId: groupUser.userId,
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import ExportJSONTask from "../tasks/ExportJSONTask";
|
|||||||
import ExportMarkdownZipTask from "../tasks/ExportMarkdownZipTask";
|
import ExportMarkdownZipTask from "../tasks/ExportMarkdownZipTask";
|
||||||
import ImportJSONTask from "../tasks/ImportJSONTask";
|
import ImportJSONTask from "../tasks/ImportJSONTask";
|
||||||
import ImportMarkdownZipTask from "../tasks/ImportMarkdownZipTask";
|
import ImportMarkdownZipTask from "../tasks/ImportMarkdownZipTask";
|
||||||
import ImportNotionTask from "../tasks/ImportNotionTask";
|
|
||||||
import BaseProcessor from "./BaseProcessor";
|
import BaseProcessor from "./BaseProcessor";
|
||||||
|
|
||||||
export default class FileOperationCreatedProcessor extends BaseProcessor {
|
export default class FileOperationCreatedProcessor extends BaseProcessor {
|
||||||
@@ -25,11 +24,6 @@ export default class FileOperationCreatedProcessor extends BaseProcessor {
|
|||||||
fileOperationId: event.modelId,
|
fileOperationId: event.modelId,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case FileOperationFormat.Notion:
|
|
||||||
await ImportNotionTask.schedule({
|
|
||||||
fileOperationId: event.modelId,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case FileOperationFormat.JSON:
|
case FileOperationFormat.JSON:
|
||||||
await ImportJSONTask.schedule({
|
await ImportJSONTask.schedule({
|
||||||
fileOperationId: event.modelId,
|
fileOperationId: event.modelId,
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ import chunk from "lodash/chunk";
|
|||||||
import keyBy from "lodash/keyBy";
|
import keyBy from "lodash/keyBy";
|
||||||
import truncate from "lodash/truncate";
|
import truncate from "lodash/truncate";
|
||||||
import { Fragment, Node } from "prosemirror-model";
|
import { Fragment, Node } from "prosemirror-model";
|
||||||
import { CreateOptions, CreationAttributes, Transaction } from "sequelize";
|
import {
|
||||||
|
CreateOptions,
|
||||||
|
CreationAttributes,
|
||||||
|
Transaction,
|
||||||
|
UniqueConstraintError,
|
||||||
|
} from "sequelize";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import { randomElement } from "@shared/random";
|
import { randomElement } from "@shared/random";
|
||||||
import { ImportInput, ImportTaskInput } from "@shared/schema";
|
import { ImportInput, ImportTaskInput } from "@shared/schema";
|
||||||
@@ -49,33 +54,46 @@ export default abstract class ImportsProcessor<
|
|||||||
* @param event The import event
|
* @param event The import event
|
||||||
*/
|
*/
|
||||||
public async perform(event: ImportEvent) {
|
public async perform(event: ImportEvent) {
|
||||||
await sequelize.transaction(async (transaction) => {
|
try {
|
||||||
const importModel = await Import.findByPk<Import<T>>(event.modelId, {
|
await sequelize.transaction(async (transaction) => {
|
||||||
rejectOnEmpty: true,
|
const importModel = await Import.findByPk<Import<T>>(event.modelId, {
|
||||||
paranoid: false,
|
rejectOnEmpty: true,
|
||||||
transaction,
|
paranoid: false,
|
||||||
lock: transaction.LOCK.UPDATE,
|
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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
if (
|
if (event.name !== "imports.delete" && err instanceof Error) {
|
||||||
!this.canProcess(importModel) ||
|
const importModel = await Import.findByPk<Import<T>>(event.modelId, {
|
||||||
importModel.state === ImportState.Errored ||
|
rejectOnEmpty: true,
|
||||||
importModel.state === ImportState.Canceled
|
paranoid: false,
|
||||||
) {
|
});
|
||||||
return;
|
importModel.error = truncate(err.message, { length: 255 });
|
||||||
|
await importModel.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (event.name) {
|
throw err; // throw error for retry.
|
||||||
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) {
|
public async onFailed(event: ImportEvent) {
|
||||||
@@ -141,44 +159,59 @@ export default abstract class ImportsProcessor<
|
|||||||
* @returns Promise that resolves when mapping and persistence is completed.
|
* @returns Promise that resolves when mapping and persistence is completed.
|
||||||
*/
|
*/
|
||||||
private async onProcessed(importModel: Import<T>, transaction: Transaction) {
|
private async onProcessed(importModel: Import<T>, transaction: Transaction) {
|
||||||
const { collections } = await this.createCollectionsAndDocuments({
|
try {
|
||||||
importModel,
|
const { collections } = await this.createCollectionsAndDocuments({
|
||||||
transaction,
|
importModel,
|
||||||
});
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
await importModel.saveWithCtx(
|
|
||||||
createContext({
|
|
||||||
user: importModel.createdBy,
|
|
||||||
transaction,
|
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,
|
||||||
|
transaction,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof UniqueConstraintError) {
|
||||||
|
Logger.error(
|
||||||
|
"ImportsProcessor persistence failed due to unique constraint error",
|
||||||
|
err,
|
||||||
|
{
|
||||||
|
fields: err.fields,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -290,6 +323,15 @@ export default abstract class ImportsProcessor<
|
|||||||
|
|
||||||
const output = outputMap[externalId];
|
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 collectionItem = importInput[externalId];
|
||||||
|
|
||||||
const attachments = await Attachment.findAll({
|
const attachments = await Attachment.findAll({
|
||||||
@@ -430,7 +472,7 @@ export default abstract class ImportsProcessor<
|
|||||||
importInput: Record<string, ImportInput<any>[number]>;
|
importInput: Record<string, ImportInput<any>[number]>;
|
||||||
actorId: string;
|
actorId: string;
|
||||||
}): ProsemirrorDoc {
|
}): ProsemirrorDoc {
|
||||||
// special case when the doc content is empty
|
// special case when the doc content is empty.
|
||||||
if (!content.content.length) {
|
if (!content.content.length) {
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { JobOptions } from "bull";
|
import { JobOptions } from "bull";
|
||||||
import chunk from "lodash/chunk";
|
import chunk from "lodash/chunk";
|
||||||
|
import truncate from "lodash/truncate";
|
||||||
import uniqBy from "lodash/uniqBy";
|
import uniqBy from "lodash/uniqBy";
|
||||||
import { Fragment, Node } from "prosemirror-model";
|
import { Fragment, Node } from "prosemirror-model";
|
||||||
import { Transaction, WhereOptions } from "sequelize";
|
import { Transaction, WhereOptions } from "sequelize";
|
||||||
@@ -63,20 +64,29 @@ export default abstract class APIImportTask<
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (importTask.state) {
|
try {
|
||||||
case ImportTaskState.Created: {
|
switch (importTask.state) {
|
||||||
importTask.state = ImportTaskState.InProgress;
|
case ImportTaskState.Created: {
|
||||||
importTask = await importTask.save();
|
importTask.state = ImportTaskState.InProgress;
|
||||||
return await this.onProcess(importTask);
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
case ImportTaskState.InProgress:
|
throw err; // throw error for retry.
|
||||||
return await this.onProcess(importTask);
|
|
||||||
|
|
||||||
case ImportTaskState.Completed:
|
|
||||||
return await this.onCompletion(importTask);
|
|
||||||
|
|
||||||
default:
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +118,7 @@ export default abstract class APIImportTask<
|
|||||||
await importTask.save({ transaction });
|
await importTask.save({ transaction });
|
||||||
|
|
||||||
const associatedImport = importTask.import;
|
const associatedImport = importTask.import;
|
||||||
|
associatedImport.error = importTask.error; // copy error from ImportTask that caused the failure.
|
||||||
associatedImport.state = ImportState.Errored;
|
associatedImport.state = ImportState.Errored;
|
||||||
await associatedImport.saveWithCtx(
|
await associatedImport.saveWithCtx(
|
||||||
createContext({
|
createContext({
|
||||||
@@ -155,10 +166,11 @@ export default abstract class APIImportTask<
|
|||||||
|
|
||||||
importTask.output = taskOutputWithReplacements;
|
importTask.output = taskOutputWithReplacements;
|
||||||
importTask.state = ImportTaskState.Completed;
|
importTask.state = ImportTaskState.Completed;
|
||||||
|
importTask.error = null; // unset any error from previous attempts.
|
||||||
await importTask.save({ transaction });
|
await importTask.save({ transaction });
|
||||||
|
|
||||||
const associatedImport = importTask.import;
|
const associatedImport = importTask.import;
|
||||||
associatedImport.documentCount += importTask.input.length;
|
associatedImport.documentCount += taskOutputWithReplacements.length;
|
||||||
await associatedImport.saveWithCtx(
|
await associatedImport.saveWithCtx(
|
||||||
createContext({
|
createContext({
|
||||||
user: associatedImport.createdBy,
|
user: associatedImport.createdBy,
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
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,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -8,11 +8,11 @@ import { sequelize } from "@server/storage/database";
|
|||||||
import { DocumentUserEvent } from "@server/types";
|
import { DocumentUserEvent } from "@server/types";
|
||||||
import BaseTask from "./BaseTask";
|
import BaseTask from "./BaseTask";
|
||||||
|
|
||||||
export default class DocumentSubscriptionTask extends BaseTask<DocumentUserEvent> {
|
export default class DocumentSubscriptionRemoveUserTask extends BaseTask<DocumentUserEvent> {
|
||||||
public async perform(event: DocumentUserEvent) {
|
public async perform(event: DocumentUserEvent) {
|
||||||
const user = await User.findByPk(event.userId);
|
const user = await User.findByPk(event.userId);
|
||||||
|
|
||||||
if (!user || event.name !== "documents.remove_user") {
|
if (!user) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,11 +56,13 @@ export default class ErrorTimedOutImportsTask extends BaseTask<Props> {
|
|||||||
|
|
||||||
await sequelize.transaction(async (transaction) => {
|
await sequelize.transaction(async (transaction) => {
|
||||||
importTask.state = ImportTaskState.Errored;
|
importTask.state = ImportTaskState.Errored;
|
||||||
|
importTask.error = "Timed out";
|
||||||
await importTask.save({ transaction });
|
await importTask.save({ transaction });
|
||||||
|
|
||||||
// this import could have been seen before in another import_task.
|
// this import could have been seen before in another import_task.
|
||||||
if (!importsErrored[associatedImport.id]) {
|
if (!importsErrored[associatedImport.id]) {
|
||||||
associatedImport.state = ImportState.Errored;
|
associatedImport.state = ImportState.Errored;
|
||||||
|
associatedImport.error = "Timed out";
|
||||||
await associatedImport.save({ transaction });
|
await associatedImport.save({ transaction });
|
||||||
importsErrored[associatedImport.id] = true;
|
importsErrored[associatedImport.id] = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
/* 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
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,354 +0,0 @@
|
|||||||
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,6 +597,7 @@ router.post(
|
|||||||
createdById: user.id,
|
createdById: user.id,
|
||||||
},
|
},
|
||||||
transaction,
|
transaction,
|
||||||
|
hooks: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import queryString from "query-string";
|
|||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import { randomElement } from "@shared/random";
|
import { randomElement } from "@shared/random";
|
||||||
import { NotificationEventType } from "@shared/types";
|
import { NotificationEventType } from "@shared/types";
|
||||||
|
import NotificationSettingsHelper from "@server/models/helpers/NotificationSettingsHelper";
|
||||||
import {
|
import {
|
||||||
buildCollection,
|
buildCollection,
|
||||||
buildDocument,
|
buildDocument,
|
||||||
@@ -697,3 +698,40 @@ describe("#notifications.update_all", () => {
|
|||||||
expect(body.data.total).toBe(2);
|
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);
|
user.setNotificationEventType(eventType, false);
|
||||||
await user.save();
|
await user.save({ transaction });
|
||||||
ctx.redirect(`${user.team.url}/settings/notifications?success`);
|
ctx.redirect(`${user.team.url}/settings/notifications?success`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -1,9 +0,0 @@
|
|||||||
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");
|
|
||||||
});
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -10,6 +10,10 @@ type Props = {
|
|||||||
captureEvents?: "all" | "pointer" | "click";
|
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> = ({
|
const EventBoundary: React.FC<Props> = ({
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
|
|||||||
@@ -5,14 +5,26 @@ type JustifyValues = CSSProperties["justifyContent"];
|
|||||||
|
|
||||||
type AlignValues = CSSProperties["alignItems"];
|
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<{
|
const Flex = styled.div<{
|
||||||
|
/** Makes the component grow to fill available space */
|
||||||
auto?: boolean;
|
auto?: boolean;
|
||||||
|
/** Changes flex direction to column */
|
||||||
column?: boolean;
|
column?: boolean;
|
||||||
|
/** Sets the align-items CSS property */
|
||||||
align?: AlignValues;
|
align?: AlignValues;
|
||||||
|
/** Sets the justify-content CSS property */
|
||||||
justify?: JustifyValues;
|
justify?: JustifyValues;
|
||||||
|
/** Enables flex-wrap */
|
||||||
wrap?: boolean;
|
wrap?: boolean;
|
||||||
|
/** Controls flex-shrink behavior */
|
||||||
shrink?: boolean;
|
shrink?: boolean;
|
||||||
|
/** Reverses the direction (row-reverse or column-reverse) */
|
||||||
reverse?: boolean;
|
reverse?: boolean;
|
||||||
|
/** Sets gap between flex items in pixels */
|
||||||
gap?: number;
|
gap?: number;
|
||||||
}>`
|
}>`
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ type Props = {
|
|||||||
className?: string;
|
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> = ({
|
const Squircle: React.FC<Props> = ({
|
||||||
color,
|
color,
|
||||||
size = 28,
|
size = 28,
|
||||||
|
|||||||
@@ -313,6 +313,10 @@ width: 100%;
|
|||||||
background: ${props.theme.mentionHoverBackground};
|
background: ${props.theme.mentionHoverBackground};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&[data-type="user"] {
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
&.mention-user::before {
|
&.mention-user::before {
|
||||||
content: "@";
|
content: "@";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,9 +28,10 @@ export function getCellAttrs(dom: HTMLElement | string): Attrs {
|
|||||||
const widthAttr = dom.getAttribute("data-colwidth");
|
const widthAttr = dom.getAttribute("data-colwidth");
|
||||||
const widths =
|
const widths =
|
||||||
widthAttr && /^\d+(,\d+)*$/.test(widthAttr)
|
widthAttr && /^\d+(,\d+)*$/.test(widthAttr)
|
||||||
? widthAttr.split(",").map((s) => Number(s))
|
? widthAttr.split(",").map(Number)
|
||||||
: null;
|
: null;
|
||||||
const colspan = Number(dom.getAttribute("colspan") || 1);
|
const colspan = Number(dom.getAttribute("colspan") || 1);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
colspan,
|
colspan,
|
||||||
rowspan: Number(dom.getAttribute("rowspan") || 1),
|
rowspan: Number(dom.getAttribute("rowspan") || 1),
|
||||||
@@ -63,10 +64,11 @@ export function setCellAttrs(node: Node): Attrs {
|
|||||||
}
|
}
|
||||||
if (node.attrs.colwidth) {
|
if (node.attrs.colwidth) {
|
||||||
if (isBrowser) {
|
if (isBrowser) {
|
||||||
attrs["data-colwidth"] = node.attrs.colwidth.join(",");
|
attrs["data-colwidth"] = node.attrs.colwidth.map(parseInt).join(",");
|
||||||
} else {
|
} else {
|
||||||
attrs.style =
|
attrs.style =
|
||||||
(attrs.style ?? "") + `min-width: ${node.attrs.colwidth}px;`;
|
(attrs.style ?? "") +
|
||||||
|
`min-width: ${parseInt(node.attrs.colwidth[0])}px;`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,9 +21,10 @@ export default class Code extends Mark {
|
|||||||
|
|
||||||
get schema(): MarkSpec {
|
get schema(): MarkSpec {
|
||||||
return {
|
return {
|
||||||
excludes: "mention placeholder highlight em strong",
|
excludes: "mention placeholder highlight",
|
||||||
parseDOM: [{ tag: "code", preserveWhitespace: true }],
|
parseDOM: [{ tag: "code", preserveWhitespace: true }],
|
||||||
toDOM: () => ["code", { class: "inline", spellCheck: "false" }],
|
toDOM: () => ["code", { class: "inline", spellCheck: "false" }],
|
||||||
|
code: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ export default class Mention extends Node {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get schema(): NodeSpec {
|
get schema(): NodeSpec {
|
||||||
|
const toPlainText = (node: ProsemirrorNode) =>
|
||||||
|
node.attrs.type === MentionType.User
|
||||||
|
? `@${node.attrs.label}`
|
||||||
|
: node.attrs.label;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
attrs: {
|
attrs: {
|
||||||
type: {
|
type: {
|
||||||
@@ -88,12 +93,9 @@ export default class Mention extends Node {
|
|||||||
"data-actorid": node.attrs.actorId,
|
"data-actorid": node.attrs.actorId,
|
||||||
"data-url": `mention://${node.attrs.id}/${node.attrs.type}/${node.attrs.modelId}`,
|
"data-url": `mention://${node.attrs.id}/${node.attrs.type}/${node.attrs.modelId}`,
|
||||||
},
|
},
|
||||||
String(node.attrs.label),
|
toPlainText(node),
|
||||||
],
|
],
|
||||||
toPlainText: (node) =>
|
toPlainText,
|
||||||
node.attrs.type === MentionType.User
|
|
||||||
? `@${node.attrs.label}`
|
|
||||||
: node.attrs.label,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ type Options = {
|
|||||||
onlyBlock?: boolean;
|
onlyBlock?: boolean;
|
||||||
/** Only check if the selection is inside a code mark. */
|
/** Only check if the selection is inside a code mark. */
|
||||||
onlyMark?: boolean;
|
onlyMark?: boolean;
|
||||||
|
/** If true then code must contain entire selection */
|
||||||
|
inclusive?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,17 +22,29 @@ export function isInCode(state: EditorState, options?: Options): boolean {
|
|||||||
const { nodes, marks } = state.schema;
|
const { nodes, marks } = state.schema;
|
||||||
|
|
||||||
if (!options?.onlyMark) {
|
if (!options?.onlyMark) {
|
||||||
if (nodes.code_block && isNodeActive(nodes.code_block)(state)) {
|
if (
|
||||||
|
nodes.code_block &&
|
||||||
|
isNodeActive(nodes.code_block, undefined, {
|
||||||
|
inclusive: options?.inclusive,
|
||||||
|
})(state)
|
||||||
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (nodes.code_fence && isNodeActive(nodes.code_fence)(state)) {
|
if (
|
||||||
|
nodes.code_fence &&
|
||||||
|
isNodeActive(nodes.code_fence, undefined, {
|
||||||
|
inclusive: options?.inclusive,
|
||||||
|
})(state)
|
||||||
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!options?.onlyBlock) {
|
if (!options?.onlyBlock) {
|
||||||
if (marks.code_inline) {
|
if (marks.code_inline) {
|
||||||
return isMarkActive(marks.code_inline)(state);
|
return isMarkActive(marks.code_inline, undefined, {
|
||||||
|
inclusive: options?.inclusive,
|
||||||
|
})(state);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { getMarksBetween } from "./getMarksBetween";
|
|||||||
type Options = {
|
type Options = {
|
||||||
/** Only return match if the range and attrs is exact */
|
/** Only return match if the range and attrs is exact */
|
||||||
exact?: boolean;
|
exact?: boolean;
|
||||||
|
/** If true then mark must contain entire selection */
|
||||||
|
inclusive?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,7 +42,8 @@ export const isMarkActive =
|
|||||||
Object.keys(attrs).every(
|
Object.keys(attrs).every(
|
||||||
(key) => mark.attrs[key] === attrs[key]
|
(key) => mark.attrs[key] === attrs[key]
|
||||||
)) &&
|
)) &&
|
||||||
(!options?.exact || (start === from && end === to))
|
(!options?.exact || (start === from && end === to)) &&
|
||||||
|
(!options?.inclusive || (start <= from && end >= to))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,31 +3,55 @@ import { EditorState } from "prosemirror-state";
|
|||||||
import { Primitive } from "utility-types";
|
import { Primitive } from "utility-types";
|
||||||
import { findParentNode } from "./findParentNode";
|
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.
|
* Checks if a node is active in the current selection or not.
|
||||||
*
|
*
|
||||||
* @param type The node type to check.
|
* @param type The node type to check.
|
||||||
* @param attrs The attributes 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.
|
* @returns A function that checks if a node is active in the current selection or not.
|
||||||
*/
|
*/
|
||||||
export const isNodeActive =
|
export const isNodeActive =
|
||||||
(type: NodeType, attrs: Record<string, Primitive> = {}) =>
|
(type: NodeType, attrs?: Record<string, Primitive>, options?: Options) =>
|
||||||
(state: EditorState) => {
|
(state: EditorState): boolean => {
|
||||||
if (!type) {
|
if (!type) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodeAfter = state.selection.$from.nodeAfter;
|
const { from, to } = state.selection;
|
||||||
let node = nodeAfter?.type === type ? nodeAfter : undefined;
|
const nodeWithPos = findParentNode(
|
||||||
|
(node) =>
|
||||||
|
node.type === type &&
|
||||||
|
(!attrs ||
|
||||||
|
Object.keys(attrs).every((key) => node.attrs[key] === attrs[key]))
|
||||||
|
)(state.selection);
|
||||||
|
|
||||||
if (!node) {
|
if (!nodeWithPos) {
|
||||||
const parent = findParentNode((n) => n.type === type)(state.selection);
|
return false;
|
||||||
node = parent?.node;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Object.keys(attrs).length || !node) {
|
if (options?.inclusive) {
|
||||||
return !!node;
|
// Check if the node's position contains the entire selection
|
||||||
|
return (
|
||||||
|
nodeWithPos.pos <= from &&
|
||||||
|
nodeWithPos.pos + nodeWithPos.node.nodeSize >= to
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return node.hasMarkup(type, { ...node.attrs, ...attrs });
|
if (options?.exact) {
|
||||||
|
// Check if node's range exactly matches selection
|
||||||
|
return (
|
||||||
|
nodeWithPos.pos === from &&
|
||||||
|
nodeWithPos.pos + nodeWithPos.node.nodeSize === to
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,16 +1,5 @@
|
|||||||
import { useState, useLayoutEffect } from "react";
|
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.
|
* A hook that returns the size of an element or ref.
|
||||||
*
|
*
|
||||||
@@ -19,19 +8,11 @@ const defaultRect = {
|
|||||||
*/
|
*/
|
||||||
export function useComponentSize(
|
export function useComponentSize(
|
||||||
input: HTMLElement | null | React.RefObject<HTMLElement | null>
|
input: HTMLElement | null | React.RefObject<HTMLElement | null>
|
||||||
): DOMRect | typeof defaultRect {
|
) {
|
||||||
const element = input instanceof HTMLElement ? input : input?.current;
|
const element = input instanceof HTMLElement ? input : input?.current;
|
||||||
const [size, setSize] = useState(() => element?.getBoundingClientRect());
|
const [size, setSize] = useState<DOMRect | undefined>(
|
||||||
|
() => element?.getBoundingClientRect() || new DOMRect()
|
||||||
useLayoutEffect(() => {
|
);
|
||||||
const sizeObserver = new ResizeObserver(() => {
|
|
||||||
element?.dispatchEvent(new CustomEvent("resize"));
|
|
||||||
});
|
|
||||||
if (element) {
|
|
||||||
sizeObserver.observe(element);
|
|
||||||
}
|
|
||||||
return () => sizeObserver.disconnect();
|
|
||||||
}, [element]);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const handleResize = () => {
|
const handleResize = () => {
|
||||||
@@ -55,6 +36,7 @@ export function useComponentSize(
|
|||||||
window.addEventListener("click", handleResize);
|
window.addEventListener("click", handleResize);
|
||||||
window.addEventListener("resize", handleResize);
|
window.addEventListener("resize", handleResize);
|
||||||
element?.addEventListener("resize", handleResize);
|
element?.addEventListener("resize", handleResize);
|
||||||
|
handleResize();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("click", handleResize);
|
window.removeEventListener("click", handleResize);
|
||||||
@@ -63,5 +45,15 @@ export function useComponentSize(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return size ?? defaultRect;
|
useLayoutEffect(() => {
|
||||||
|
const sizeObserver = new ResizeObserver(() => {
|
||||||
|
element?.dispatchEvent(new CustomEvent("resize"));
|
||||||
|
});
|
||||||
|
if (element) {
|
||||||
|
sizeObserver.observe(element);
|
||||||
|
}
|
||||||
|
return () => sizeObserver.disconnect();
|
||||||
|
}, [element]);
|
||||||
|
|
||||||
|
return size ?? new DOMRect();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -903,9 +903,6 @@
|
|||||||
"{{ count }} document imported_plural": "{{ count }} documents imported",
|
"{{ 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>.",
|
"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",
|
"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",
|
"Last active": "Last active",
|
||||||
"Guest": "Guest",
|
"Guest": "Guest",
|
||||||
"Shared by": "Shared by",
|
"Shared by": "Shared by",
|
||||||
|
|||||||
Reference in New Issue
Block a user