mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
74 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 26be6dcf98 | |||
| 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 | |||
| 3ffe7e7671 | |||
| a7fe6c9af3 | |||
| 52c673261b | |||
| 60c0a53a1f | |||
| 66fae19034 | |||
| 37ea6bb92b | |||
| 762816adbc | |||
| d1b24b15d5 | |||
| 877b7ad0df | |||
| e98d931aaa | |||
| ba7d102a72 | |||
| ab1f00e919 | |||
| 34cb31ff43 | |||
| aac95c2b2e | |||
| 0dd6ef5196 | |||
| 5cd11002d1 | |||
| 5334f7ae08 | |||
| df1de2b822 | |||
| deb93ef767 | |||
| 5bef4c4b55 | |||
| 72bff1ec8a | |||
| c12b257098 | |||
| f6da244c33 | |||
| ab55e0bed9 | |||
| 84ae9a2c31 | |||
| 5c4eb32c26 | |||
| 10b8f11e0b | |||
| 0a4c3bd633 | |||
| 580cf52fd3 | |||
| ee1fd65a19 | |||
| 323c5f5978 | |||
| bdb34a202c | |||
| 40278b2d9a | |||
| a69ef1f3c9 | |||
| 6e98568e5b | |||
| 8b65ad3cfa | |||
| 533a14369c | |||
| 0ec6440506 | |||
| 6fde025ce4 | |||
| 18bbe6ecf6 | |||
| a48f6c7a85 | |||
| ec9f45f310 | |||
| dd053c4152 | |||
| 5565034486 | |||
| 42cfac97aa | |||
| f369c2f8bf | |||
| 08f91aa60c | |||
| 0fe50c179c |
+5
-5
@@ -147,6 +147,10 @@ DISCORD_SERVER_ID=
|
||||
# DISCORD_SERVER_ID and DISCORD_SERVER_ROLES must be set together.
|
||||
DISCORD_SERVER_ROLES=
|
||||
|
||||
# –––––––––––––– IMPORTS ––––––––––––––
|
||||
NOTION_CLIENT_ID=
|
||||
NOTION_CLIENT_SECRET=
|
||||
|
||||
# –––––––––––––––– OPTIONAL ––––––––––––––––
|
||||
|
||||
# Base64 encoded private key and certificate for HTTPS termination. This is only
|
||||
@@ -201,14 +205,10 @@ SENTRY_TUNNEL=
|
||||
|
||||
# To support sending outgoing transactional emails such as "document updated" or
|
||||
# "you've been invited" you'll need to provide authentication for an SMTP server
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=
|
||||
SMTP_SERVICE=
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM_EMAIL=
|
||||
SMTP_REPLY_EMAIL=
|
||||
SMTP_TLS_CIPHERS=
|
||||
SMTP_SECURE=true
|
||||
|
||||
# The default interface language. See translate.getoutline.com for a list of
|
||||
# available language codes and their rough percentage translated.
|
||||
|
||||
@@ -15,6 +15,8 @@ requestInfoDefaultTitles:
|
||||
|
||||
requestInfoLabelToAdd: more information needed
|
||||
|
||||
requestInfoUserstoExclude:
|
||||
- tommoor
|
||||
|
||||
# Configuration for new-pr-welcome - https://github.com/behaviorbot/new-pr-welcome
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
name: Lint
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
run-linters:
|
||||
if: startsWith(github.actor, 'codegen-sh')
|
||||
name: Run linters
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
# Give the default GITHUB_TOKEN write permission to commit and push the
|
||||
# added or changed files to the repository.
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: 'yarn'
|
||||
- run: yarn install --frozen-lockfile
|
||||
- run: yarn lint --fix
|
||||
|
||||
- name: Commit changes
|
||||
uses: stefanzweifel/git-auto-commit-action@v5
|
||||
with:
|
||||
commit_message: 'Applied automatic fixes'
|
||||
+1
-2
@@ -14,8 +14,7 @@
|
||||
"setupFilesAfterEnv": ["<rootDir>/server/test/setup.ts"],
|
||||
"globalSetup": "<rootDir>/server/test/globalSetup.js",
|
||||
"globalTeardown": "<rootDir>/server/test/globalTeardown.js",
|
||||
"testEnvironment": "node",
|
||||
"testTimeout": 10000
|
||||
"testEnvironment": "node"
|
||||
},
|
||||
{
|
||||
"displayName": "app",
|
||||
|
||||
@@ -171,6 +171,10 @@
|
||||
"description": "smtp.example.com (optional)",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_SERVICE": {
|
||||
"description": "Well-known SMTP service name for nodemailer (optional, e.g. 'gmail', 'SES')",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_PORT": {
|
||||
"description": "1234 (optional)",
|
||||
"required": false
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { observer } from "mobx-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 ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
|
||||
import Layout from "~/components/Layout";
|
||||
@@ -10,6 +16,7 @@ import Sidebar from "~/components/Sidebar";
|
||||
import SidebarRight from "~/components/Sidebar/Right";
|
||||
import SettingsSidebar from "~/components/Sidebar/Settings";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import history from "~/utils/history";
|
||||
@@ -48,6 +55,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
const can = usePolicy(ui.activeDocumentId);
|
||||
const canCollection = usePolicy(ui.activeCollectionId);
|
||||
const team = useCurrentTeam();
|
||||
const [spendPostLoginPath] = usePostLoginPath();
|
||||
|
||||
const goToSearch = (ev: KeyboardEvent) => {
|
||||
if (!ev.metaKey && !ev.ctrlKey) {
|
||||
@@ -72,6 +80,11 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
return <ErrorSuspended />;
|
||||
}
|
||||
|
||||
const postLoginPath = spendPostLoginPath();
|
||||
if (postLoginPath) {
|
||||
return <Redirect to={postLoginPath} />;
|
||||
}
|
||||
|
||||
const sidebar = (
|
||||
<Fade>
|
||||
<Switch>
|
||||
|
||||
@@ -7,17 +7,43 @@ import User from "~/models/User";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import Avatar, { AvatarSize } from "./Avatar";
|
||||
|
||||
/**
|
||||
* Props for the AvatarWithPresence component
|
||||
*/
|
||||
type Props = {
|
||||
/** The user to display the avatar for */
|
||||
user: User;
|
||||
/** Whether the user is currently present in the document */
|
||||
isPresent: boolean;
|
||||
/** Whether the user is currently editing the document */
|
||||
isEditing: boolean;
|
||||
/** Whether the user is currently observing the document */
|
||||
isObserving: boolean;
|
||||
/** Whether this avatar represents the current user */
|
||||
isCurrentUser: boolean;
|
||||
/** Optional click handler for the avatar */
|
||||
onClick?: React.MouseEventHandler<HTMLImageElement>;
|
||||
/** Size of the avatar, defaults to AvatarSize.Large */
|
||||
size?: AvatarSize;
|
||||
/** Optional inline styles to apply to the avatar wrapper */
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
/**
|
||||
* AvatarWithPresence component displays a user's avatar with visual indicators
|
||||
* for their current status (present, editing, observing).
|
||||
*
|
||||
* The component shows different visual states:
|
||||
* - Present users have full opacity
|
||||
* - Non-present users have reduced opacity
|
||||
* - Observing users have a colored border matching their user color
|
||||
* - Hovering shows a colored border
|
||||
*
|
||||
* A tooltip displays the user's name and current status.
|
||||
*
|
||||
* @param props - Component properties
|
||||
* @returns React component
|
||||
*/
|
||||
function AvatarWithPresence({
|
||||
onClick,
|
||||
user,
|
||||
@@ -64,16 +90,33 @@ function AvatarWithPresence({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Centered container for tooltip content
|
||||
*/
|
||||
const Centered = styled.div`
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
/**
|
||||
* Props for the AvatarPresence styled component
|
||||
*/
|
||||
type AvatarWrapperProps = {
|
||||
/** Whether the user is currently present */
|
||||
$isPresent: boolean;
|
||||
/** Whether the user is currently observing */
|
||||
$isObserving: boolean;
|
||||
/** The user's color for border highlighting */
|
||||
$color: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Styled component that wraps the Avatar and provides visual indicators
|
||||
* for the user's presence status.
|
||||
*
|
||||
* - Adjusts opacity based on presence
|
||||
* - Adds colored borders for observing users
|
||||
* - Handles hover effects
|
||||
*/
|
||||
const AvatarPresence = styled.div<AvatarWrapperProps>`
|
||||
opacity: ${(props) => (props.$isPresent ? 1 : 0.5)};
|
||||
transition: opacity 250ms ease-in-out;
|
||||
|
||||
@@ -49,7 +49,7 @@ function Collaborators(props: Props) {
|
||||
() =>
|
||||
orderBy(
|
||||
filter(
|
||||
users.orderedData,
|
||||
users.all,
|
||||
(u) =>
|
||||
(presentIds.includes(u.id) ||
|
||||
document.collaboratorIds.includes(u.id)) &&
|
||||
@@ -58,7 +58,7 @@ function Collaborators(props: Props) {
|
||||
[(u) => presentIds.includes(u.id), "id"],
|
||||
["asc", "asc"]
|
||||
),
|
||||
[document.collaboratorIds, users.orderedData, presentIds]
|
||||
[document.collaboratorIds, users.all, presentIds]
|
||||
);
|
||||
|
||||
// load any users we don't yet have in memory
|
||||
|
||||
@@ -4,6 +4,12 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import {
|
||||
AuthenticationFailed,
|
||||
AuthorizationFailed,
|
||||
DocumentTooLarge,
|
||||
TooManyConnections,
|
||||
} from "@shared/collaboration/CloseEvents";
|
||||
import Fade from "~/components/Fade";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
@@ -14,21 +20,21 @@ function ConnectionStatus() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const codeToMessage = {
|
||||
1009: {
|
||||
[DocumentTooLarge.code]: {
|
||||
title: t("Document is too large"),
|
||||
body: t(
|
||||
"This document has reached the maximum size and can no longer be edited"
|
||||
),
|
||||
},
|
||||
4401: {
|
||||
[AuthenticationFailed.code]: {
|
||||
title: t("Authentication failed"),
|
||||
body: t("Please try logging out and back in again"),
|
||||
},
|
||||
4403: {
|
||||
[AuthorizationFailed.code]: {
|
||||
title: t("Authorization failed"),
|
||||
body: t("You may have lost access to this document, try reloading"),
|
||||
},
|
||||
4503: {
|
||||
[TooManyConnections.code]: {
|
||||
title: t("Too many users connected to document"),
|
||||
body: t("Your edits will sync once other users leave the document"),
|
||||
},
|
||||
|
||||
@@ -23,7 +23,10 @@ function Dialogs() {
|
||||
key={id}
|
||||
isOpen={modal.isOpen}
|
||||
fullscreen={modal.fullscreen ?? false}
|
||||
onRequestClose={() => dialogs.closeModal(id)}
|
||||
onRequestClose={() => {
|
||||
modal.onClose?.();
|
||||
dialogs.closeModal(id);
|
||||
}}
|
||||
title={modal.title}
|
||||
style={modal.style}
|
||||
>
|
||||
|
||||
@@ -205,7 +205,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
<div className="ProseMirror">
|
||||
{paragraphs.map((paragraph, index) => (
|
||||
<p key={index} dir="auto">
|
||||
{paragraph.content.map((content) => content.text)}
|
||||
{paragraph.content?.map((content) => content.text)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,11 @@ import React from "react";
|
||||
import styled from "styled-components";
|
||||
import { fadeIn } from "~/styles/animations";
|
||||
|
||||
/**
|
||||
* Fade in animation for a component.
|
||||
*
|
||||
* @param timing - The duration of the fade in animation, default is 250ms.
|
||||
*/
|
||||
const Fade = styled.span<{ timing?: number | string }>`
|
||||
animation: ${fadeIn} ${(props) => props.timing || "250ms"} ease-in-out;
|
||||
`;
|
||||
@@ -17,7 +22,6 @@ type Props = {
|
||||
*/
|
||||
export const ConditionalFade = ({ animate, children }: Props) => {
|
||||
const [isAnimated] = React.useState(animate);
|
||||
|
||||
return isAnimated ? <Fade>{children}</Fade> : <>{children}</>;
|
||||
};
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ export const Suggestions = observer(
|
||||
? users.notInDocument(document.id, query)
|
||||
: collection
|
||||
? users.notInCollection(collection.id, query)
|
||||
: users.orderedData
|
||||
: users.activeOrInvited
|
||||
).filter((u) => !u.isSuspended && u.id !== user.id);
|
||||
|
||||
if (isEmail(query)) {
|
||||
@@ -114,7 +114,7 @@ export const Suggestions = observer(
|
||||
}, [
|
||||
getSuggestionForEmail,
|
||||
users,
|
||||
users.orderedData,
|
||||
users.activeOrInvited,
|
||||
groups,
|
||||
groups.orderedData,
|
||||
document?.id,
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import invariant from "invariant";
|
||||
import find from "lodash/find";
|
||||
import isObject from "lodash/isObject";
|
||||
import { action, observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { withTranslation, WithTranslation } from "react-i18next";
|
||||
import semver from "semver";
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import { toast } from "sonner";
|
||||
import EDITOR_VERSION from "@shared/editor/version";
|
||||
import { FileOperationState, FileOperationType } from "@shared/types";
|
||||
import {
|
||||
FileOperationState,
|
||||
FileOperationType,
|
||||
ImportState,
|
||||
} from "@shared/types";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import Collection from "~/models/Collection";
|
||||
import Comment from "~/models/Comment";
|
||||
@@ -18,6 +19,7 @@ import FileOperation from "~/models/FileOperation";
|
||||
import Group from "~/models/Group";
|
||||
import GroupMembership from "~/models/GroupMembership";
|
||||
import GroupUser from "~/models/GroupUser";
|
||||
import Import from "~/models/Import";
|
||||
import Membership from "~/models/Membership";
|
||||
import Notification from "~/models/Notification";
|
||||
import Pin from "~/models/Pin";
|
||||
@@ -103,6 +105,7 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
subscriptions,
|
||||
fileOperations,
|
||||
notifications,
|
||||
imports,
|
||||
} = this.props;
|
||||
|
||||
const currentUserId = auth?.user?.id;
|
||||
@@ -117,23 +120,10 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on("authenticated", (data) => {
|
||||
this.socket.on("authenticated", () => {
|
||||
if (this.socket) {
|
||||
this.socket.authenticated = true;
|
||||
}
|
||||
if (isObject(data) && "editorVersion" in data) {
|
||||
const parsedClientVersion = semver.parse(EDITOR_VERSION);
|
||||
const parsedCurrentVersion = semver.parse(String(data.editorVersion));
|
||||
|
||||
if (
|
||||
parsedClientVersion &&
|
||||
parsedCurrentVersion &&
|
||||
(parsedClientVersion.major < parsedCurrentVersion.major ||
|
||||
parsedClientVersion.minor < parsedCurrentVersion.minor)
|
||||
) {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on("unauthorized", (err: Error) => {
|
||||
@@ -636,6 +626,23 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on("imports.create", (event: PartialExcept<Import, "id">) => {
|
||||
imports.add(event);
|
||||
});
|
||||
|
||||
this.socket.on("imports.update", (event: PartialExcept<Import, "id">) => {
|
||||
imports.add(event);
|
||||
|
||||
if (
|
||||
event.state === ImportState.Completed &&
|
||||
event.createdBy?.id === auth.user?.id
|
||||
) {
|
||||
toast.success(event.name, {
|
||||
description: this.props.t("Your import completed"),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on(
|
||||
"subscriptions.create",
|
||||
(event: PartialExcept<Subscription, "id">) => {
|
||||
|
||||
@@ -645,12 +645,11 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
"section" in item ? item.section?.({ t }) : undefined;
|
||||
|
||||
const response = (
|
||||
<>
|
||||
<React.Fragment key={`${index}-${item.name}`}>
|
||||
{currentHeading !== previousHeading && (
|
||||
<Header key={currentHeading}>{currentHeading}</Header>
|
||||
)}
|
||||
<ListItem
|
||||
key={index}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerDown={handlePointerDown}
|
||||
>
|
||||
@@ -659,7 +658,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
onClick: () => handleClickItem(item),
|
||||
})}
|
||||
</ListItem>
|
||||
</>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
previousHeading = currentHeading;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled from "styled-components";
|
||||
@@ -68,12 +69,16 @@ function SuggestionsMenuItem({
|
||||
|
||||
const Subtitle = styled.span<{ $active?: boolean }>`
|
||||
color: ${(props) =>
|
||||
props.$active ? props.theme.white50 : props.theme.textTertiary};
|
||||
props.$active
|
||||
? transparentize(0.35, props.theme.accentText)
|
||||
: props.theme.textTertiary};
|
||||
`;
|
||||
|
||||
const Shortcut = styled.span<{ $active?: boolean }>`
|
||||
color: ${(props) =>
|
||||
props.$active ? props.theme.white50 : props.theme.textTertiary};
|
||||
props.$active
|
||||
? transparentize(0.35, props.theme.accentText)
|
||||
: props.theme.textTertiary};
|
||||
flex-grow: 1;
|
||||
text-align: right;
|
||||
`;
|
||||
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
import { Decoration, DecorationSet } from "prosemirror-view";
|
||||
import * as React from "react";
|
||||
import { v4 } from "uuid";
|
||||
import { LANGUAGES } from "@shared/editor/extensions/Prism";
|
||||
import Extension, { WidgetProps } from "@shared/editor/lib/Extension";
|
||||
import { codeLanguages } from "@shared/editor/lib/code";
|
||||
import isMarkdown from "@shared/editor/lib/isMarkdown";
|
||||
import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize";
|
||||
import { isRemoteTransaction } from "@shared/editor/lib/multiplayer";
|
||||
@@ -88,7 +88,7 @@ export default class PasteHandler extends Extension {
|
||||
|
||||
// If the users selection is currently in a code block then paste
|
||||
// as plain text, ignore all formatting and HTML content.
|
||||
if (isInCode(state)) {
|
||||
if (isInCode(state, { inclusive: true })) {
|
||||
event.preventDefault();
|
||||
view.dispatch(state.tr.insertText(text));
|
||||
return true;
|
||||
@@ -122,6 +122,8 @@ export default class PasteHandler extends Extension {
|
||||
}
|
||||
|
||||
// Is the link a link to a document? If so, we can grab the title and insert it.
|
||||
const containsHash = text.includes("#");
|
||||
|
||||
if (isDocumentUrl(text)) {
|
||||
const slug = parseDocumentSlug(text);
|
||||
|
||||
@@ -133,7 +135,7 @@ export default class PasteHandler extends Extension {
|
||||
return;
|
||||
}
|
||||
if (document) {
|
||||
if (state.schema.nodes.mention) {
|
||||
if (state.schema.nodes.mention && !containsHash) {
|
||||
view.dispatch(
|
||||
view.state.tr.replaceWith(
|
||||
state.selection.from,
|
||||
@@ -178,7 +180,7 @@ export default class PasteHandler extends Extension {
|
||||
return;
|
||||
}
|
||||
if (collection) {
|
||||
if (state.schema.nodes.mention) {
|
||||
if (state.schema.nodes.mention && !containsHash) {
|
||||
view.dispatch(
|
||||
view.state.tr.replaceWith(
|
||||
state.selection.from,
|
||||
@@ -226,7 +228,7 @@ export default class PasteHandler extends Extension {
|
||||
state.tr
|
||||
.replaceSelectionWith(
|
||||
state.schema.nodes.code_block.create({
|
||||
language: Object.keys(LANGUAGES).includes(
|
||||
language: Object.keys(codeLanguages).includes(
|
||||
vscodeMeta.mode
|
||||
)
|
||||
? vscodeMeta.mode
|
||||
|
||||
@@ -23,7 +23,7 @@ export default class Suggestion extends Extension {
|
||||
this.options.trigger
|
||||
)}(${`[\\p{L}\/\\p{M}\\d${
|
||||
this.options.allowSpaces ? "\\s{1}" : ""
|
||||
}\\.]+`})${this.options.requireSearchTerm ? "" : "?"}$`,
|
||||
}\\.\\-–_]+`})${this.options.requireSearchTerm ? "" : "?"}$`,
|
||||
"u"
|
||||
);
|
||||
}
|
||||
|
||||
+13
-12
@@ -2,8 +2,11 @@ import { CopyIcon, ExpandedIcon } from "outline-icons";
|
||||
import { Node as ProseMirrorNode } from "prosemirror-model";
|
||||
import { EditorState } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import { LANGUAGES } from "@shared/editor/extensions/Prism";
|
||||
import { getFrequentCodeLanguages } from "@shared/editor/lib/code";
|
||||
import {
|
||||
getFrequentCodeLanguages,
|
||||
codeLanguages,
|
||||
getLabelForLanguage,
|
||||
} from "@shared/editor/lib/code";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
|
||||
@@ -14,20 +17,19 @@ export default function codeMenuItems(
|
||||
): MenuItem[] {
|
||||
const node = state.selection.$from.node();
|
||||
|
||||
const allLanguages = Object.entries(LANGUAGES) as [
|
||||
keyof typeof LANGUAGES,
|
||||
string
|
||||
][];
|
||||
const frequentLanguages = getFrequentCodeLanguages();
|
||||
|
||||
const frequentLangMenuItems = frequentLanguages.map((value) => {
|
||||
const label = LANGUAGES[value];
|
||||
const label = codeLanguages[value]?.label;
|
||||
return langToMenuItem({ node, value, label });
|
||||
});
|
||||
|
||||
const remainingLangMenuItems = allLanguages
|
||||
.filter(([value]) => !frequentLanguages.includes(value))
|
||||
.map(([value, label]) => langToMenuItem({ node, value, label }));
|
||||
const remainingLangMenuItems = Object.entries(codeLanguages)
|
||||
.filter(
|
||||
([value]) =>
|
||||
!frequentLanguages.includes(value as keyof typeof codeLanguages)
|
||||
)
|
||||
.map(([value, item]) => langToMenuItem({ node, value, label: item.label }));
|
||||
|
||||
const languageMenuItems = frequentLangMenuItems.length
|
||||
? [
|
||||
@@ -52,8 +54,7 @@ export default function codeMenuItems(
|
||||
visible: !readOnly,
|
||||
name: "code_block",
|
||||
icon: <ExpandedIcon />,
|
||||
// @ts-expect-error We have a fallback for incorrect mapping
|
||||
label: LANGUAGES[node.attrs.language ?? "none"],
|
||||
label: getLabelForLanguage(node.attrs.language ?? "none"),
|
||||
children: languageMenuItems,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
/**
|
||||
* Hook that provides a dictionary of translated UI strings.
|
||||
*
|
||||
* @returns An object containing all translated UI strings used throughout the application
|
||||
*/
|
||||
export default function useDictionary() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import * as React from "react";
|
||||
import useWindowSize from "./useWindowSize";
|
||||
|
||||
/**
|
||||
* Hook to calculate the maximum height for an element based on its position and viewport size.
|
||||
*
|
||||
* @param options Configuration options
|
||||
* @param options.elementRef A ref pointing to the element to calculate max height for
|
||||
* @param options.maxViewportPercentage The maximum height of the element as a percentage of the viewport
|
||||
* @param options.margin The margin to apply to the positioning
|
||||
* @returns Object containing the calculated maxHeight and a function to recalculate it
|
||||
*/
|
||||
const useMaxHeight = ({
|
||||
elementRef,
|
||||
maxViewportPercentage = 90,
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Hook to check if a media query matches the current viewport.
|
||||
*
|
||||
* @param query The CSS media query to check against
|
||||
* @returns boolean indicating whether the media query matches
|
||||
*/
|
||||
export default function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState<boolean>(false);
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { breakpoints } from "@shared/styles";
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
|
||||
/**
|
||||
* Hook to detect if the current viewport is mobile-sized.
|
||||
*
|
||||
* @returns boolean indicating whether the current viewport is mobile-sized
|
||||
*/
|
||||
export default function useMobile(): boolean {
|
||||
return useMediaQuery(`(max-width: ${breakpoints.tablet - 1}px)`);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export default function usePolicy(entity?: string | Model | null) {
|
||||
? entity
|
||||
: entity.id
|
||||
: "";
|
||||
const policy = policies.get(entityId);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
@@ -28,11 +29,11 @@ export default function usePolicy(entity?: string | Model | null) {
|
||||
) {
|
||||
// The policy for this model is missing and we have an authenticated session, attempt to
|
||||
// reload relationships for this model.
|
||||
if (!policies.get(entity.id) && user) {
|
||||
if (!policy && user) {
|
||||
void entity.loadRelations();
|
||||
}
|
||||
}
|
||||
}, [policies, user, entity]);
|
||||
}, [policies, policy, user, entity]);
|
||||
|
||||
return policies.abilities(entityId);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import React from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
/**
|
||||
* Hook to access URL query parameters from the current location.
|
||||
*
|
||||
* @returns URLSearchParams object containing the current URL query parameters
|
||||
*/
|
||||
export default function useQuery() {
|
||||
const location = useLocation();
|
||||
|
||||
|
||||
@@ -24,6 +24,14 @@ export default function useQueryNotices() {
|
||||
);
|
||||
break;
|
||||
}
|
||||
case QueryNotices.UnsubscribeCollection: {
|
||||
toast.success(
|
||||
t("Unsubscribed from collection", {
|
||||
type: "success",
|
||||
})
|
||||
);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
}
|
||||
}, [t, notice]);
|
||||
|
||||
@@ -2,6 +2,11 @@ import { MobXProviderContext } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import RootStore from "~/stores";
|
||||
|
||||
/**
|
||||
* Hook to access the MobX stores from the React context.
|
||||
*
|
||||
* @returns The root store containing all application stores
|
||||
*/
|
||||
export default function useStores() {
|
||||
return React.useContext(MobXProviderContext) as typeof RootStore;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* Hook that executes a callback when the component unmounts.
|
||||
*
|
||||
* @param callback Function to be called on component unmount
|
||||
*/
|
||||
const useUnmount = (callback: (...args: Array<any>) => any) => {
|
||||
const ref = React.useRef(callback);
|
||||
ref.current = callback;
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { useLayoutEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Hook to get the current viewport height, accounting for mobile virtual keyboards.
|
||||
* Uses the VisualViewport API when available, falling back to window.innerHeight.
|
||||
*
|
||||
* @returns The current viewport height in pixels
|
||||
*/
|
||||
export default function useViewportHeight(): number | void {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/VisualViewport#browser_compatibility
|
||||
// Note: No support in Firefox at time of writing, however this mainly exists
|
||||
|
||||
@@ -13,6 +13,13 @@ const defaultOptions = {
|
||||
throttle: 100,
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to track the window's scroll position.
|
||||
*
|
||||
* @param options Configuration options
|
||||
* @param options.throttle Time in milliseconds to throttle the scroll event
|
||||
* @returns Object containing the current scroll position (x, y coordinates)
|
||||
*/
|
||||
export default function useWindowScrollPosition(options: {
|
||||
throttle: number;
|
||||
}): {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { CrossIcon, TrashIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMenuState } from "reakit/Menu";
|
||||
import Import from "~/models/Import";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import { MenuItem } from "~/types";
|
||||
|
||||
type Props = {
|
||||
/** Import to which actions will be applied. */
|
||||
importModel: Import;
|
||||
/** Callback to handle import cancellation. */
|
||||
onCancel: () => Promise<void>;
|
||||
/** Callback to handle import deletion. */
|
||||
onDelete: () => Promise<void>;
|
||||
};
|
||||
|
||||
export const ImportMenu = observer(
|
||||
({ importModel, onCancel, onDelete }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(importModel);
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
});
|
||||
|
||||
const items = React.useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
type: "button",
|
||||
title: t("Cancel"),
|
||||
visible: can.cancel,
|
||||
icon: <CrossIcon />,
|
||||
dangerous: true,
|
||||
onClick: onCancel,
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: t("Delete"),
|
||||
visible: can.delete,
|
||||
icon: <TrashIcon />,
|
||||
dangerous: true,
|
||||
onClick: onDelete,
|
||||
},
|
||||
] satisfies MenuItem[],
|
||||
[t, can.delete, can.cancel, onCancel, onDelete]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
|
||||
<ContextMenu {...menu} aria-label={t("Import menu options")}>
|
||||
<Template {...menu} items={items} />
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -600,7 +600,7 @@ export default class Document extends ArchivableModel implements Searchable {
|
||||
*/
|
||||
getSummary = (blocks = 4) => ({
|
||||
...this.data,
|
||||
content: this.data.content.slice(0, blocks),
|
||||
content: this.data.content?.slice(0, blocks),
|
||||
});
|
||||
|
||||
@computed
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { observable } from "mobx";
|
||||
import { ImportableIntegrationService, ImportState } from "@shared/types";
|
||||
import ImportsStore from "~/stores/ImportsStore";
|
||||
import User from "./User";
|
||||
import Model from "./base/Model";
|
||||
import Field from "./decorators/Field";
|
||||
import { AfterChange } from "./decorators/Lifecycle";
|
||||
import Relation from "./decorators/Relation";
|
||||
|
||||
class Import extends Model {
|
||||
static modelName = "Import";
|
||||
|
||||
store: ImportsStore;
|
||||
|
||||
/** The name of the import. */
|
||||
name: string;
|
||||
|
||||
/** Descriptive error message when the import errors out. */
|
||||
error: string | null;
|
||||
|
||||
/** The current state of the import. */
|
||||
@Field
|
||||
@observable
|
||||
state: ImportState;
|
||||
|
||||
/** The external service from which the import is created. */
|
||||
service: ImportableIntegrationService;
|
||||
|
||||
/** The count of documents created in the import. */
|
||||
@observable
|
||||
documentCount: number;
|
||||
|
||||
/** The user who created the import. */
|
||||
@Relation(() => User, {})
|
||||
createdBy: User;
|
||||
|
||||
/** The ID of the user who created the import. */
|
||||
createdById: string;
|
||||
|
||||
/**
|
||||
* Cancel the import – this will stop the import process and mark it as
|
||||
* cancelled at the first opportunity.
|
||||
*/
|
||||
cancel = async () => this.store.cancel(this);
|
||||
|
||||
// hooks
|
||||
|
||||
@AfterChange
|
||||
static removePolicies(model: Import, previousAttributes: Partial<Import>) {
|
||||
if (previousAttributes.state && previousAttributes.state !== model.state) {
|
||||
const { policies } = model.store.rootStore;
|
||||
policies.remove(model.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Import;
|
||||
+14
-14
@@ -43,12 +43,6 @@ export default abstract class Model {
|
||||
this: Model,
|
||||
options: { withoutPolicies?: boolean } = {}
|
||||
): Promise<any> {
|
||||
const relations = getRelationsForModelClass(
|
||||
this.constructor as typeof Model
|
||||
);
|
||||
if (!relations) {
|
||||
return;
|
||||
}
|
||||
// this is to ensure that multiple loads don’t happen in parallel
|
||||
if (this.loadingRelations) {
|
||||
return this.loadingRelations;
|
||||
@@ -56,14 +50,20 @@ export default abstract class Model {
|
||||
|
||||
const promises = [];
|
||||
|
||||
for (const properties of relations.values()) {
|
||||
const store = this.store.rootStore.getStoreForModelName(
|
||||
properties.relationClassResolver().modelName
|
||||
);
|
||||
if ("fetch" in store) {
|
||||
const id = this[properties.idKey];
|
||||
if (id) {
|
||||
promises.push(store.fetch(id as string));
|
||||
const relations = getRelationsForModelClass(
|
||||
this.constructor as typeof Model
|
||||
);
|
||||
|
||||
if (relations) {
|
||||
for (const properties of relations.values()) {
|
||||
const store = this.store.rootStore.getStoreForModelName(
|
||||
properties.relationClassResolver().modelName
|
||||
);
|
||||
if ("fetch" in store) {
|
||||
const id = this[properties.idKey];
|
||||
if (id) {
|
||||
promises.push(store.fetch(id as string));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { observable } from "mobx";
|
||||
import { computed, observable } from "mobx";
|
||||
import Model from "./Model";
|
||||
|
||||
export default abstract class ParanoidModel extends Model {
|
||||
@observable
|
||||
deletedAt: string | undefined;
|
||||
|
||||
@computed
|
||||
get isDeleted(): boolean {
|
||||
return !!this.deletedAt;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import Facepile from "~/components/Facepile";
|
||||
import Fade from "~/components/Fade";
|
||||
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import usePersistedState from "~/hooks/usePersistedState";
|
||||
@@ -63,7 +64,7 @@ function CommentThread({
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const [autoFocus, setAutoFocus] = React.useState(thread.isNew);
|
||||
const [autoFocus, setAutoFocusOn, setAutoFocusOff] = useBoolean(thread.isNew);
|
||||
|
||||
const can = usePolicy(document);
|
||||
|
||||
@@ -156,9 +157,9 @@ function CommentThread({
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!focused && autoFocus) {
|
||||
setAutoFocus(false);
|
||||
setAutoFocusOff();
|
||||
}
|
||||
}, [focused, autoFocus]);
|
||||
}, [focused, autoFocus, setAutoFocusOff]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (focused) {
|
||||
@@ -273,7 +274,7 @@ function CommentThread({
|
||||
)}
|
||||
</ResizingHeightContainer>
|
||||
{!focused && !recessed && !draft && canReply && (
|
||||
<Reply onClick={() => setAutoFocus(true)}>{t("Reply")}…</Reply>
|
||||
<Reply onClick={setAutoFocusOn}>{t("Reply")}…</Reply>
|
||||
)}
|
||||
</Thread>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,12 @@ import { useHistory } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { IndexeddbPersistence } from "y-indexeddb";
|
||||
import * as Y from "yjs";
|
||||
import {
|
||||
AuthenticationFailed,
|
||||
DocumentTooLarge,
|
||||
EditorUpdateError,
|
||||
} from "@shared/collaboration/CloseEvents";
|
||||
import EDITOR_VERSION from "@shared/editor/version";
|
||||
import { supportsPassiveListener } from "@shared/utils/browser";
|
||||
import Editor, { Props as EditorProps } from "~/components/Editor";
|
||||
import MultiplayerExtension from "~/editor/extensions/Multiplayer";
|
||||
@@ -65,6 +71,9 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
|
||||
const name = `document.${documentId}`;
|
||||
const localProvider = new IndexeddbPersistence(name, ydoc);
|
||||
const provider = new HocuspocusProvider({
|
||||
parameters: {
|
||||
editorVersion: EDITOR_VERSION,
|
||||
},
|
||||
url: `${env.COLLABORATION_URL}/collaboration`,
|
||||
name,
|
||||
document: ydoc,
|
||||
@@ -140,8 +149,14 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
|
||||
provider.on("close", (ev: MessageEvent) => {
|
||||
if ("code" in ev.event) {
|
||||
provider.shouldConnect =
|
||||
ev.event.code !== 1009 && ev.event.code !== 4401;
|
||||
ev.event.code !== DocumentTooLarge.code &&
|
||||
ev.event.code !== AuthenticationFailed.code &&
|
||||
ev.event.code !== EditorUpdateError.code;
|
||||
ui.setMultiplayerStatus("disconnected", ev.event.code);
|
||||
|
||||
if (ev.event.code === EditorUpdateError.code) {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+1
-8
@@ -2,7 +2,7 @@ import { observer } from "mobx-react";
|
||||
import { HomeIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Switch, Route, Redirect } from "react-router-dom";
|
||||
import { Switch, Route } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { Action } from "~/components/Actions";
|
||||
@@ -18,7 +18,6 @@ import Tab from "~/components/Tab";
|
||||
import Tabs from "~/components/Tabs";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
|
||||
import { usePinnedDocuments } from "~/hooks/usePinnedDocuments";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
@@ -29,16 +28,10 @@ function Home() {
|
||||
const team = useCurrentTeam();
|
||||
const user = useCurrentUser();
|
||||
const { t } = useTranslation();
|
||||
const [spendPostLoginPath] = usePostLoginPath();
|
||||
const userId = user?.id;
|
||||
const { pins, count } = usePinnedDocuments("home");
|
||||
const can = usePolicy(team);
|
||||
|
||||
const postLoginPath = spendPostLoginPath();
|
||||
if (postLoginPath) {
|
||||
return <Redirect to={postLoginPath} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Scene
|
||||
icon={<HomeIcon />}
|
||||
|
||||
+154
-88
@@ -1,10 +1,13 @@
|
||||
import orderBy from "lodash/orderBy";
|
||||
import { observer } from "mobx-react";
|
||||
import { NewDocumentIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { Pagination } from "@shared/constants";
|
||||
import { FileOperationType } from "@shared/types";
|
||||
import { cdnPath } from "@shared/utils/urls";
|
||||
import FileOperation from "~/models/FileOperation";
|
||||
import ImportModel from "~/models/Import";
|
||||
import Button from "~/components/Button";
|
||||
import Heading from "~/components/Heading";
|
||||
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
|
||||
@@ -15,16 +18,146 @@ import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
import env from "~/env";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import FileOperationListItem from "./components/FileOperationListItem";
|
||||
import ImportJSONDialog from "./components/ImportJSONDialog";
|
||||
import { ImportListItem } from "./components/ImportListItem";
|
||||
import ImportMarkdownDialog from "./components/ImportMarkdownDialog";
|
||||
import ImportNotionDialog from "./components/ImportNotionDialog";
|
||||
|
||||
type Config = {
|
||||
/** The title of the import. */
|
||||
title: string;
|
||||
/** The auxiliary descriptive text of the import. */
|
||||
subtitle: string;
|
||||
/** An icon to denote the kind of import. */
|
||||
icon: React.ReactElement;
|
||||
/** Trigger for the import. */
|
||||
action: React.ReactElement;
|
||||
};
|
||||
|
||||
function useImportsConfig() {
|
||||
const { t } = useTranslation();
|
||||
const { dialogs } = useStores();
|
||||
const appName = env.APP_NAME;
|
||||
|
||||
return React.useMemo(() => {
|
||||
const items: Config[] = [
|
||||
{
|
||||
title: t("Markdown"),
|
||||
subtitle: t(
|
||||
"Import a zip file of Markdown documents (exported from version 0.67.0 or earlier)"
|
||||
),
|
||||
icon: <MarkdownIcon size={28} />,
|
||||
action: (
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={() => {
|
||||
dialogs.openModal({
|
||||
title: t("Import data"),
|
||||
content: <ImportMarkdownDialog />,
|
||||
});
|
||||
}}
|
||||
neutral
|
||||
>
|
||||
{t("Import")}…
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "JSON",
|
||||
subtitle: t(
|
||||
"Import a JSON data file exported from another {{ appName }} instance",
|
||||
{
|
||||
appName,
|
||||
}
|
||||
),
|
||||
icon: <OutlineIcon size={28} cover />,
|
||||
action: (
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={() => {
|
||||
dialogs.openModal({
|
||||
title: t("Import data"),
|
||||
content: <ImportJSONDialog />,
|
||||
});
|
||||
}}
|
||||
neutral
|
||||
>
|
||||
{t("Import")}…
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
PluginManager.getHooks(Hook.Imports).forEach((plugin) => {
|
||||
items.push({ ...plugin.value });
|
||||
});
|
||||
|
||||
items.push({
|
||||
title: "Confluence",
|
||||
subtitle: t("Import pages from a Confluence instance"),
|
||||
icon: <img src={cdnPath("/images/confluence.png")} width={28} />,
|
||||
action: (
|
||||
<Button type="submit" disabled neutral>
|
||||
{t("Enterprise")}
|
||||
</Button>
|
||||
),
|
||||
});
|
||||
|
||||
return items;
|
||||
}, [t, dialogs, appName]);
|
||||
}
|
||||
|
||||
function Import() {
|
||||
const { t } = useTranslation();
|
||||
const { dialogs, fileOperations } = useStores();
|
||||
const { fileOperations, imports } = useStores();
|
||||
const configs = useImportsConfig();
|
||||
const appName = env.APP_NAME;
|
||||
|
||||
const [, setForceRender] = React.useState(0);
|
||||
const offset = React.useMemo(() => ({ imports: 0, fileOperations: 0 }), []);
|
||||
|
||||
const fetchImports = React.useCallback(async () => {
|
||||
const [importsArr, fileOpsArr] = await Promise.all([
|
||||
imports.fetchPage({
|
||||
offset: offset.imports,
|
||||
limit: Pagination.defaultLimit,
|
||||
}),
|
||||
fileOperations.fetchPage({
|
||||
type: FileOperationType.Import,
|
||||
offset: offset.fileOperations,
|
||||
limit: Pagination.defaultLimit,
|
||||
}),
|
||||
]);
|
||||
|
||||
const pageImports = orderBy(
|
||||
[...importsArr, ...fileOpsArr],
|
||||
"createdAt",
|
||||
"desc"
|
||||
).slice(0, Pagination.defaultLimit);
|
||||
|
||||
const apiImportsCount = pageImports.filter(
|
||||
(item) => item instanceof ImportModel
|
||||
).length;
|
||||
|
||||
offset.imports += apiImportsCount;
|
||||
offset.fileOperations += pageImports.length - apiImportsCount;
|
||||
|
||||
// needed to re-render after mobx store and offset is updated
|
||||
setForceRender((s) => ++s);
|
||||
|
||||
return pageImports;
|
||||
}, [imports, fileOperations, offset]);
|
||||
|
||||
const allImports = orderBy(
|
||||
[
|
||||
...imports.orderedData,
|
||||
...fileOperations.filter({ type: FileOperationType.Import }),
|
||||
],
|
||||
"createdAt",
|
||||
"desc"
|
||||
).slice(0, offset.imports + offset.fileOperations);
|
||||
|
||||
return (
|
||||
<Scene title={t("Import")} icon={<NewDocumentIcon />}>
|
||||
<Heading>{t("Import")}</Heading>
|
||||
@@ -38,100 +171,33 @@ function Import() {
|
||||
</Text>
|
||||
|
||||
<div>
|
||||
<Item
|
||||
border={false}
|
||||
image={<MarkdownIcon size={28} />}
|
||||
title={t("Markdown")}
|
||||
subtitle={t(
|
||||
"Import a zip file of Markdown documents (exported from version 0.67.0 or earlier)"
|
||||
)}
|
||||
actions={
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={() => {
|
||||
dialogs.openModal({
|
||||
title: t("Import data"),
|
||||
content: <ImportMarkdownDialog />,
|
||||
});
|
||||
}}
|
||||
neutral
|
||||
>
|
||||
{t("Import")}…
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Item
|
||||
border={false}
|
||||
image={<OutlineIcon size={28} cover />}
|
||||
title="JSON"
|
||||
subtitle={t(
|
||||
"Import a JSON data file exported from another {{ appName }} instance",
|
||||
{
|
||||
appName,
|
||||
}
|
||||
)}
|
||||
actions={
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={() => {
|
||||
dialogs.openModal({
|
||||
title: t("Import data"),
|
||||
content: <ImportJSONDialog />,
|
||||
});
|
||||
}}
|
||||
neutral
|
||||
>
|
||||
{t("Import")}…
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Item
|
||||
border={false}
|
||||
image={<img src={cdnPath("/images/notion.png")} width={28} />}
|
||||
title="Notion"
|
||||
subtitle={t("Import pages exported from Notion")}
|
||||
actions={
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={() => {
|
||||
dialogs.openModal({
|
||||
title: t("Import data"),
|
||||
content: <ImportNotionDialog />,
|
||||
});
|
||||
}}
|
||||
neutral
|
||||
>
|
||||
{t("Import")}…
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Item
|
||||
border={false}
|
||||
image={<img src={cdnPath("/images/confluence.png")} width={28} />}
|
||||
title="Confluence"
|
||||
subtitle={t("Import pages from a Confluence instance")}
|
||||
actions={
|
||||
<Button type="submit" disabled neutral>
|
||||
{t("Enterprise")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{configs.map((config) => (
|
||||
<Item
|
||||
key={config.title}
|
||||
title={config.title}
|
||||
subtitle={config.subtitle}
|
||||
image={config.icon}
|
||||
actions={config.action}
|
||||
border={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<br />
|
||||
<PaginatedList
|
||||
items={fileOperations.imports}
|
||||
fetch={fileOperations.fetchPage}
|
||||
options={{
|
||||
type: FileOperationType.Import,
|
||||
}}
|
||||
items={allImports}
|
||||
fetch={fetchImports}
|
||||
heading={
|
||||
<h2>
|
||||
<Trans>Recent imports</Trans>
|
||||
</h2>
|
||||
}
|
||||
renderItem={(item: FileOperation) => (
|
||||
<FileOperationListItem key={item.id} fileOperation={item} />
|
||||
)}
|
||||
renderItem={(item: ImportModel | FileOperation) =>
|
||||
item instanceof ImportModel ? (
|
||||
<ImportListItem key={item.id} importModel={item} />
|
||||
) : (
|
||||
<FileOperationListItem key={item.id} fileOperation={item} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Scene>
|
||||
);
|
||||
|
||||
@@ -194,7 +194,7 @@ function getFilteredUsers({
|
||||
|
||||
switch (filter) {
|
||||
case "all":
|
||||
filteredUsers = users.orderedData;
|
||||
filteredUsers = users.all;
|
||||
break;
|
||||
case "suspended":
|
||||
filteredUsers = users.suspended;
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import capitalize from "lodash/capitalize";
|
||||
import { observer } from "mobx-react";
|
||||
import { CrossIcon, DoneIcon, WarningIcon } from "outline-icons";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useTheme } from "styled-components";
|
||||
import { ImportState } from "@shared/types";
|
||||
import Import from "~/models/Import";
|
||||
import { Action } from "~/components/Actions";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import Spinner from "~/components/Spinner";
|
||||
import Time from "~/components/Time";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { ImportMenu } from "~/menus/ImportMenu";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
|
||||
type Props = {
|
||||
/** Import that's displayed as list item. */
|
||||
importModel: Import;
|
||||
};
|
||||
|
||||
export const ImportListItem = observer(({ importModel }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { dialogs } = useStores();
|
||||
const user = useCurrentUser();
|
||||
const theme = useTheme();
|
||||
const showProgress =
|
||||
importModel.state !== ImportState.Canceled &&
|
||||
importModel.state !== ImportState.Errored;
|
||||
const showErrorInfo =
|
||||
!isCloudHosted &&
|
||||
importModel.state === ImportState.Errored &&
|
||||
!!importModel.error;
|
||||
|
||||
const stateMap = React.useMemo(
|
||||
() => ({
|
||||
[ImportState.Created]: t("Processing"),
|
||||
[ImportState.InProgress]: t("Processing"),
|
||||
[ImportState.Processed]: t("Processing"),
|
||||
[ImportState.Completed]: t("Completed"),
|
||||
[ImportState.Errored]: t("Failed"),
|
||||
[ImportState.Canceled]: t("Canceled"),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const iconMap = React.useMemo(
|
||||
() => ({
|
||||
[ImportState.Created]: <Spinner />,
|
||||
[ImportState.InProgress]: <Spinner />,
|
||||
[ImportState.Processed]: <Spinner />,
|
||||
[ImportState.Completed]: <DoneIcon color={theme.accent} />,
|
||||
[ImportState.Errored]: <WarningIcon color={theme.danger} />,
|
||||
[ImportState.Canceled]: <CrossIcon color={theme.textTertiary} />,
|
||||
}),
|
||||
[theme]
|
||||
);
|
||||
|
||||
const handleCancel = React.useCallback(async () => {
|
||||
const onCancel = async () => {
|
||||
try {
|
||||
await importModel.cancel();
|
||||
toast.success(t("Import canceled"));
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
dialogs.openModal({
|
||||
title: t("Are you sure you want to cancel this import?"),
|
||||
content: (
|
||||
<ConfirmationDialog
|
||||
onSubmit={onCancel}
|
||||
submitText={t("Cancel")}
|
||||
savingText={`${t("Canceling")}…`}
|
||||
danger
|
||||
>
|
||||
{t(
|
||||
"Canceling this import will discard any progress made. This cannot be undone."
|
||||
)}
|
||||
</ConfirmationDialog>
|
||||
),
|
||||
});
|
||||
}, [t, dialogs, importModel]);
|
||||
|
||||
const handleDelete = React.useCallback(async () => {
|
||||
const onDelete = async () => {
|
||||
try {
|
||||
await importModel.delete();
|
||||
toast.success(t("Import deleted"));
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
dialogs.openModal({
|
||||
title: t("Are you sure you want to delete this import?"),
|
||||
content: (
|
||||
<ConfirmationDialog
|
||||
onSubmit={onDelete}
|
||||
savingText={`${t("Deleting")}…`}
|
||||
danger
|
||||
>
|
||||
{t(
|
||||
"Deleting this import will also delete all collections and documents that were created from it. This cannot be undone."
|
||||
)}
|
||||
</ConfirmationDialog>
|
||||
),
|
||||
});
|
||||
}, [t, dialogs, importModel]);
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
title={importModel.name}
|
||||
image={iconMap[importModel.state]}
|
||||
subtitle={
|
||||
<>
|
||||
{stateMap[importModel.state]} •
|
||||
{showErrorInfo && (
|
||||
<>
|
||||
{importModel.error}
|
||||
{`. ${t("Check server logs for more details.")}`} •
|
||||
</>
|
||||
)}
|
||||
{t(`{{userName}} requested`, {
|
||||
userName:
|
||||
user.id === importModel.createdBy.id
|
||||
? t("You")
|
||||
: importModel.createdBy.name,
|
||||
})}
|
||||
|
||||
<Time dateTime={importModel.createdAt} addSuffix shorten />
|
||||
•
|
||||
{capitalize(importModel.service)}
|
||||
{showProgress && (
|
||||
<>
|
||||
•
|
||||
{t("{{ count }} document imported", {
|
||||
count: importModel.documentCount,
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<Action>
|
||||
<ImportMenu
|
||||
importModel={importModel}
|
||||
onCancel={handleCancel}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</Action>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
@@ -8,6 +8,7 @@ type DialogDefinition = {
|
||||
isOpen: boolean;
|
||||
fullscreen?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
export default class DialogsStore {
|
||||
@@ -50,6 +51,7 @@ export default class DialogsStore {
|
||||
fullscreen,
|
||||
replace,
|
||||
style,
|
||||
onClose,
|
||||
}: {
|
||||
id?: string;
|
||||
title: string;
|
||||
@@ -57,6 +59,7 @@ export default class DialogsStore {
|
||||
content: React.ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
replace?: boolean;
|
||||
onClose?: () => void;
|
||||
}) => {
|
||||
setTimeout(
|
||||
action(() => {
|
||||
@@ -70,6 +73,7 @@ export default class DialogsStore {
|
||||
fullscreen,
|
||||
style,
|
||||
isOpen: true,
|
||||
onClose,
|
||||
});
|
||||
}),
|
||||
0
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import invariant from "invariant";
|
||||
import { action, runInAction } from "mobx";
|
||||
import Import from "~/models/Import";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import RootStore from "./RootStore";
|
||||
import Store from "./base/Store";
|
||||
|
||||
export default class ImportsStore extends Store<Import> {
|
||||
constructor(rootStore: RootStore) {
|
||||
super(rootStore, Import);
|
||||
}
|
||||
|
||||
@action
|
||||
cancel = async (importModel: Import) => {
|
||||
const res = await client.post("/imports.cancel", {
|
||||
id: importModel.id,
|
||||
});
|
||||
|
||||
runInAction("Import#cancel", () => {
|
||||
invariant(res?.data, "Data should be available");
|
||||
importModel.updateData(res.data);
|
||||
this.addPolicies(res.policies);
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import FileOperationsStore from "./FileOperationsStore";
|
||||
import GroupMembershipsStore from "./GroupMembershipsStore";
|
||||
import GroupUsersStore from "./GroupUsersStore";
|
||||
import GroupsStore from "./GroupsStore";
|
||||
import ImportsStore from "./ImportsStore";
|
||||
import IntegrationsStore from "./IntegrationsStore";
|
||||
import MembershipsStore from "./MembershipsStore";
|
||||
import NotificationsStore from "./NotificationsStore";
|
||||
@@ -43,6 +44,7 @@ export default class RootStore {
|
||||
events: EventsStore;
|
||||
groups: GroupsStore;
|
||||
groupUsers: GroupUsersStore;
|
||||
imports: ImportsStore;
|
||||
integrations: IntegrationsStore;
|
||||
memberships: MembershipsStore;
|
||||
notifications: NotificationsStore;
|
||||
@@ -72,6 +74,7 @@ export default class RootStore {
|
||||
this.registerStore(EventsStore);
|
||||
this.registerStore(GroupsStore);
|
||||
this.registerStore(GroupUsersStore);
|
||||
this.registerStore(ImportsStore);
|
||||
this.registerStore(IntegrationsStore);
|
||||
this.registerStore(MembershipsStore);
|
||||
this.registerStore(NotificationsStore);
|
||||
|
||||
@@ -27,46 +27,44 @@ export default class UsersStore extends Store<User> {
|
||||
|
||||
@computed
|
||||
get active(): User[] {
|
||||
return this.orderedData.filter(
|
||||
(user) => !user.isSuspended && user.lastActiveAt
|
||||
);
|
||||
return this.all.filter((user) => !user.isSuspended && !user.isInvited);
|
||||
}
|
||||
|
||||
@computed
|
||||
get suspended(): User[] {
|
||||
return this.orderedData.filter((user) => user.isSuspended);
|
||||
return this.all.filter((user) => user.isSuspended);
|
||||
}
|
||||
|
||||
@computed
|
||||
get activeOrInvited(): User[] {
|
||||
return this.orderedData.filter((user) => !user.isSuspended);
|
||||
return this.all.filter((user) => !user.isSuspended);
|
||||
}
|
||||
|
||||
@computed
|
||||
get invited(): User[] {
|
||||
return this.orderedData.filter((user) => user.isInvited);
|
||||
return this.all.filter((user) => user.isInvited);
|
||||
}
|
||||
|
||||
@computed
|
||||
get admins(): User[] {
|
||||
return this.orderedData.filter((user) => user.isAdmin);
|
||||
return this.all.filter((user) => user.isAdmin && !user.isInvited);
|
||||
}
|
||||
|
||||
@computed
|
||||
get members(): User[] {
|
||||
return this.orderedData.filter(
|
||||
return this.all.filter(
|
||||
(user) => !user.isViewer && !user.isAdmin && !user.isInvited
|
||||
);
|
||||
}
|
||||
|
||||
@computed
|
||||
get viewers(): User[] {
|
||||
return this.orderedData.filter((user) => user.isViewer);
|
||||
return this.all.filter((user) => user.isViewer && !user.isInvited);
|
||||
}
|
||||
|
||||
@computed
|
||||
get all(): User[] {
|
||||
return this.orderedData.filter((user) => user.lastActiveAt);
|
||||
return this.orderedData.filter((user) => !user.isDeleted);
|
||||
}
|
||||
|
||||
@computed
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import commandScore from "command-score";
|
||||
import invariant from "invariant";
|
||||
import { deburr, type ObjectIterateeCustom } from "lodash";
|
||||
import type { ObjectIterateeCustom } from "lodash";
|
||||
import deburr from "lodash/deburr";
|
||||
import filter from "lodash/filter";
|
||||
import find from "lodash/find";
|
||||
import flatten from "lodash/flatten";
|
||||
@@ -102,6 +103,9 @@ export default abstract class Store<T extends Model> {
|
||||
|
||||
return this.orderedData
|
||||
.filter((item: T & Searchable) => {
|
||||
if ("deletedAt" in item && item.deletedAt) {
|
||||
return false;
|
||||
}
|
||||
if ("searchContent" in item) {
|
||||
const seachables =
|
||||
typeof item.searchContent === "string"
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
RateLimitExceededError,
|
||||
RequestError,
|
||||
ServiceUnavailableError,
|
||||
UnprocessableEntityError,
|
||||
UpdateRequiredError,
|
||||
} from "./errors";
|
||||
|
||||
@@ -214,6 +215,10 @@ class ApiClient {
|
||||
throw new ServiceUnavailableError(error.message);
|
||||
}
|
||||
|
||||
if (response.status === 422) {
|
||||
throw new UnprocessableEntityError(error.message);
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
throw new RateLimitExceededError(
|
||||
`Too many requests, try again in a minute.`
|
||||
|
||||
@@ -12,6 +12,7 @@ import isCloudHosted from "./isCloudHosted";
|
||||
*/
|
||||
export enum Hook {
|
||||
Settings = "settings",
|
||||
Imports = "imports",
|
||||
Icon = "icon",
|
||||
}
|
||||
|
||||
@@ -31,6 +32,16 @@ type PluginValueMap = {
|
||||
/** Whether the plugin is enabled in the current context. */
|
||||
enabled?: (team: Team, user: User) => boolean;
|
||||
};
|
||||
[Hook.Imports]: {
|
||||
/** The title of the import. */
|
||||
title: string;
|
||||
/** The auxiliary descriptive text of the import. */
|
||||
subtitle: string;
|
||||
/** An icon to denote the kind of import. */
|
||||
icon: React.ReactElement;
|
||||
/** Trigger for the import. */
|
||||
action: React.ReactElement;
|
||||
};
|
||||
[Hook.Icon]: React.ElementType;
|
||||
};
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ export class ServiceUnavailableError extends ExtendableError {}
|
||||
|
||||
export class BadGatewayError extends ExtendableError {}
|
||||
|
||||
export class UnprocessableEntityError extends ExtendableError {}
|
||||
|
||||
export class RateLimitExceededError extends ExtendableError {}
|
||||
|
||||
export class RequestError extends ExtendableError {}
|
||||
|
||||
+25
-22
@@ -48,16 +48,16 @@
|
||||
"> 0.25%, not dead"
|
||||
],
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.758.0",
|
||||
"@aws-sdk/lib-storage": "3.758.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.758.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.758.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.758.0",
|
||||
"@babel/core": "^7.26.9",
|
||||
"@aws-sdk/client-s3": "3.777.0",
|
||||
"@aws-sdk/lib-storage": "3.777.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.777.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.777.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.775.0",
|
||||
"@babel/core": "^7.26.10",
|
||||
"@babel/plugin-proposal-decorators": "^7.25.9",
|
||||
"@babel/plugin-transform-class-properties": "^7.25.9",
|
||||
"@babel/plugin-transform-destructuring": "^7.25.9",
|
||||
"@babel/plugin-transform-regenerator": "^7.25.9",
|
||||
"@babel/plugin-transform-regenerator": "^7.27.0",
|
||||
"@babel/preset-env": "^7.26.9",
|
||||
"@babel/preset-react": "^7.26.3",
|
||||
"@benrbray/prosemirror-math": "^0.2.2",
|
||||
@@ -79,6 +79,7 @@
|
||||
"@hocuspocus/server": "1.1.2",
|
||||
"@joplin/turndown-plugin-gfm": "^1.0.49",
|
||||
"@juggle/resize-observer": "^3.4.0",
|
||||
"@notionhq/client": "^2.2.16",
|
||||
"@octokit/auth-app": "^6.1.3",
|
||||
"@outlinewiki/koa-passport": "^4.2.1",
|
||||
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
|
||||
@@ -88,13 +89,14 @@
|
||||
"@sentry/node": "^7.120.3",
|
||||
"@sentry/react": "^7.120.3",
|
||||
"@tanstack/react-table": "^8.20.6",
|
||||
"@tanstack/react-virtual": "^3.11.3",
|
||||
"@tanstack/react-virtual": "^3.13.6",
|
||||
"@tippyjs/react": "^4.2.6",
|
||||
"@types/form-data": "^2.5.2",
|
||||
"@types/mailparser": "^3.4.5",
|
||||
"@types/sanitize-filename": "^1.6.3",
|
||||
"@vitejs/plugin-react": "^3.1.0",
|
||||
"addressparser": "^1.0.1",
|
||||
"async-sema": "^3.1.1",
|
||||
"autotrack": "^2.4.1",
|
||||
"babel-plugin-styled-components": "^2.1.4",
|
||||
"babel-plugin-transform-class-properties": "^6.24.1",
|
||||
@@ -108,7 +110,7 @@
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"core-js": "^3.37.0",
|
||||
"crypto-js": "^4.2.0",
|
||||
"datadog-metrics": "^0.11.2",
|
||||
"datadog-metrics": "^0.12.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"dd-trace": "^5.40.0",
|
||||
"diff": "^5.2.0",
|
||||
@@ -127,11 +129,12 @@
|
||||
"fuzzy-search": "^3.2.1",
|
||||
"glob": "^8.1.0",
|
||||
"http-errors": "2.0.0",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"i18next": "^22.5.1",
|
||||
"i18next-fs-backend": "^2.6.0",
|
||||
"i18next-http-backend": "^2.7.3",
|
||||
"invariant": "^2.2.4",
|
||||
"ioredis": "^5.4.1",
|
||||
"ioredis": "^5.6.0",
|
||||
"is-printable-key-event": "^1.0.0",
|
||||
"jsdom": "^22.1.0",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
@@ -170,20 +173,20 @@
|
||||
"passport-oauth2": "^1.8.0",
|
||||
"passport-slack-oauth2": "^1.2.0",
|
||||
"patch-package": "^7.0.2",
|
||||
"pg": "^8.12.0",
|
||||
"pg": "^8.14.1",
|
||||
"pg-tsquery": "^8.4.2",
|
||||
"pluralize": "^8.0.0",
|
||||
"png-chunks-extract": "^1.0.0",
|
||||
"polished": "^4.3.1",
|
||||
"prosemirror-codemark": "^0.4.2",
|
||||
"prosemirror-commands": "^1.6.2",
|
||||
"prosemirror-commands": "^1.7.0",
|
||||
"prosemirror-dropcursor": "^1.8.1",
|
||||
"prosemirror-gapcursor": "^1.3.2",
|
||||
"prosemirror-history": "^1.4.1",
|
||||
"prosemirror-inputrules": "^1.4.0",
|
||||
"prosemirror-keymap": "^1.2.2",
|
||||
"prosemirror-markdown": "^1.13.1",
|
||||
"prosemirror-model": "^1.24.0",
|
||||
"prosemirror-markdown": "^1.13.2",
|
||||
"prosemirror-model": "^1.25.0",
|
||||
"prosemirror-schema-list": "^1.4.1",
|
||||
"prosemirror-state": "^1.4.3",
|
||||
"prosemirror-tables": "^1.6.4",
|
||||
@@ -245,7 +248,7 @@
|
||||
"uuid": "^8.3.2",
|
||||
"validator": "13.12.0",
|
||||
"vaul": "^1.1.2",
|
||||
"vite": "^5.4.14",
|
||||
"vite": "^5.4.16",
|
||||
"vite-plugin-pwa": "^0.20.3",
|
||||
"winston": "^3.17.0",
|
||||
"ws": "^7.5.10",
|
||||
@@ -254,13 +257,13 @@
|
||||
"y-protocols": "^1.0.6",
|
||||
"yauzl": "^2.10.0",
|
||||
"yjs": "^13.6.1",
|
||||
"zod": "^3.23.8"
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.26.4",
|
||||
"@babel/preset-typescript": "^7.26.0",
|
||||
"@babel/cli": "^7.27.0",
|
||||
"@babel/preset-typescript": "^7.27.0",
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@relative-ci/agent": "^4.2.14",
|
||||
"@relative-ci/agent": "^4.3.0",
|
||||
"@testing-library/react": "^12.0.0",
|
||||
"@types/addressparser": "^1.0.3",
|
||||
"@types/body-scroll-lock": "^3.1.2",
|
||||
@@ -293,7 +296,7 @@
|
||||
"@types/markdown-it-emoji": "^2.0.4",
|
||||
"@types/mime-types": "^2.1.4",
|
||||
"@types/natural-sort": "^0.0.24",
|
||||
"@types/node": "20.17.16",
|
||||
"@types/node": "20.17.27",
|
||||
"@types/node-fetch": "^2.6.9",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/passport-oauth2": "^1.4.17",
|
||||
@@ -357,7 +360,7 @@
|
||||
"prettier": "^2.8.8",
|
||||
"react-refresh": "^0.14.2",
|
||||
"rimraf": "^2.5.4",
|
||||
"rollup-plugin-webpack-stats": "^2.0.1",
|
||||
"rollup-plugin-webpack-stats": "^2.0.3",
|
||||
"terser": "^5.39.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vite-plugin-static-copy": "^0.17.0",
|
||||
@@ -375,4 +378,4 @@
|
||||
"prismjs": "1.30.0"
|
||||
},
|
||||
"version": "0.82.0"
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { Hook, PluginManager } from "@server/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import router from "./auth/email";
|
||||
|
||||
const enabled = !!env.SMTP_HOST || env.isDevelopment;
|
||||
const enabled = !!(env.SMTP_HOST || env.SMTP_SERVICE) || env.isDevelopment;
|
||||
|
||||
if (enabled) {
|
||||
PluginManager.add({
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { observer } from "mobx-react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import env from "@shared/env";
|
||||
import { IntegrationService } from "@shared/types";
|
||||
import Button from "~/components/Button";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { redirectTo } from "~/utils/urls";
|
||||
import { NotionUtils } from "../shared/NotionUtils";
|
||||
import { ImportDialog } from "./components/ImportDialog";
|
||||
|
||||
export const Notion = observer(() => {
|
||||
const { t } = useTranslation();
|
||||
const { dialogs } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const queryParams = useQuery();
|
||||
|
||||
const appName = env.APP_NAME;
|
||||
const authUrl = NotionUtils.authUrl({ state: { teamId: team.id } });
|
||||
|
||||
const service = queryParams.get("service");
|
||||
const oauthSuccess = queryParams.get("success") === "";
|
||||
const oauthError = queryParams.get("error");
|
||||
const integrationId = queryParams.get("integrationId");
|
||||
|
||||
const clearQueryParams = React.useCallback(() => {
|
||||
history.replace({
|
||||
pathname: location.pathname,
|
||||
search: "",
|
||||
});
|
||||
}, [history, location]);
|
||||
|
||||
const handleSubmit = React.useCallback(() => {
|
||||
dialogs.closeAllModals();
|
||||
clearQueryParams();
|
||||
}, [dialogs, clearQueryParams]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
integrationId &&
|
||||
oauthSuccess &&
|
||||
service === IntegrationService.Notion
|
||||
) {
|
||||
dialogs.openModal({
|
||||
title: t("Import data"),
|
||||
content: (
|
||||
<ImportDialog integrationId={integrationId} onSubmit={handleSubmit} />
|
||||
),
|
||||
onClose: clearQueryParams,
|
||||
});
|
||||
}
|
||||
}, [t, dialogs, oauthSuccess, service, clearQueryParams]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!oauthError) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (oauthError === "access_denied") {
|
||||
toast.error(
|
||||
t(
|
||||
"Whoops, you need to accept the permissions in Notion to connect {{ appName }} to your workspace. Try again?",
|
||||
{
|
||||
appName,
|
||||
}
|
||||
)
|
||||
);
|
||||
} else {
|
||||
toast.error(
|
||||
t(
|
||||
"Something went wrong while authenticating your request. Please try logging in again."
|
||||
)
|
||||
);
|
||||
}
|
||||
}, [t, appName, oauthError]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={() => redirectTo(authUrl)}
|
||||
disabled={!env.NOTION_CLIENT_ID}
|
||||
neutral
|
||||
>
|
||||
{t("Import")}…
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { ImportInput } from "@shared/schema";
|
||||
import { CollectionPermission, IntegrationService } from "@shared/types";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import InputSelectPermission from "~/components/InputSelectPermission";
|
||||
import Text from "~/components/Text";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { EmptySelectValue } from "~/types";
|
||||
|
||||
type Props = {
|
||||
/** The integrationId associated with this import flow. */
|
||||
integrationId: string;
|
||||
/** Callback to handle import creation. */
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
export function ImportDialog({ integrationId, onSubmit }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { imports } = useStores();
|
||||
const [submitting, setSubmitting, resetSubmitting] = useBoolean();
|
||||
const [permission, setPermission] = React.useState<CollectionPermission>();
|
||||
|
||||
const handlePermissionChange = React.useCallback(
|
||||
(value: CollectionPermission | typeof EmptySelectValue) => {
|
||||
setPermission(value === EmptySelectValue ? undefined : value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleStartImport = React.useCallback(async () => {
|
||||
setSubmitting();
|
||||
|
||||
// TODO: This can send the page info + permission once we overcome the search timeout issues.
|
||||
const input: ImportInput<IntegrationService.Notion> = [{ permission }];
|
||||
|
||||
try {
|
||||
await imports.create(
|
||||
{ service: IntegrationService.Notion },
|
||||
{ integrationId, input }
|
||||
);
|
||||
|
||||
toast.success(
|
||||
t("Your import is being processed, you can safely leave this page")
|
||||
);
|
||||
|
||||
onSubmit();
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
resetSubmitting();
|
||||
}
|
||||
}, [permission, onSubmit]);
|
||||
|
||||
return (
|
||||
<Flex column gap={12}>
|
||||
<div>
|
||||
<InputSelectPermission
|
||||
value={permission}
|
||||
onChange={handlePermissionChange}
|
||||
/>
|
||||
<Text as="span" type="secondary">
|
||||
{t(
|
||||
"Set the default permission level for collections created from the import"
|
||||
)}
|
||||
.
|
||||
</Text>
|
||||
</div>
|
||||
<Flex justify="flex-end">
|
||||
<Button onClick={handleStartImport} disabled={submitting}>
|
||||
{t("Start import")}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { t } from "i18next";
|
||||
import * as React from "react";
|
||||
import { cdnPath } from "@shared/utils/urls";
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import { Notion } from "./Imports";
|
||||
|
||||
PluginManager.add([
|
||||
{
|
||||
...config,
|
||||
type: Hook.Imports,
|
||||
value: {
|
||||
title: "Notion",
|
||||
subtitle: t("Import pages from Notion"),
|
||||
icon: <img src={cdnPath("/images/notion.png")} width={28} />,
|
||||
action: <Notion />,
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"id": "notion",
|
||||
"name": "Notion",
|
||||
"description": "Adds a Notion integration for importing data."
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import Router from "koa-router";
|
||||
import { IntegrationService, IntegrationType } from "@shared/types";
|
||||
import { parseDomain } from "@shared/utils/domains";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { transaction } from "@server/middlewares/transaction";
|
||||
import validate from "@server/middlewares/validate";
|
||||
import { Integration, IntegrationAuthentication, Team } from "@server/models";
|
||||
import { APIContext } from "@server/types";
|
||||
import { NotionClient } from "../notion";
|
||||
import * as T from "./schema";
|
||||
import { NotionUtils } from "plugins/notion/shared/NotionUtils";
|
||||
|
||||
const router = new Router();
|
||||
|
||||
router.get(
|
||||
"notion.callback",
|
||||
auth({ optional: true }),
|
||||
validate(T.NotionCallbackSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.NotionCallbackReq>) => {
|
||||
const { code, state, error } = ctx.input.query;
|
||||
const { user } = ctx.state.auth;
|
||||
const { transaction } = ctx.state;
|
||||
|
||||
let parsedState;
|
||||
try {
|
||||
parsedState = NotionUtils.parseState(state);
|
||||
} catch {
|
||||
ctx.redirect(NotionUtils.errorUrl("invalid_state"));
|
||||
return;
|
||||
}
|
||||
|
||||
const { teamId } = parsedState;
|
||||
|
||||
// This code block accounts for the root domain being unable to access authentication for subdomains.
|
||||
// We must forward to the appropriate subdomain to complete the oauth flow.
|
||||
if (!user) {
|
||||
if (teamId) {
|
||||
try {
|
||||
const team = await Team.findByPk(teamId, {
|
||||
rejectOnEmpty: true,
|
||||
transaction,
|
||||
});
|
||||
|
||||
return parseDomain(ctx.host).teamSubdomain === team.subdomain
|
||||
? ctx.redirect("/")
|
||||
: ctx.redirectOnClient(
|
||||
NotionUtils.callbackUrl({
|
||||
baseUrl: team.url,
|
||||
params: ctx.request.querystring,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
Logger.error(`Error fetching team for teamId: ${teamId}!`, err);
|
||||
return ctx.redirect(NotionUtils.errorUrl("unauthenticated"));
|
||||
}
|
||||
} else {
|
||||
return ctx.redirect(NotionUtils.errorUrl("unauthenticated"));
|
||||
}
|
||||
}
|
||||
|
||||
// Check error after any sub-domain redirection. Otherwise, the user will be redirected to the root domain.
|
||||
if (error) {
|
||||
ctx.redirect(NotionUtils.errorUrl(error));
|
||||
return;
|
||||
}
|
||||
|
||||
// validation middleware ensures that code is non-null at this point.
|
||||
const data = await NotionClient.oauthAccess(code!);
|
||||
|
||||
const authentication = await IntegrationAuthentication.create(
|
||||
{
|
||||
service: IntegrationService.Notion,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
token: data.access_token,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
const integration = await Integration.create<
|
||||
Integration<IntegrationType.Import>
|
||||
>(
|
||||
{
|
||||
service: IntegrationService.Notion,
|
||||
type: IntegrationType.Import,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
authenticationId: authentication.id,
|
||||
settings: {
|
||||
externalWorkspace: {
|
||||
id: data.workspace_id,
|
||||
name: data.workspace_name ?? "Notion import",
|
||||
iconUrl: data.workspace_icon ?? undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
ctx.redirect(NotionUtils.successUrl(integration.id));
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,25 @@
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import { z } from "zod";
|
||||
import { BaseSchema } from "@server/routes/api/schema";
|
||||
|
||||
export const NotionCallbackSchema = BaseSchema.extend({
|
||||
query: z
|
||||
.object({
|
||||
code: z.string().nullish(),
|
||||
state: z.string(),
|
||||
error: z.string().nullish(),
|
||||
})
|
||||
.refine((req) => !(isEmpty(req.code) && isEmpty(req.error)), {
|
||||
message: "one of code or error is required",
|
||||
}),
|
||||
});
|
||||
|
||||
export type NotionCallbackReq = z.infer<typeof NotionCallbackSchema>;
|
||||
|
||||
export const NotionSearchSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
integrationId: z.string().uuid(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type NotionSearchReq = z.infer<typeof NotionSearchSchema>;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { IsOptional } from "class-validator";
|
||||
import { Environment } from "@server/env";
|
||||
import { Public } from "@server/utils/decorators/Public";
|
||||
import environment from "@server/utils/environment";
|
||||
import { CannotUseWithout } from "@server/utils/validators";
|
||||
|
||||
class NotionPluginEnvironment extends Environment {
|
||||
@Public
|
||||
@IsOptional()
|
||||
public NOTION_CLIENT_ID = this.toOptionalString(environment.NOTION_CLIENT_ID);
|
||||
|
||||
@IsOptional()
|
||||
@CannotUseWithout("NOTION_CLIENT_ID")
|
||||
public NOTION_CLIENT_SECRET = this.toOptionalString(
|
||||
environment.NOTION_CLIENT_SECRET
|
||||
);
|
||||
}
|
||||
|
||||
export default new NotionPluginEnvironment();
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Hook, PluginManager } from "@server/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import router from "./api/notion";
|
||||
import env from "./env";
|
||||
import { NotionImportsProcessor } from "./processors/NotionImportsProcessor";
|
||||
import NotionAPIImportTask from "./tasks/NotionAPIImportTask";
|
||||
|
||||
const enabled = !!env.NOTION_CLIENT_ID && !!env.NOTION_CLIENT_SECRET;
|
||||
|
||||
if (enabled) {
|
||||
PluginManager.add([
|
||||
{
|
||||
...config,
|
||||
type: Hook.API,
|
||||
value: router,
|
||||
},
|
||||
{
|
||||
type: Hook.Processor,
|
||||
value: NotionImportsProcessor,
|
||||
},
|
||||
{
|
||||
type: Hook.Task,
|
||||
value: NotionAPIImportTask,
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import {
|
||||
APIErrorCode,
|
||||
APIResponseError,
|
||||
Client,
|
||||
isFullPage,
|
||||
isFullPageOrDatabase,
|
||||
isFullUser,
|
||||
} from "@notionhq/client";
|
||||
import {
|
||||
BlockObjectResponse,
|
||||
DatabaseObjectResponse,
|
||||
PageObjectResponse,
|
||||
RichTextItemResponse,
|
||||
} from "@notionhq/client/build/src/api-endpoints";
|
||||
import { RateLimit } from "async-sema";
|
||||
import compact from "lodash/compact";
|
||||
import { z } from "zod";
|
||||
import { Second } from "@shared/utils/time";
|
||||
import { NotionUtils } from "../shared/NotionUtils";
|
||||
import { Block, Page, PageType } from "../shared/types";
|
||||
import env from "./env";
|
||||
|
||||
type PageInfo = {
|
||||
title: string;
|
||||
emoji?: string;
|
||||
author?: string;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
};
|
||||
|
||||
const Credentials = Buffer.from(
|
||||
`${env.NOTION_CLIENT_ID}:${env.NOTION_CLIENT_SECRET}`
|
||||
).toString("base64");
|
||||
|
||||
const AccessTokenResponseSchema = z.object({
|
||||
access_token: z.string(),
|
||||
bot_id: z.string(),
|
||||
workspace_id: z.string(),
|
||||
workspace_name: z.string().nullish(),
|
||||
workspace_icon: z.string().url().nullish(),
|
||||
});
|
||||
|
||||
export class NotionClient {
|
||||
private client: Client;
|
||||
private limiter: ReturnType<typeof RateLimit>;
|
||||
private pageSize = 25;
|
||||
|
||||
constructor(
|
||||
accessToken: string,
|
||||
rateLimit: { window: number; limit: number } = {
|
||||
window: Second.ms,
|
||||
limit: 3,
|
||||
}
|
||||
) {
|
||||
this.client = new Client({
|
||||
auth: accessToken,
|
||||
});
|
||||
this.limiter = RateLimit(rateLimit.limit, {
|
||||
timeUnit: rateLimit.window,
|
||||
uniformDistribution: true,
|
||||
});
|
||||
}
|
||||
|
||||
static async oauthAccess(code: string) {
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
Authorization: `Basic ${Credentials}`,
|
||||
};
|
||||
const body = {
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: NotionUtils.callbackUrl(),
|
||||
};
|
||||
|
||||
const res = await fetch(NotionUtils.tokenUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
return AccessTokenResponseSchema.parse(await res.json());
|
||||
}
|
||||
|
||||
async fetchRootPages() {
|
||||
const pages: Page[] = [];
|
||||
|
||||
let cursor: string | undefined;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
await this.limiter();
|
||||
|
||||
const response = await this.client.search({
|
||||
start_cursor: cursor,
|
||||
page_size: this.pageSize,
|
||||
});
|
||||
|
||||
response.results.forEach((item) => {
|
||||
if (!isFullPageOrDatabase(item)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.parent.type === "workspace") {
|
||||
pages.push({
|
||||
type: item.object === "page" ? PageType.Page : PageType.Database,
|
||||
id: item.id,
|
||||
name: this.parseTitle(item),
|
||||
emoji: this.parseEmoji(item),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
hasMore = response.has_more;
|
||||
cursor = response.next_cursor ?? undefined;
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
async fetchPage(pageId: string) {
|
||||
const pageInfo = await this.fetchPageInfo(pageId);
|
||||
const blocks = await this.fetchBlockChildren(pageId);
|
||||
return { ...pageInfo, blocks };
|
||||
}
|
||||
|
||||
async fetchDatabase(databaseId: string) {
|
||||
const databaseInfo = await this.fetchDatabaseInfo(databaseId);
|
||||
const pages = await this.queryDatabase(databaseId);
|
||||
return { ...databaseInfo, pages };
|
||||
}
|
||||
|
||||
private async fetchBlockChildren(blockId: string) {
|
||||
const blocks: Block[] = [];
|
||||
|
||||
let cursor: string | undefined;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
await this.limiter();
|
||||
|
||||
const response = await this.client.blocks.children.list({
|
||||
block_id: blockId,
|
||||
start_cursor: cursor,
|
||||
page_size: this.pageSize,
|
||||
});
|
||||
|
||||
blocks.push(...(response.results as BlockObjectResponse[]));
|
||||
|
||||
hasMore = response.has_more;
|
||||
cursor = response.next_cursor ?? undefined;
|
||||
}
|
||||
|
||||
// Recursive fetch when direct children have their own children.
|
||||
await Promise.all(
|
||||
blocks.map(async (block) => {
|
||||
if (
|
||||
block.has_children &&
|
||||
block.type !== "child_page" &&
|
||||
block.type !== "child_database"
|
||||
) {
|
||||
block.children = await this.fetchBlockChildren(block.id);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
private async queryDatabase(databaseId: string) {
|
||||
const pages: Page[] = [];
|
||||
|
||||
let cursor: string | undefined;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
await this.limiter();
|
||||
|
||||
const response = await this.client.databases.query({
|
||||
database_id: databaseId,
|
||||
filter_properties: ["title"],
|
||||
start_cursor: cursor,
|
||||
page_size: this.pageSize,
|
||||
});
|
||||
|
||||
const pagesFromRes = compact(
|
||||
response.results.map<Page | undefined>((item) => {
|
||||
if (!isFullPage(item)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
type: PageType.Page,
|
||||
id: item.id,
|
||||
name: this.parseTitle(item),
|
||||
emoji: this.parseEmoji(item),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
pages.push(...pagesFromRes);
|
||||
|
||||
hasMore = response.has_more;
|
||||
cursor = response.next_cursor ?? undefined;
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
private async fetchPageInfo(pageId: string): Promise<PageInfo> {
|
||||
await this.limiter();
|
||||
const page = (await this.client.pages.retrieve({
|
||||
page_id: pageId,
|
||||
})) as PageObjectResponse;
|
||||
|
||||
const author = await this.fetchUsername(page.created_by.id);
|
||||
|
||||
return {
|
||||
title: this.parseTitle(page),
|
||||
emoji: this.parseEmoji(page),
|
||||
author: author ?? undefined,
|
||||
createdAt: !page.created_time ? undefined : new Date(page.created_time),
|
||||
updatedAt: !page.last_edited_time
|
||||
? undefined
|
||||
: new Date(page.last_edited_time),
|
||||
};
|
||||
}
|
||||
|
||||
private async fetchDatabaseInfo(databaseId: string): Promise<PageInfo> {
|
||||
await this.limiter();
|
||||
const database = (await this.client.databases.retrieve({
|
||||
database_id: databaseId,
|
||||
})) as DatabaseObjectResponse;
|
||||
|
||||
const author = await this.fetchUsername(database.created_by.id);
|
||||
|
||||
return {
|
||||
title: this.parseTitle(database),
|
||||
emoji: this.parseEmoji(database),
|
||||
author: author ?? undefined,
|
||||
createdAt: !database.created_time
|
||||
? undefined
|
||||
: new Date(database.created_time),
|
||||
updatedAt: !database.last_edited_time
|
||||
? undefined
|
||||
: new Date(database.last_edited_time),
|
||||
};
|
||||
}
|
||||
|
||||
private async fetchUsername(userId: string) {
|
||||
await this.limiter();
|
||||
try {
|
||||
const user = await this.client.users.retrieve({ user_id: userId });
|
||||
|
||||
if (user.type === "person" || !user.bot.owner) {
|
||||
return user.name;
|
||||
}
|
||||
|
||||
// bot belongs to a user, get the user's name.
|
||||
if (user.bot.owner.type === "user" && isFullUser(user.bot.owner.user)) {
|
||||
return user.bot.owner.user.name;
|
||||
}
|
||||
|
||||
// bot belongs to a workspace, fallback to bot's name.
|
||||
return user.name;
|
||||
} catch (error) {
|
||||
// Handle the case where a user can't be found
|
||||
if (
|
||||
error instanceof APIResponseError &&
|
||||
error.code === APIErrorCode.ObjectNotFound
|
||||
) {
|
||||
return "Unknown";
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private parseTitle(item: PageObjectResponse | DatabaseObjectResponse) {
|
||||
let richTexts: RichTextItemResponse[];
|
||||
|
||||
if (item.object === "page") {
|
||||
const titleProp = Object.values(item.properties).find(
|
||||
(property) => property.type === "title"
|
||||
);
|
||||
richTexts = titleProp?.title ?? [];
|
||||
} else {
|
||||
richTexts = item.title;
|
||||
}
|
||||
|
||||
return richTexts.map((richText) => richText.plain_text).join("");
|
||||
}
|
||||
|
||||
private parseEmoji(item: PageObjectResponse | DatabaseObjectResponse) {
|
||||
// Other icon types return a url to download from, which we don't support.
|
||||
return item.icon?.type === "emoji" ? item.icon.emoji : undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Transaction } from "sequelize";
|
||||
import { NotionImportInput, NotionImportTaskInput } from "@shared/schema";
|
||||
import { IntegrationService } from "@shared/types";
|
||||
import { Import, ImportTask, Integration } from "@server/models";
|
||||
import ImportsProcessor from "@server/queues/processors/ImportsProcessor";
|
||||
import { NotionClient } from "../notion";
|
||||
import NotionAPIImportTask from "../tasks/NotionAPIImportTask";
|
||||
|
||||
export class NotionImportsProcessor extends ImportsProcessor<IntegrationService.Notion> {
|
||||
/**
|
||||
* Determine whether this is a "Notion" import.
|
||||
*
|
||||
* @param importModel Import model associated with the import.
|
||||
* @returns boolean.
|
||||
*/
|
||||
protected canProcess(
|
||||
importModel: Import<IntegrationService.Notion>
|
||||
): boolean {
|
||||
return importModel.service === IntegrationService.Notion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build task inputs which will be used for `NotionAPIImportTask`s.
|
||||
*
|
||||
* @param importInput Array of root externalId and associated info which were used to create the import.
|
||||
* @returns `NotionImportTaskInput`.
|
||||
*/
|
||||
protected async buildTasksInput(
|
||||
importModel: Import<IntegrationService.Notion>,
|
||||
transaction: Transaction
|
||||
): Promise<NotionImportTaskInput> {
|
||||
const integration = await Integration.scope("withAuthentication").findByPk(
|
||||
importModel.integrationId,
|
||||
{ rejectOnEmpty: true }
|
||||
);
|
||||
|
||||
const notion = new NotionClient(integration.authentication.token);
|
||||
|
||||
const rootPages = await notion.fetchRootPages();
|
||||
|
||||
// App will send the default permission in an array with single item.
|
||||
const defaultPermission = importModel.input[0].permission;
|
||||
|
||||
// TODO: This update can be deleted when we receive the page info + permission from app.
|
||||
const importInput: NotionImportInput = rootPages.map((page) => ({
|
||||
type: page.type,
|
||||
externalId: page.id,
|
||||
permission: defaultPermission,
|
||||
}));
|
||||
|
||||
importModel.input = importInput;
|
||||
await importModel.save({ transaction });
|
||||
|
||||
return rootPages.map((page) => ({
|
||||
type: page.type,
|
||||
externalId: page.id,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule the first `NotionAPIImportTask` for the import.
|
||||
*
|
||||
* @param importTask ImportTask model associated with the `NotionAPIImportTask`.
|
||||
* @returns Promise that resolves when the task is scheduled.
|
||||
*/
|
||||
protected async scheduleTask(
|
||||
importTask: ImportTask<IntegrationService.Notion>
|
||||
): Promise<void> {
|
||||
await NotionAPIImportTask.schedule({ importTaskId: importTask.id });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { APIResponseError, APIErrorCode } from "@notionhq/client";
|
||||
import { ImportTaskInput, ImportTaskOutput } from "@shared/schema";
|
||||
import { IntegrationService, ProsemirrorDoc } from "@shared/types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { Integration } from "@server/models";
|
||||
import ImportTask from "@server/models/ImportTask";
|
||||
import APIImportTask, {
|
||||
ProcessOutput,
|
||||
} from "@server/queues/tasks/APIImportTask";
|
||||
import { Block, PageType } from "../../shared/types";
|
||||
import { NotionClient } from "../notion";
|
||||
import { NotionConverter, NotionPage } from "../utils/NotionConverter";
|
||||
|
||||
type ChildPage = { type: PageType; externalId: string };
|
||||
|
||||
type ParsePageOutput = ImportTaskOutput[number] & {
|
||||
collectionExternalId?: string;
|
||||
children: ChildPage[];
|
||||
};
|
||||
|
||||
export default class NotionAPIImportTask extends APIImportTask<IntegrationService.Notion> {
|
||||
/**
|
||||
* Process the Notion import task.
|
||||
* This fetches data from Notion and converts it to task output.
|
||||
*
|
||||
* @param importTask ImportTask model to process.
|
||||
* @returns Promise with output that resolves once processing has completed.
|
||||
*/
|
||||
protected async process(
|
||||
importTask: ImportTask<IntegrationService.Notion>
|
||||
): Promise<ProcessOutput<IntegrationService.Notion>> {
|
||||
const integration = await Integration.scope("withAuthentication").findByPk(
|
||||
importTask.import.integrationId,
|
||||
{ rejectOnEmpty: true }
|
||||
);
|
||||
|
||||
const client = new NotionClient(integration.authentication.token);
|
||||
|
||||
const parsedPages = await Promise.all(
|
||||
importTask.input.map(async (item) => this.processPage({ item, client }))
|
||||
);
|
||||
|
||||
// Filter out any null results (from pages/databases that couldn't be accessed)
|
||||
const validParsedPages = parsedPages.filter(Boolean) as ParsePageOutput[];
|
||||
|
||||
const taskOutput: ImportTaskOutput = validParsedPages.map((parsedPage) => ({
|
||||
externalId: parsedPage.externalId,
|
||||
title: parsedPage.title,
|
||||
emoji: parsedPage.emoji,
|
||||
content: parsedPage.content,
|
||||
author: parsedPage.author,
|
||||
createdAt: parsedPage.createdAt,
|
||||
updatedAt: parsedPage.updatedAt,
|
||||
}));
|
||||
|
||||
const childTasksInput: ImportTaskInput<IntegrationService.Notion> =
|
||||
validParsedPages.flatMap((parsedPage) =>
|
||||
parsedPage.children.map((childPage) => ({
|
||||
type: childPage.type,
|
||||
externalId: childPage.externalId,
|
||||
parentExternalId: parsedPage.externalId,
|
||||
collectionExternalId: parsedPage.collectionExternalId,
|
||||
}))
|
||||
);
|
||||
|
||||
return { taskOutput, childTasksInput };
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule the next `NotionAPIImportTask`.
|
||||
*
|
||||
* @param importTask ImportTask model associated with the `NotionAPIImportTask`.
|
||||
* @returns Promise that resolves when the task is scheduled.
|
||||
*/
|
||||
protected async scheduleNextTask(
|
||||
importTask: ImportTask<IntegrationService.Notion>
|
||||
) {
|
||||
await NotionAPIImportTask.schedule({ importTaskId: importTask.id });
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch page data from Notion and convert it to expected output.
|
||||
*
|
||||
* @param item Object containing data about a notion page (or) database.
|
||||
* @param client Notion client.
|
||||
* @returns Promise of parsed page output that resolves when the task is scheduled.
|
||||
*/
|
||||
private async processPage({
|
||||
item,
|
||||
client,
|
||||
}: {
|
||||
item: ImportTaskInput<IntegrationService.Notion>[number];
|
||||
client: NotionClient;
|
||||
}): Promise<ParsePageOutput | null> {
|
||||
const collectionExternalId = item.collectionExternalId ?? item.externalId;
|
||||
|
||||
try {
|
||||
// Convert Notion database to an empty page with "pages in database" as its children.
|
||||
if (item.type === PageType.Database) {
|
||||
const { pages, ...databaseInfo } = await client.fetchDatabase(
|
||||
item.externalId
|
||||
);
|
||||
|
||||
return {
|
||||
...databaseInfo,
|
||||
externalId: item.externalId,
|
||||
content: ProsemirrorHelper.getEmptyDocument() as ProsemirrorDoc,
|
||||
collectionExternalId,
|
||||
children: pages.map((page) => ({
|
||||
type: page.type,
|
||||
externalId: page.id,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const { blocks, ...pageInfo } = await client.fetchPage(item.externalId);
|
||||
|
||||
return {
|
||||
...pageInfo,
|
||||
externalId: item.externalId,
|
||||
content: NotionConverter.page({ children: blocks } as NotionPage),
|
||||
collectionExternalId,
|
||||
children: this.parseChildPages(blocks),
|
||||
};
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Notion page blocks to get its child pages and databases.
|
||||
*
|
||||
* @param pageBlocks Array of blocks representing the page's content.
|
||||
* @returns Array containing child page and child database info.
|
||||
*/
|
||||
private parseChildPages(pageBlocks: Block[]): ChildPage[] {
|
||||
const childPages: ChildPage[] = [];
|
||||
|
||||
pageBlocks.forEach((block) => {
|
||||
if (block.type === "child_page") {
|
||||
childPages.push({ type: PageType.Page, externalId: block.id });
|
||||
} else if (block.type === "child_database") {
|
||||
childPages.push({ type: PageType.Database, externalId: block.id });
|
||||
} else if (block.children?.length) {
|
||||
childPages.push(...this.parseChildPages(block.children));
|
||||
}
|
||||
});
|
||||
|
||||
return childPages;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Node } from "prosemirror-model";
|
||||
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
|
||||
import nodesWithEmptyTextNode from "@server/test/fixtures/notion-page-with-empty-text-nodes.json";
|
||||
import allNodes from "@server/test/fixtures/notion-page.json";
|
||||
import { NotionConverter, NotionPage } from "./NotionConverter";
|
||||
|
||||
describe("NotionConverter", () => {
|
||||
it("converts a page", () => {
|
||||
const response = NotionConverter.page({
|
||||
children: allNodes,
|
||||
} as NotionPage);
|
||||
|
||||
expect(response).toMatchSnapshot();
|
||||
expect(ProsemirrorHelper.toProsemirror(response)).toBeInstanceOf(Node);
|
||||
});
|
||||
|
||||
it("converts a page with empty text nodes", () => {
|
||||
const response = NotionConverter.page({
|
||||
children: nodesWithEmptyTextNode,
|
||||
} as NotionPage);
|
||||
|
||||
expect(response).toMatchSnapshot();
|
||||
expect(ProsemirrorHelper.toProsemirror(response)).toBeInstanceOf(Node);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,587 @@
|
||||
import type {
|
||||
BookmarkBlockObjectResponse,
|
||||
BreadcrumbBlockObjectResponse,
|
||||
BulletedListItemBlockObjectResponse,
|
||||
DividerBlockObjectResponse,
|
||||
Heading1BlockObjectResponse,
|
||||
Heading2BlockObjectResponse,
|
||||
Heading3BlockObjectResponse,
|
||||
NumberedListItemBlockObjectResponse,
|
||||
ParagraphBlockObjectResponse,
|
||||
QuoteBlockObjectResponse,
|
||||
RichTextItemResponse,
|
||||
FileBlockObjectResponse,
|
||||
PdfBlockObjectResponse,
|
||||
ImageBlockObjectResponse,
|
||||
EmbedBlockObjectResponse,
|
||||
TableBlockObjectResponse,
|
||||
ToDoBlockObjectResponse,
|
||||
EquationBlockObjectResponse,
|
||||
CodeBlockObjectResponse,
|
||||
ToggleBlockObjectResponse,
|
||||
PageObjectResponse,
|
||||
VideoBlockObjectResponse,
|
||||
CalloutBlockObjectResponse,
|
||||
ColumnListBlockObjectResponse,
|
||||
ColumnBlockObjectResponse,
|
||||
LinkPreviewBlockObjectResponse,
|
||||
SyncedBlockBlockObjectResponse,
|
||||
LinkToPageBlockObjectResponse,
|
||||
} from "@notionhq/client/build/src/api-endpoints";
|
||||
import isArray from "lodash/isArray";
|
||||
import { NoticeTypes } from "@shared/editor/nodes/Notice";
|
||||
import { MentionType, ProsemirrorData, ProsemirrorDoc } from "@shared/types";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { Block } from "../../shared/types";
|
||||
|
||||
export type NotionPage = PageObjectResponse & {
|
||||
children: Block[];
|
||||
};
|
||||
|
||||
/** Convert Notion blocks to Outline data. */
|
||||
export class NotionConverter {
|
||||
/**
|
||||
* Nodes which cannot contain block children in Outline, their children
|
||||
* will be flattened into the parent.
|
||||
*/
|
||||
private static nodesWithoutBlockChildren = ["paragraph", "toggle"];
|
||||
|
||||
public static page(item: NotionPage): ProsemirrorDoc {
|
||||
return {
|
||||
type: "doc",
|
||||
content: this.mapChildren(item),
|
||||
};
|
||||
}
|
||||
|
||||
private static mapChildren(item: Block | NotionPage) {
|
||||
const mapChild = (
|
||||
child: Block
|
||||
): ProsemirrorData | ProsemirrorData[] | undefined => {
|
||||
if (child.type === "child_page" || child.type === "child_database") {
|
||||
return; // this will be created as a nested page, no need to handle/convert.
|
||||
}
|
||||
|
||||
// @ts-expect-error Not all blocks have an interface
|
||||
if (this[child.type]) {
|
||||
// @ts-expect-error Not all blocks have an interface
|
||||
const response = this[child.type](child);
|
||||
if (
|
||||
response &&
|
||||
this.nodesWithoutBlockChildren.includes(response.type) &&
|
||||
"children" in child
|
||||
) {
|
||||
return [response, ...this.mapChildren(child)];
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
Logger.warn("Encountered unknown Notion block", child);
|
||||
return undefined;
|
||||
};
|
||||
|
||||
let wrappingList;
|
||||
const children = [] as ProsemirrorData[];
|
||||
|
||||
if (!item.children) {
|
||||
return [];
|
||||
}
|
||||
|
||||
for (const child of item.children) {
|
||||
const mapped = mapChild(child);
|
||||
if (!mapped) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ensure lists are wrapped correctly – we require a wrapping element
|
||||
// whereas Notion does not
|
||||
// TODO: Handle mixed list
|
||||
if (child.type === "numbered_list_item") {
|
||||
if (!wrappingList) {
|
||||
wrappingList = {
|
||||
type: "ordered_list",
|
||||
content: [] as ProsemirrorData[],
|
||||
};
|
||||
}
|
||||
|
||||
wrappingList.content.push(...(isArray(mapped) ? mapped : [mapped]));
|
||||
continue;
|
||||
}
|
||||
if (child.type === "bulleted_list_item") {
|
||||
if (!wrappingList) {
|
||||
wrappingList = {
|
||||
type: "bullet_list",
|
||||
content: [] as ProsemirrorData[],
|
||||
};
|
||||
}
|
||||
|
||||
wrappingList.content.push(...(isArray(mapped) ? mapped : [mapped]));
|
||||
continue;
|
||||
}
|
||||
if (child.type === "to_do") {
|
||||
if (!wrappingList) {
|
||||
wrappingList = {
|
||||
type: "checkbox_list",
|
||||
content: [] as ProsemirrorData[],
|
||||
};
|
||||
}
|
||||
|
||||
wrappingList.content.push(...(isArray(mapped) ? mapped : [mapped]));
|
||||
continue;
|
||||
}
|
||||
if (wrappingList) {
|
||||
children.push(wrappingList);
|
||||
wrappingList = undefined;
|
||||
}
|
||||
children.push(...(isArray(mapped) ? mapped : [mapped]));
|
||||
}
|
||||
|
||||
if (wrappingList) {
|
||||
children.push(wrappingList);
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
private static callout(item: Block<CalloutBlockObjectResponse>) {
|
||||
const colorToNoticeType: Record<string, NoticeTypes> = {
|
||||
default_background: NoticeTypes.Info,
|
||||
blue_background: NoticeTypes.Info,
|
||||
purple_background: NoticeTypes.Info,
|
||||
green_background: NoticeTypes.Success,
|
||||
orange_background: NoticeTypes.Tip,
|
||||
yellow_background: NoticeTypes.Tip,
|
||||
pink_background: NoticeTypes.Warning,
|
||||
red_background: NoticeTypes.Warning,
|
||||
};
|
||||
|
||||
return {
|
||||
type: "container_notice",
|
||||
attrs: {
|
||||
style:
|
||||
colorToNoticeType[item.callout.color as string] ?? NoticeTypes.Info,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: item.callout.rich_text.map(this.rich_text).filter(Boolean),
|
||||
},
|
||||
...this.mapChildren(item),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private static column_list(item: Block<ColumnListBlockObjectResponse>) {
|
||||
return this.mapChildren(item);
|
||||
}
|
||||
|
||||
private static column(item: Block<ColumnBlockObjectResponse>) {
|
||||
return this.mapChildren(item);
|
||||
}
|
||||
|
||||
private static bookmark(item: BookmarkBlockObjectResponse) {
|
||||
const caption = item.bookmark.caption
|
||||
.map(this.rich_text_to_plaintext)
|
||||
.join("");
|
||||
|
||||
return {
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
text: caption || item.bookmark.url,
|
||||
type: "text",
|
||||
marks: [
|
||||
{
|
||||
type: "link",
|
||||
attrs: {
|
||||
href: item.bookmark.url,
|
||||
title: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private static breadcrumb(_: BreadcrumbBlockObjectResponse) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static bulleted_list_item(
|
||||
item: Block<BulletedListItemBlockObjectResponse>
|
||||
) {
|
||||
return {
|
||||
type: "list_item",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: item.bulleted_list_item.rich_text
|
||||
.map(this.rich_text)
|
||||
.filter(Boolean),
|
||||
},
|
||||
...this.mapChildren(item),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private static code(item: CodeBlockObjectResponse) {
|
||||
const text = item.code.rich_text.map(this.rich_text_to_plaintext).join("");
|
||||
|
||||
return {
|
||||
type: "code_fence",
|
||||
attrs: {
|
||||
language: item.code.language,
|
||||
},
|
||||
content: text ? [{ type: "text", text }] : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private static numbered_list_item(
|
||||
item: Block<NumberedListItemBlockObjectResponse>
|
||||
) {
|
||||
return {
|
||||
type: "list_item",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: item.numbered_list_item.rich_text
|
||||
.map(this.rich_text)
|
||||
.filter(Boolean),
|
||||
},
|
||||
...this.mapChildren(item),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private static rich_text(item: RichTextItemResponse) {
|
||||
const annotationToMark: Record<
|
||||
keyof RichTextItemResponse["annotations"],
|
||||
string
|
||||
> = {
|
||||
bold: "strong",
|
||||
code: "code_inline",
|
||||
italic: "em",
|
||||
underline: "underline",
|
||||
strikethrough: "strikethrough",
|
||||
color: "highlight",
|
||||
};
|
||||
|
||||
const mapAttrs = () =>
|
||||
Object.entries(item.annotations)
|
||||
.filter(([key]) => key !== "color")
|
||||
.filter(([, enabled]) => enabled)
|
||||
.map(([key]) => ({
|
||||
type: annotationToMark[key as keyof typeof annotationToMark],
|
||||
}));
|
||||
|
||||
if (item.type === "mention") {
|
||||
if (item.mention.type === "page") {
|
||||
return {
|
||||
type: "mention",
|
||||
attrs: {
|
||||
type: MentionType.Document,
|
||||
label: item.plain_text,
|
||||
modelId: item.mention.page.id,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (item.mention.type === "link_mention") {
|
||||
return {
|
||||
type: "text",
|
||||
text: item.plain_text,
|
||||
marks: [
|
||||
{
|
||||
type: "link",
|
||||
attrs: {
|
||||
href: item.mention.link_mention.href,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (item.mention.type === "link_preview") {
|
||||
return {
|
||||
type: "text",
|
||||
text: item.plain_text,
|
||||
marks: [
|
||||
{
|
||||
type: "link",
|
||||
attrs: {
|
||||
href: item.mention.link_preview.url,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (!item.plain_text) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "text",
|
||||
text: item.plain_text,
|
||||
};
|
||||
}
|
||||
|
||||
if (item.type === "equation") {
|
||||
return {
|
||||
type: "math_inline",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: item.equation.expression,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (!item.text.content) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "text",
|
||||
text: item.text.content,
|
||||
marks: [
|
||||
...mapAttrs(),
|
||||
...(item.text.link
|
||||
? [{ type: "link", attrs: { href: item.text.link.url } }]
|
||||
: []),
|
||||
].filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
private static rich_text_to_plaintext(item: RichTextItemResponse) {
|
||||
return item.plain_text;
|
||||
}
|
||||
|
||||
private static divider(_: DividerBlockObjectResponse) {
|
||||
return {
|
||||
type: "hr",
|
||||
};
|
||||
}
|
||||
|
||||
private static equation(item: EquationBlockObjectResponse) {
|
||||
return {
|
||||
type: "math_block",
|
||||
content: item.equation.expression
|
||||
? [
|
||||
{
|
||||
type: "text",
|
||||
text: item.equation.expression,
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private static embed(item: EmbedBlockObjectResponse) {
|
||||
return {
|
||||
type: "embed",
|
||||
attrs: {
|
||||
href: item.embed.url,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static file(item: FileBlockObjectResponse) {
|
||||
return {
|
||||
type: "attachment",
|
||||
attrs: {
|
||||
href: "file" in item.file ? item.file.file.url : item.file.external.url,
|
||||
title: item.file.name,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static pdf(item: PdfBlockObjectResponse) {
|
||||
return {
|
||||
type: "attachment",
|
||||
attrs: {
|
||||
href: "file" in item.pdf ? item.pdf.file.url : item.pdf.external.url,
|
||||
title: item.pdf.caption.map(this.rich_text_to_plaintext).join(""),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static heading_1(item: Heading1BlockObjectResponse) {
|
||||
return {
|
||||
type: "heading",
|
||||
attrs: {
|
||||
level: 1,
|
||||
},
|
||||
content: item.heading_1.rich_text.map(this.rich_text).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
private static heading_2(item: Heading2BlockObjectResponse) {
|
||||
return {
|
||||
type: "heading",
|
||||
attrs: {
|
||||
level: 2,
|
||||
},
|
||||
content: item.heading_2.rich_text.map(this.rich_text).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
private static heading_3(item: Heading3BlockObjectResponse) {
|
||||
return {
|
||||
type: "heading",
|
||||
attrs: {
|
||||
level: 3,
|
||||
},
|
||||
content: item.heading_3.rich_text.map(this.rich_text).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
private static image(item: ImageBlockObjectResponse) {
|
||||
return {
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "image",
|
||||
attrs: {
|
||||
src:
|
||||
"file" in item.image
|
||||
? item.image.file.url
|
||||
: item.image.external.url,
|
||||
alt: item.image.caption.map(this.rich_text_to_plaintext).join(""),
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private static link_preview(item: LinkPreviewBlockObjectResponse) {
|
||||
return {
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: item.link_preview.url,
|
||||
marks: [
|
||||
{
|
||||
type: "link",
|
||||
attrs: {
|
||||
href: item.link_preview.url,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private static link_to_page(item: LinkToPageBlockObjectResponse) {
|
||||
if (item.link_to_page.type !== "page_id") {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
type: "mention",
|
||||
attrs: {
|
||||
modelId: item.link_to_page.page_id,
|
||||
type: MentionType.Document,
|
||||
label: "Page",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static paragraph(item: ParagraphBlockObjectResponse) {
|
||||
return {
|
||||
type: "paragraph",
|
||||
content: item.paragraph.rich_text.map(this.rich_text).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
private static quote(item: Block<QuoteBlockObjectResponse>) {
|
||||
return {
|
||||
type: "blockquote",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: item.quote.rich_text.map(this.rich_text).filter(Boolean),
|
||||
},
|
||||
...this.mapChildren(item),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private static synced_block(item: Block<SyncedBlockBlockObjectResponse>) {
|
||||
return this.mapChildren(item);
|
||||
}
|
||||
|
||||
private static table(
|
||||
item: TableBlockObjectResponse & {
|
||||
children: Array<{
|
||||
table_row: {
|
||||
cells: Array<Array<RichTextItemResponse>>;
|
||||
};
|
||||
type?: "table_row";
|
||||
object?: "block";
|
||||
}>;
|
||||
}
|
||||
) {
|
||||
return {
|
||||
type: "table",
|
||||
content: item.children.map((tr, y) => ({
|
||||
type: "tr",
|
||||
content: tr.table_row.cells.map((td, x) => ({
|
||||
type:
|
||||
(item.table.has_row_header && y === 0) ||
|
||||
(item.table.has_column_header && x === 0)
|
||||
? "th"
|
||||
: "td",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: td.map(this.rich_text).filter(Boolean),
|
||||
},
|
||||
],
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private static toggle(item: ToggleBlockObjectResponse) {
|
||||
return {
|
||||
type: "paragraph",
|
||||
content: item.toggle.rich_text.map(this.rich_text).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
private static to_do(item: Block<ToDoBlockObjectResponse>) {
|
||||
return {
|
||||
type: "checkbox_item",
|
||||
attrs: {
|
||||
checked: item.to_do.checked,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: item.to_do.rich_text.map(this.rich_text).filter(Boolean),
|
||||
},
|
||||
...this.mapChildren(item),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private static video(item: VideoBlockObjectResponse) {
|
||||
if (item.video.type === "file") {
|
||||
return {
|
||||
type: "video",
|
||||
attrs: {
|
||||
src: item.video.file.url,
|
||||
title: item.video.caption.map(this.rich_text_to_plaintext).join(""),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "embed",
|
||||
attrs: {
|
||||
href: item.video.external.url,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,58 @@
|
||||
import queryString from "query-string";
|
||||
import env from "@shared/env";
|
||||
import { IntegrationService } from "@shared/types";
|
||||
import { settingsPath } from "@shared/utils/routeHelpers";
|
||||
|
||||
export type OAuthState = {
|
||||
teamId: string;
|
||||
};
|
||||
|
||||
export class NotionUtils {
|
||||
public static tokenUrl = "https://api.notion.com/v1/oauth/token";
|
||||
private static authBaseUrl = "https://api.notion.com/v1/oauth/authorize";
|
||||
|
||||
private static settingsUrl = settingsPath("import");
|
||||
|
||||
static parseState(state: string): OAuthState {
|
||||
return JSON.parse(state);
|
||||
}
|
||||
|
||||
static successUrl(integrationId: string) {
|
||||
const params = {
|
||||
success: "",
|
||||
service: IntegrationService.Notion,
|
||||
integrationId,
|
||||
};
|
||||
return `${this.settingsUrl}?${queryString.stringify(params)}`;
|
||||
}
|
||||
|
||||
static errorUrl(error: string) {
|
||||
const params = {
|
||||
error,
|
||||
service: IntegrationService.Notion,
|
||||
};
|
||||
return `${this.settingsUrl}?${queryString.stringify(params)}`;
|
||||
}
|
||||
|
||||
static callbackUrl(
|
||||
{ baseUrl, params }: { baseUrl: string; params?: string } = {
|
||||
baseUrl: `${env.URL}`,
|
||||
params: undefined,
|
||||
}
|
||||
) {
|
||||
return params
|
||||
? `${baseUrl}/api/notion.callback?${params}`
|
||||
: `${baseUrl}/api/notion.callback`;
|
||||
}
|
||||
|
||||
static authUrl({ state }: { state: OAuthState }) {
|
||||
const params = {
|
||||
client_id: env.NOTION_CLIENT_ID,
|
||||
redirect_uri: this.callbackUrl(),
|
||||
state: JSON.stringify(state),
|
||||
response_type: "code",
|
||||
owner: "user",
|
||||
};
|
||||
return `${this.authBaseUrl}?${queryString.stringify(params)}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { BlockObjectResponse } from "@notionhq/client/build/src/api-endpoints";
|
||||
|
||||
export enum PageType {
|
||||
Page = "page",
|
||||
Database = "database",
|
||||
}
|
||||
|
||||
export type Page = {
|
||||
type: PageType;
|
||||
id: string;
|
||||
name: string;
|
||||
emoji?: string;
|
||||
};
|
||||
|
||||
// Transformed block structure with "children".
|
||||
export type Block<T = BlockObjectResponse> = T & {
|
||||
children?: Block[];
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { HttpsProxyAgent } from "https-proxy-agent";
|
||||
import OAuth2Strategy, { Strategy } from "passport-oauth2";
|
||||
|
||||
export class OIDCStrategy extends Strategy {
|
||||
constructor(
|
||||
options: OAuth2Strategy.StrategyOptionsWithRequest,
|
||||
verify: OAuth2Strategy.VerifyFunctionWithRequest
|
||||
) {
|
||||
super(options, verify);
|
||||
|
||||
if (process.env.https_proxy) {
|
||||
const httpsProxyAgent = new HttpsProxyAgent(process.env.https_proxy);
|
||||
this._oauth2.setAgent(httpsProxyAgent);
|
||||
}
|
||||
}
|
||||
|
||||
authenticate(req: any, options: any) {
|
||||
options.originalQuery = req.query;
|
||||
super.authenticate(req, options);
|
||||
}
|
||||
|
||||
authorizationParams(options: any) {
|
||||
return {
|
||||
...(options.originalQuery || {}),
|
||||
...(super.authorizationParams?.(options) || {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import passport from "@outlinewiki/koa-passport";
|
||||
import type { Context } from "koa";
|
||||
import Router from "koa-router";
|
||||
import get from "lodash/get";
|
||||
import { Strategy } from "passport-oauth2";
|
||||
import { slugifyDomain } from "@shared/utils/domains";
|
||||
import { parseEmail } from "@shared/utils/email";
|
||||
import accountProvisioner from "@server/commands/accountProvisioner";
|
||||
@@ -21,24 +20,11 @@ import {
|
||||
} from "@server/utils/passport";
|
||||
import config from "../../plugin.json";
|
||||
import env from "../env";
|
||||
import { OIDCStrategy } from "./OIDCStrategy";
|
||||
|
||||
const router = new Router();
|
||||
const scopes = env.OIDC_SCOPES.split(" ");
|
||||
|
||||
const authorizationParams = Strategy.prototype.authorizationParams;
|
||||
Strategy.prototype.authorizationParams = function (options) {
|
||||
return {
|
||||
...(options.originalQuery || {}),
|
||||
...(authorizationParams.bind(this)(options) || {}),
|
||||
};
|
||||
};
|
||||
|
||||
const authenticate = Strategy.prototype.authenticate;
|
||||
Strategy.prototype.authenticate = function (req, options) {
|
||||
options.originalQuery = req.query;
|
||||
authenticate.bind(this)(req, options);
|
||||
};
|
||||
|
||||
if (
|
||||
env.OIDC_CLIENT_ID &&
|
||||
env.OIDC_CLIENT_SECRET &&
|
||||
@@ -48,7 +34,7 @@ if (
|
||||
) {
|
||||
passport.use(
|
||||
config.id,
|
||||
new Strategy(
|
||||
new OIDCStrategy(
|
||||
{
|
||||
authorizationURL: env.OIDC_AUTH_URI,
|
||||
tokenURL: env.OIDC_TOKEN_URI,
|
||||
|
||||
@@ -17,6 +17,9 @@ import { getTestServer } from "@server/test/support";
|
||||
|
||||
const server = getTestServer();
|
||||
|
||||
// Increase timeout for all tests in this file
|
||||
jest.setTimeout(10000);
|
||||
|
||||
describe("#files.create", () => {
|
||||
it("should fail with status 400 bad request if key is invalid", async () => {
|
||||
const user = await buildUser();
|
||||
|
||||
@@ -231,6 +231,12 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
|
||||
case "userMemberships.update":
|
||||
// Ignored
|
||||
return;
|
||||
case "imports.create":
|
||||
case "imports.update":
|
||||
case "imports.processed":
|
||||
case "imports.delete":
|
||||
// Ignored
|
||||
return;
|
||||
default:
|
||||
assertUnreachable(event);
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export const TooManyConnections = {
|
||||
code: 4503,
|
||||
reason: "Too Many Connections",
|
||||
};
|
||||
@@ -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,12 +1,14 @@
|
||||
import {
|
||||
Extension,
|
||||
connectedPayload,
|
||||
onConnectPayload,
|
||||
onDisconnectPayload,
|
||||
} from "@hocuspocus/server";
|
||||
import pluralize from "pluralize";
|
||||
import { TooManyConnections } from "@shared/collaboration/CloseEvents";
|
||||
import env from "@server/env";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { trace } from "@server/logging/tracing";
|
||||
import { TooManyConnections } from "./CloseEvents";
|
||||
import { withContext } from "./types";
|
||||
|
||||
@trace()
|
||||
@@ -14,11 +16,13 @@ export class ConnectionLimitExtension implements Extension {
|
||||
/**
|
||||
* Map of documentId -> connection count
|
||||
*/
|
||||
connectionsByDocument: Map<string, Set<string>> = new Map();
|
||||
public connectionsByDocument: Map<string, Set<string>> = new Map();
|
||||
|
||||
/**
|
||||
* onDisconnect hook
|
||||
* On disconnect hook
|
||||
*
|
||||
* @param data The disconnect payload
|
||||
* @returns Promise
|
||||
*/
|
||||
onDisconnect({ documentName, socketId }: withContext<onDisconnectPayload>) {
|
||||
const connections = this.connectionsByDocument.get(documentName);
|
||||
@@ -32,21 +36,30 @@ export class ConnectionLimitExtension implements Extension {
|
||||
}
|
||||
}
|
||||
|
||||
const connectionCount = connections?.size ?? 0;
|
||||
Logger.debug(
|
||||
"multiplayer",
|
||||
`${connections?.size} connections to "${documentName}"`
|
||||
`${connectionCount} ${pluralize(
|
||||
"connection",
|
||||
connectionCount
|
||||
)} to "${documentName}"`
|
||||
);
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* connected hook
|
||||
* @param data The connected payload
|
||||
* 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 onConnect payload
|
||||
* @returns Promise, resolving will allow the connection, rejecting will drop.
|
||||
*/
|
||||
connected({ documentName, socketId }: withContext<connectedPayload>) {
|
||||
onConnect({ documentName }: withContext<onConnectPayload>) {
|
||||
const connections =
|
||||
this.connectionsByDocument.get(documentName) || new Set();
|
||||
|
||||
if (connections?.size >= env.COLLABORATION_MAX_CLIENTS_PER_DOCUMENT) {
|
||||
Logger.info(
|
||||
"multiplayer",
|
||||
@@ -57,12 +70,30 @@ export class ConnectionLimitExtension implements Extension {
|
||||
return Promise.reject(TooManyConnections);
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* Connected hook is called after a new connection has been established.
|
||||
* We can safely update the connection count for the document.
|
||||
*
|
||||
* @param data The onConnect payload
|
||||
* @returns Promise
|
||||
*/
|
||||
connected({ documentName, socketId }: withContext<connectedPayload>) {
|
||||
const connections =
|
||||
this.connectionsByDocument.get(documentName) || new Set();
|
||||
|
||||
connections.add(socketId);
|
||||
this.connectionsByDocument.set(documentName, connections);
|
||||
const connectionCount = connections.size ?? 0;
|
||||
|
||||
Logger.debug(
|
||||
"multiplayer",
|
||||
`${connections.size} connections to "${documentName}"`
|
||||
`${connectionCount} ${pluralize(
|
||||
"connection",
|
||||
connectionCount
|
||||
)} to "${documentName}"`
|
||||
);
|
||||
|
||||
return Promise.resolve();
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Extension, onConnectPayload } from "@hocuspocus/server";
|
||||
import semver from "semver";
|
||||
import { EditorUpdateError } from "@shared/collaboration/CloseEvents";
|
||||
import EDITOR_VERSION from "@shared/editor/version";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { trace } from "@server/logging/tracing";
|
||||
import { withContext } from "./types";
|
||||
|
||||
@trace()
|
||||
export class EditorVersionExtension implements Extension {
|
||||
/**
|
||||
* On connect hook – prevents connections from clients with an outdated editor
|
||||
* version. See the equivalent logic for API in /server/routes/api/middlewares/editor.ts
|
||||
*
|
||||
* @param data The connect payload
|
||||
* @returns Promise, resolving will allow the connection, rejecting will drop.
|
||||
*/
|
||||
onConnect({ requestParameters }: withContext<onConnectPayload>) {
|
||||
const clientVersion = requestParameters.get("editorVersion");
|
||||
|
||||
if (clientVersion) {
|
||||
const parsedClientVersion = semver.parse(clientVersion);
|
||||
const parsedServerVersion = semver.parse(EDITOR_VERSION);
|
||||
|
||||
if (
|
||||
parsedClientVersion &&
|
||||
parsedServerVersion &&
|
||||
parsedClientVersion.major < parsedServerVersion.major
|
||||
) {
|
||||
Logger.debug(
|
||||
"multiplayer",
|
||||
`Dropping connection due to outdated editor version: ${clientVersion} < ${EDITOR_VERSION}`
|
||||
);
|
||||
return Promise.reject(EditorUpdateError);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ type Props = Optional<
|
||||
| "collectionId"
|
||||
| "parentDocumentId"
|
||||
| "importId"
|
||||
| "apiImportId"
|
||||
| "template"
|
||||
| "fullWidth"
|
||||
| "sourceMetadata"
|
||||
@@ -51,6 +52,7 @@ export default async function documentCreator({
|
||||
templateDocument,
|
||||
fullWidth,
|
||||
importId,
|
||||
apiImportId,
|
||||
createdAt,
|
||||
// allows override for import
|
||||
updatedAt,
|
||||
@@ -116,6 +118,7 @@ export default async function documentCreator({
|
||||
templateId,
|
||||
publishedAt,
|
||||
importId,
|
||||
apiImportId,
|
||||
sourceMetadata,
|
||||
fullWidth: fullWidth ?? templateDocument?.fullWidth,
|
||||
icon: icon ?? templateDocument?.icon,
|
||||
@@ -142,7 +145,7 @@ export default async function documentCreator({
|
||||
teamId: document.teamId,
|
||||
actorId: user.id,
|
||||
data: {
|
||||
source: importId ? "import" : undefined,
|
||||
source: importId || apiImportId ? "import" : undefined,
|
||||
title: document.title,
|
||||
templateId,
|
||||
},
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
import { NotificationEventType } from "@shared/types";
|
||||
import { Event } from "@server/models";
|
||||
import {
|
||||
buildUser,
|
||||
buildNotification,
|
||||
buildDocument,
|
||||
buildCollection,
|
||||
} from "@server/test/factories";
|
||||
import { withAPIContext } from "@server/test/support";
|
||||
import notificationUpdater from "./notificationUpdater";
|
||||
|
||||
describe("notificationUpdater", () => {
|
||||
it("should mark the notification as viewed", async () => {
|
||||
const user = await buildUser();
|
||||
const actor = await buildUser({
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const collection = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
createdById: actor.id,
|
||||
});
|
||||
const document = await buildDocument({
|
||||
teamId: user.teamId,
|
||||
collectionId: collection.id,
|
||||
createdById: actor.id,
|
||||
});
|
||||
const notification = await buildNotification({
|
||||
actorId: actor.id,
|
||||
event: NotificationEventType.UpdateDocument,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
documentId: document.id,
|
||||
collectionId: collection.id,
|
||||
});
|
||||
|
||||
expect(notification.archivedAt).toBe(null);
|
||||
expect(notification.viewedAt).toBe(null);
|
||||
|
||||
await withAPIContext(user, (ctx) =>
|
||||
notificationUpdater(ctx, {
|
||||
notification,
|
||||
viewedAt: new Date(),
|
||||
})
|
||||
);
|
||||
const event = await Event.findLatest({
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
expect(notification.viewedAt).not.toBe(null);
|
||||
expect(notification.archivedAt).toBe(null);
|
||||
expect(event!.name).toEqual("notifications.update");
|
||||
expect(event!.modelId).toEqual(notification.id);
|
||||
});
|
||||
|
||||
it("should mark the notification as unseen", async () => {
|
||||
const user = await buildUser();
|
||||
const actor = await buildUser({
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const collection = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
createdById: actor.id,
|
||||
});
|
||||
const document = await buildDocument({
|
||||
teamId: user.teamId,
|
||||
collectionId: collection.id,
|
||||
createdById: actor.id,
|
||||
});
|
||||
const notification = await buildNotification({
|
||||
actorId: actor.id,
|
||||
event: NotificationEventType.UpdateDocument,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
documentId: document.id,
|
||||
collectionId: collection.id,
|
||||
viewedAt: new Date(),
|
||||
});
|
||||
|
||||
expect(notification.archivedAt).toBe(null);
|
||||
expect(notification.viewedAt).not.toBe(null);
|
||||
|
||||
await withAPIContext(user, (ctx) =>
|
||||
notificationUpdater(ctx, {
|
||||
notification,
|
||||
viewedAt: null,
|
||||
})
|
||||
);
|
||||
const event = await Event.findLatest({
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
expect(notification.viewedAt).toBe(null);
|
||||
expect(notification.archivedAt).toBe(null);
|
||||
expect(event!.name).toEqual("notifications.update");
|
||||
expect(event!.modelId).toEqual(notification.id);
|
||||
});
|
||||
|
||||
it("should archive the notification", async () => {
|
||||
const user = await buildUser();
|
||||
const actor = await buildUser({
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const collection = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
createdById: actor.id,
|
||||
});
|
||||
const document = await buildDocument({
|
||||
teamId: user.teamId,
|
||||
collectionId: collection.id,
|
||||
createdById: actor.id,
|
||||
});
|
||||
const notification = await buildNotification({
|
||||
actorId: actor.id,
|
||||
event: NotificationEventType.UpdateDocument,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
documentId: document.id,
|
||||
collectionId: collection.id,
|
||||
});
|
||||
|
||||
expect(notification.archivedAt).toBe(null);
|
||||
expect(notification.viewedAt).toBe(null);
|
||||
|
||||
await withAPIContext(user, (ctx) =>
|
||||
notificationUpdater(ctx, {
|
||||
notification,
|
||||
archivedAt: new Date(),
|
||||
})
|
||||
);
|
||||
const event = await Event.findLatest({
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
expect(notification.viewedAt).toBe(null);
|
||||
expect(notification.archivedAt).not.toBe(null);
|
||||
expect(event!.name).toEqual("notifications.update");
|
||||
expect(event!.modelId).toEqual(notification.id);
|
||||
});
|
||||
|
||||
it("should unarchive the notification", async () => {
|
||||
const user = await buildUser();
|
||||
const actor = await buildUser({
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const collection = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
createdById: actor.id,
|
||||
});
|
||||
const document = await buildDocument({
|
||||
teamId: user.teamId,
|
||||
collectionId: collection.id,
|
||||
createdById: actor.id,
|
||||
});
|
||||
const notification = await buildNotification({
|
||||
actorId: actor.id,
|
||||
event: NotificationEventType.UpdateDocument,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
documentId: document.id,
|
||||
collectionId: collection.id,
|
||||
archivedAt: new Date(),
|
||||
});
|
||||
|
||||
expect(notification.archivedAt).not.toBe(null);
|
||||
expect(notification.viewedAt).toBe(null);
|
||||
|
||||
await withAPIContext(user, (ctx) =>
|
||||
notificationUpdater(ctx, {
|
||||
notification,
|
||||
archivedAt: null,
|
||||
})
|
||||
);
|
||||
const event = await Event.findLatest({
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
expect(notification.viewedAt).toBe(null);
|
||||
expect(notification.archivedAt).toBeNull();
|
||||
expect(event!.name).toEqual("notifications.update");
|
||||
expect(event!.modelId).toEqual(notification.id);
|
||||
});
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
import isUndefined from "lodash/isUndefined";
|
||||
import { Event, Notification } from "@server/models";
|
||||
import { APIContext } from "@server/types";
|
||||
|
||||
type Props = {
|
||||
/** Notification to be updated */
|
||||
notification: Notification;
|
||||
/** Time at which notification was viewed */
|
||||
viewedAt?: Date | null;
|
||||
/** Time at which notification was archived */
|
||||
archivedAt?: Date | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* This command updates notification properties.
|
||||
*
|
||||
* @param ctx The originating request context
|
||||
* @param Props The properties of the notification to update
|
||||
* @returns Notification The updated notification
|
||||
*/
|
||||
export default async function notificationUpdater(
|
||||
ctx: APIContext,
|
||||
{ notification, viewedAt, archivedAt }: Props
|
||||
): Promise<Notification> {
|
||||
const { transaction } = ctx.state;
|
||||
|
||||
if (!isUndefined(viewedAt)) {
|
||||
notification.viewedAt = viewedAt;
|
||||
}
|
||||
if (!isUndefined(archivedAt)) {
|
||||
notification.archivedAt = archivedAt;
|
||||
}
|
||||
const changed = notification.changed();
|
||||
if (changed) {
|
||||
await notification.save({ transaction });
|
||||
|
||||
await Event.createFromContext(
|
||||
ctx,
|
||||
{
|
||||
name: "notifications.update",
|
||||
userId: notification.userId,
|
||||
modelId: notification.id,
|
||||
documentId: notification.documentId,
|
||||
},
|
||||
{
|
||||
actorId: notification.userId,
|
||||
teamId: notification.teamId,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return notification;
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import slugify from "slugify";
|
||||
import { RESERVED_SUBDOMAINS } from "@shared/utils/domains";
|
||||
import { traceFunction } from "@server/logging/tracing";
|
||||
import { Team, Event } from "@server/models";
|
||||
import { generateAvatarUrl } from "@server/utils/avatars";
|
||||
|
||||
type Props = {
|
||||
/** The displayed name of the team */
|
||||
@@ -36,13 +35,10 @@ async function teamCreator({
|
||||
ip,
|
||||
transaction,
|
||||
}: Props): Promise<Team> {
|
||||
// If the service did not provide a logo/avatar then we attempt to generate
|
||||
// one via ClearBit, or fallback to colored initials in worst case scenario
|
||||
// If the service did not provide a logo/avatar then we'll use the default
|
||||
// avatar generation mechanism (colored initials)
|
||||
if (!avatarUrl || !avatarUrl.startsWith("http")) {
|
||||
avatarUrl = await generateAvatarUrl({
|
||||
domain,
|
||||
id: subdomain,
|
||||
});
|
||||
avatarUrl = null;
|
||||
}
|
||||
|
||||
const team = await Team.create(
|
||||
|
||||
@@ -33,7 +33,7 @@ export class Mailer {
|
||||
transporter: Transporter | undefined;
|
||||
|
||||
constructor() {
|
||||
if (env.SMTP_HOST) {
|
||||
if (env.SMTP_HOST || env.SMTP_SERVICE) {
|
||||
this.transporter = nodemailer.createTransport(this.getOptions());
|
||||
}
|
||||
if (useTestEmailService) {
|
||||
@@ -198,6 +198,17 @@ export class Mailer {
|
||||
};
|
||||
|
||||
private getOptions(): SMTPTransport.Options {
|
||||
// nodemailer will use the service config to determine host/port
|
||||
if (env.SMTP_SERVICE) {
|
||||
return {
|
||||
service: env.SMTP_SERVICE,
|
||||
auth: {
|
||||
user: env.SMTP_USERNAME,
|
||||
pass: env.SMTP_PASSWORD,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: env.SMTP_NAME,
|
||||
host: env.SMTP_HOST,
|
||||
|
||||
+13
-3
@@ -15,7 +15,7 @@ import {
|
||||
} from "class-validator";
|
||||
import uniq from "lodash/uniq";
|
||||
import { languages } from "@shared/i18n";
|
||||
import { CannotUseWithout } from "@server/utils/validators";
|
||||
import { CannotUseWith, CannotUseWithout } from "@server/utils/validators";
|
||||
import Deprecated from "./models/decorators/Deprecated";
|
||||
import { getArg } from "./utils/args";
|
||||
import { Public, PublicEnvironmentRegister } from "./utils/decorators/Public";
|
||||
@@ -291,10 +291,19 @@ export class Environment {
|
||||
/**
|
||||
* The host of your SMTP server for enabling emails.
|
||||
*/
|
||||
public SMTP_HOST = environment.SMTP_HOST;
|
||||
@CannotUseWith("SMTP_SERVICE")
|
||||
public SMTP_HOST = this.toOptionalString(environment.SMTP_HOST);
|
||||
|
||||
/**
|
||||
* The service name of a well-known SMTP service for nodemailer.
|
||||
* See https://community.nodemailer.com/2-0-0-beta/setup-smtp/well-known-services/
|
||||
*/
|
||||
@CannotUseWith("SMTP_HOST")
|
||||
public SMTP_SERVICE = this.toOptionalString(environment.SMTP_SERVICE);
|
||||
|
||||
@Public
|
||||
public EMAIL_ENABLED = !!this.SMTP_HOST || this.isDevelopment;
|
||||
public EMAIL_ENABLED =
|
||||
!!(this.SMTP_HOST || this.SMTP_SERVICE) || this.isDevelopment;
|
||||
|
||||
/**
|
||||
* Optional hostname of the client, used for identifying to the server
|
||||
@@ -307,6 +316,7 @@ export class Environment {
|
||||
*/
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@CannotUseWith("SMTP_SERVICE")
|
||||
public SMTP_PORT = this.toOptionalNumber(environment.SMTP_PORT);
|
||||
|
||||
/**
|
||||
|
||||
@@ -201,6 +201,12 @@ export function AuthenticationProviderDisabledError(
|
||||
});
|
||||
}
|
||||
|
||||
export function UnprocessableEntityError(
|
||||
message = "Cannot process the request"
|
||||
) {
|
||||
return httpErrors(422, message, { id: "unprocessable_entity" });
|
||||
}
|
||||
|
||||
export function ClientClosedRequestError(
|
||||
message = "Client closed request before response was received"
|
||||
) {
|
||||
|
||||
@@ -48,9 +48,7 @@ class Metrics {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
ddMetrics.flush(resolve, reject);
|
||||
});
|
||||
return ddMetrics.flush();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"use strict";
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.sequelize.transaction(async transaction => {
|
||||
await queryInterface.createTable(
|
||||
"imports",
|
||||
{
|
||||
id: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false,
|
||||
primaryKey: true,
|
||||
},
|
||||
name: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
service: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
state: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
input: {
|
||||
type: Sequelize.JSONB,
|
||||
allowNull: false,
|
||||
},
|
||||
documentCount: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
},
|
||||
integrationId: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: "integrations",
|
||||
},
|
||||
},
|
||||
createdById: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: "users",
|
||||
},
|
||||
},
|
||||
teamId: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: "teams",
|
||||
},
|
||||
},
|
||||
createdAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
updatedAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
deletedAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true,
|
||||
},
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
await queryInterface.addIndex("imports", ["service", "teamId"], {
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addIndex("imports", ["state", "teamId"], {
|
||||
transaction,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
await queryInterface.sequelize.transaction(async transaction => {
|
||||
await queryInterface.removeIndex("imports", ["service", "teamId"], {
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.removeIndex("imports", ["state", "teamId"], {
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.dropTable("imports", { transaction });
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
"use strict";
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.sequelize.transaction(async transaction => {
|
||||
await queryInterface.createTable(
|
||||
"import_tasks",
|
||||
{
|
||||
id: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false,
|
||||
primaryKey: true,
|
||||
},
|
||||
state: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
input: {
|
||||
type: Sequelize.JSONB,
|
||||
allowNull: false,
|
||||
},
|
||||
output: {
|
||||
type: Sequelize.JSONB,
|
||||
allowNull: true,
|
||||
},
|
||||
importId: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false,
|
||||
onDelete: "cascade",
|
||||
references: {
|
||||
model: "imports",
|
||||
},
|
||||
},
|
||||
createdAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
updatedAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
await queryInterface.addIndex("import_tasks", ["importId"], {
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addIndex("import_tasks", ["state", "importId"], {
|
||||
transaction,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
await queryInterface.sequelize.transaction(async transaction => {
|
||||
await queryInterface.removeIndex("import_tasks", ["importId"], {
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.removeIndex("import_tasks", ["state", "importId"], {
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.dropTable("import_tasks", { transaction });
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
"use strict";
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.sequelize.transaction(async transaction => {
|
||||
await queryInterface.addColumn(
|
||||
"collections",
|
||||
"apiImportId",
|
||||
{
|
||||
type: Sequelize.UUID,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: "imports",
|
||||
},
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
await queryInterface.addColumn(
|
||||
"documents",
|
||||
"apiImportId",
|
||||
{
|
||||
type: Sequelize.UUID,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: "imports",
|
||||
},
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
await queryInterface.addIndex("collections", ["apiImportId"], {
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addIndex("documents", ["apiImportId"], {
|
||||
transaction,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
await queryInterface.sequelize.transaction(async transaction => {
|
||||
await queryInterface.removeIndex("collections", [
|
||||
"apiImportId",
|
||||
{ transaction },
|
||||
]);
|
||||
await queryInterface.removeIndex("documents", [
|
||||
"apiImportId",
|
||||
{ transaction },
|
||||
]);
|
||||
await queryInterface.removeColumn("collections", "apiImportId", {
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.removeColumn("documents", "apiImportId", {
|
||||
transaction,
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
/* eslint-disable lines-between-class-members */
|
||||
import fractionalIndex from "fractional-index";
|
||||
import find from "lodash/find";
|
||||
import findIndex from "lodash/findIndex";
|
||||
import remove from "lodash/remove";
|
||||
@@ -11,6 +12,8 @@ import {
|
||||
InferAttributes,
|
||||
InferCreationAttributes,
|
||||
EmptyResultError,
|
||||
type CreateOptions,
|
||||
type UpdateOptions,
|
||||
} from "sequelize";
|
||||
import {
|
||||
Sequelize,
|
||||
@@ -32,6 +35,8 @@ import {
|
||||
BeforeDestroy,
|
||||
IsDate,
|
||||
AllowNull,
|
||||
BeforeCreate,
|
||||
BeforeUpdate,
|
||||
} from "sequelize-typescript";
|
||||
import isUUID from "validator/lib/isUUID";
|
||||
import type { CollectionSort, ProsemirrorData } from "@shared/types";
|
||||
@@ -41,12 +46,15 @@ import { sortNavigationNodes } from "@shared/utils/collections";
|
||||
import slugify from "@shared/utils/slugify";
|
||||
import { CollectionValidation } from "@shared/validations";
|
||||
import { ValidationError } from "@server/errors";
|
||||
import removeIndexCollision from "@server/utils/removeIndexCollision";
|
||||
import { generateUrlId } from "@server/utils/url";
|
||||
import { ValidateIndex } from "@server/validation";
|
||||
import Document from "./Document";
|
||||
import FileOperation from "./FileOperation";
|
||||
import Group from "./Group";
|
||||
import GroupMembership from "./GroupMembership";
|
||||
import GroupUser from "./GroupUser";
|
||||
import Import from "./Import";
|
||||
import Team from "./Team";
|
||||
import User from "./User";
|
||||
import UserMembership from "./UserMembership";
|
||||
@@ -216,8 +224,8 @@ class Collection extends ParanoidModel<
|
||||
color: string | null;
|
||||
|
||||
@Length({
|
||||
max: 256,
|
||||
msg: `index must be 256 characters or less`,
|
||||
max: ValidateIndex.maxLength,
|
||||
msg: `index must be ${ValidateIndex.maxLength} characters or less`,
|
||||
})
|
||||
@Column
|
||||
index: string | null;
|
||||
@@ -323,6 +331,30 @@ class Collection extends ParanoidModel<
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeCreate
|
||||
static async setIndex(model: Collection, options: CreateOptions<Collection>) {
|
||||
if (model.index) {
|
||||
model.index = await removeIndexCollision(model.teamId, model.index, {
|
||||
transaction: options.transaction,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const firstCollectionForTeam = await this.findOne({
|
||||
where: {
|
||||
teamId: model.teamId,
|
||||
},
|
||||
order: [
|
||||
// using LC_COLLATE:"C" because we need byte order to drive the sorting
|
||||
Sequelize.literal('"collection"."index" collate "C"'),
|
||||
["updatedAt", "DESC"],
|
||||
],
|
||||
...options,
|
||||
});
|
||||
|
||||
model.index = fractionalIndex(null, firstCollectionForTeam?.index ?? null);
|
||||
}
|
||||
|
||||
@AfterCreate
|
||||
static async onAfterCreate(
|
||||
model: Collection,
|
||||
@@ -342,6 +374,18 @@ class Collection extends ParanoidModel<
|
||||
});
|
||||
}
|
||||
|
||||
@BeforeUpdate
|
||||
static async checkIndex(
|
||||
model: Collection,
|
||||
options: UpdateOptions<Collection>
|
||||
) {
|
||||
if (model.index && model.changed("index")) {
|
||||
model.index = await removeIndexCollision(model.teamId, model.index, {
|
||||
transaction: options.transaction,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// associations
|
||||
|
||||
@BelongsTo(() => FileOperation, "importId")
|
||||
@@ -351,6 +395,13 @@ class Collection extends ParanoidModel<
|
||||
@Column(DataType.UUID)
|
||||
importId: string | null;
|
||||
|
||||
@BelongsTo(() => Import, "apiImportId")
|
||||
apiImport: Import<any> | null;
|
||||
|
||||
@ForeignKey(() => Import)
|
||||
@Column(DataType.UUID)
|
||||
apiImportId: string | null;
|
||||
|
||||
@BelongsTo(() => User, "archivedById")
|
||||
archivedBy?: User | null;
|
||||
|
||||
@@ -713,6 +764,7 @@ class Collection extends ParanoidModel<
|
||||
index?: number,
|
||||
options: FindOptions & {
|
||||
save?: boolean;
|
||||
silent?: boolean;
|
||||
documentJson?: NavigationNode;
|
||||
includeArchived?: boolean;
|
||||
} = {}
|
||||
|
||||
@@ -57,6 +57,7 @@ import FileOperation from "./FileOperation";
|
||||
import Group from "./Group";
|
||||
import GroupMembership from "./GroupMembership";
|
||||
import GroupUser from "./GroupUser";
|
||||
import Import from "./Import";
|
||||
import Revision from "./Revision";
|
||||
import Star from "./Star";
|
||||
import Team from "./Team";
|
||||
@@ -537,6 +538,13 @@ class Document extends ArchivableModel<
|
||||
@Column(DataType.UUID)
|
||||
importId: string | null;
|
||||
|
||||
@BelongsTo(() => Import, "apiImportId")
|
||||
apiImport: Import<any> | null;
|
||||
|
||||
@ForeignKey(() => Import)
|
||||
@Column(DataType.UUID)
|
||||
apiImportId: string | null;
|
||||
|
||||
@AllowNull
|
||||
@Column(DataType.JSONB)
|
||||
sourceMetadata: SourceMetadata | null;
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { InferAttributes, InferCreationAttributes } from "sequelize";
|
||||
import {
|
||||
BelongsTo,
|
||||
Column,
|
||||
DataType,
|
||||
Default,
|
||||
DefaultScope,
|
||||
ForeignKey,
|
||||
IsIn,
|
||||
IsNumeric,
|
||||
Table,
|
||||
} from "sequelize-typescript";
|
||||
import { type ImportInput } from "@shared/schema";
|
||||
import { ImportableIntegrationService, ImportState } from "@shared/types";
|
||||
import { ImportValidation } from "@shared/validations";
|
||||
import Integration from "./Integration";
|
||||
import Team from "./Team";
|
||||
import User from "./User";
|
||||
import ParanoidModel from "./base/ParanoidModel";
|
||||
import Fix from "./decorators/Fix";
|
||||
import Length from "./validators/Length";
|
||||
import NotContainsUrl from "./validators/NotContainsUrl";
|
||||
|
||||
@DefaultScope(() => ({
|
||||
include: [
|
||||
{
|
||||
association: "createdBy",
|
||||
required: true,
|
||||
paranoid: false,
|
||||
},
|
||||
],
|
||||
}))
|
||||
@Table({ tableName: "imports", modelName: "import" })
|
||||
@Fix
|
||||
class Import<T extends ImportableIntegrationService> extends ParanoidModel<
|
||||
InferAttributes<Import<T>>,
|
||||
Partial<InferCreationAttributes<Import<T>>>
|
||||
> {
|
||||
@NotContainsUrl
|
||||
@Length({
|
||||
max: ImportValidation.maxNameLength,
|
||||
msg: `name must be ${ImportValidation.maxNameLength} characters or less`,
|
||||
})
|
||||
@Column(DataType.STRING)
|
||||
name: string;
|
||||
|
||||
@IsIn([Object.values(ImportableIntegrationService)])
|
||||
@Column(DataType.STRING)
|
||||
service: T;
|
||||
|
||||
@IsIn([Object.values(ImportState)])
|
||||
@Column(DataType.STRING)
|
||||
state: ImportState;
|
||||
|
||||
@Column(DataType.JSONB)
|
||||
input: ImportInput<T>;
|
||||
|
||||
@IsNumeric
|
||||
@Default(0)
|
||||
@Column(DataType.INTEGER)
|
||||
documentCount: number;
|
||||
|
||||
@Column
|
||||
error: string | null;
|
||||
|
||||
// associations
|
||||
|
||||
@BelongsTo(() => Integration, "integrationId")
|
||||
integration: Integration;
|
||||
|
||||
@ForeignKey(() => Integration)
|
||||
@Column(DataType.UUID)
|
||||
integrationId: string;
|
||||
|
||||
@BelongsTo(() => User, "createdById")
|
||||
createdBy: User;
|
||||
|
||||
@ForeignKey(() => User)
|
||||
@Column(DataType.UUID)
|
||||
createdById: string;
|
||||
|
||||
@BelongsTo(() => Team, "teamId")
|
||||
team: Team;
|
||||
|
||||
@ForeignKey(() => Team)
|
||||
@Column(DataType.UUID)
|
||||
teamId: string;
|
||||
}
|
||||
|
||||
export default Import;
|
||||
@@ -0,0 +1,61 @@
|
||||
import { InferAttributes, InferCreationAttributes } from "sequelize";
|
||||
import {
|
||||
AllowNull,
|
||||
BelongsTo,
|
||||
Column,
|
||||
DataType,
|
||||
ForeignKey,
|
||||
IsIn,
|
||||
Table,
|
||||
} from "sequelize-typescript";
|
||||
import { type ImportTaskInput, ImportTaskOutput } from "@shared/schema";
|
||||
import { ImportableIntegrationService, ImportTaskState } from "@shared/types";
|
||||
import Import from "./Import";
|
||||
import IdModel from "./base/IdModel";
|
||||
import Fix from "./decorators/Fix";
|
||||
|
||||
// Not all fields are automatically inferred by Sequelize.
|
||||
// see https://sequelize.org/docs/v7/models/model-typing/#manual-attribute-typing
|
||||
type NonInferredAttributes<T extends ImportableIntegrationService> = {
|
||||
input: ImportTaskInput<T>;
|
||||
};
|
||||
|
||||
export type ImportTaskAttributes<T extends ImportableIntegrationService> =
|
||||
InferAttributes<ImportTask<T>> & NonInferredAttributes<T>;
|
||||
|
||||
export type ImportTaskCreationAttributes<
|
||||
T extends ImportableIntegrationService
|
||||
> = Partial<InferCreationAttributes<ImportTask<T>>> &
|
||||
Partial<NonInferredAttributes<T>>;
|
||||
|
||||
@Table({ tableName: "import_tasks", modelName: "import_task" })
|
||||
@Fix
|
||||
class ImportTask<T extends ImportableIntegrationService> extends IdModel<
|
||||
ImportTaskAttributes<T>,
|
||||
ImportTaskCreationAttributes<T>
|
||||
> {
|
||||
@IsIn([Object.values(ImportTaskState)])
|
||||
@Column(DataType.STRING)
|
||||
state: ImportTaskState;
|
||||
|
||||
@Column(DataType.JSONB)
|
||||
input: ImportTaskInput<T>;
|
||||
|
||||
@AllowNull
|
||||
@Column(DataType.JSONB)
|
||||
output: ImportTaskOutput | null;
|
||||
|
||||
@Column
|
||||
error: string | null;
|
||||
|
||||
// associations
|
||||
|
||||
@BelongsTo(() => Import, "importId")
|
||||
import: Import<T>;
|
||||
|
||||
@ForeignKey(() => Import)
|
||||
@Column(DataType.UUID)
|
||||
importId: string;
|
||||
}
|
||||
|
||||
export default ImportTask;
|
||||
@@ -190,7 +190,7 @@ class Team extends ParanoidModel<
|
||||
* @return {boolean} Whether to show email login options
|
||||
*/
|
||||
get emailSigninEnabled(): boolean {
|
||||
return this.guestSignin && (!!env.SMTP_HOST || env.isDevelopment);
|
||||
return this.guestSignin && env.EMAIL_ENABLED;
|
||||
}
|
||||
|
||||
get url() {
|
||||
|
||||
@@ -280,6 +280,7 @@ class Model<
|
||||
*
|
||||
* @param query The query options.
|
||||
* @param callback The function to call for each batch of results
|
||||
* @return The total number of results processed.
|
||||
*/
|
||||
static async findAllInBatches<T extends Model>(
|
||||
query: Replace<FindOptions<T>, "limit", "batchLimit"> & {
|
||||
@@ -287,7 +288,8 @@ class Model<
|
||||
totalLimit?: number;
|
||||
},
|
||||
callback: (results: Array<T>, query: FindOptions<T>) => Promise<void>
|
||||
) {
|
||||
): Promise<number> {
|
||||
let total = 0;
|
||||
const mappedQuery = {
|
||||
...query,
|
||||
offset: query.offset ?? 0,
|
||||
@@ -299,12 +301,15 @@ class Model<
|
||||
do {
|
||||
// @ts-expect-error this T
|
||||
results = await this.findAll<T>(mappedQuery);
|
||||
total += results.length;
|
||||
await callback(results, mappedQuery);
|
||||
mappedQuery.offset += mappedQuery.limit;
|
||||
} while (
|
||||
results.length >= mappedQuery.limit &&
|
||||
(mappedQuery.totalLimit ?? Infinity) > mappedQuery.offset
|
||||
);
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,7 +9,7 @@ import NotificationHelper from "./NotificationHelper";
|
||||
|
||||
describe("NotificationHelper", () => {
|
||||
describe("getCommentNotificationRecipients", () => {
|
||||
it("should return users who have notification enabled for comment creation and are subscribed to the document in case of parent comment", async () => {
|
||||
it("should only return users who have notification enabled for comment creation and are subscribed to the document in case of new thread", async () => {
|
||||
const documentAuthor = await buildUser();
|
||||
const document = await buildDocument({
|
||||
userId: documentAuthor.id,
|
||||
@@ -54,7 +54,7 @@ describe("NotificationHelper", () => {
|
||||
expect(recipients[0].id).toEqual(notificationEnabledUser.id);
|
||||
});
|
||||
|
||||
it("should return users who have notification enabled for comment creation and are in the thread in case of child comment", async () => {
|
||||
it("should only return users who have notification enabled for comment creation and are in the thread in case of child comment", async () => {
|
||||
const documentAuthor = await buildUser();
|
||||
const document = await buildDocument({
|
||||
userId: documentAuthor.id,
|
||||
@@ -112,32 +112,104 @@ describe("NotificationHelper", () => {
|
||||
expect(recipients.length).toEqual(1);
|
||||
expect(recipients[0].id).toEqual(notificationEnabledUserInThread.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDocumentNotificationRecipients", () => {
|
||||
it("should return all users who have notification enabled for the event", async () => {
|
||||
it("should not return users who have notification disabled for comment creation and are in the thread in case of child comment", async () => {
|
||||
const documentAuthor = await buildUser();
|
||||
const document = await buildDocument({
|
||||
userId: documentAuthor.id,
|
||||
teamId: documentAuthor.teamId,
|
||||
});
|
||||
const notificationEnabledUser = await buildUser({
|
||||
const notificationEnabledUserInThread = await buildUser({
|
||||
teamId: document.teamId,
|
||||
notificationSettings: { [NotificationEventType.UpdateDocument]: true },
|
||||
notificationSettings: { [NotificationEventType.CreateComment]: false },
|
||||
});
|
||||
const notificationEnabledUserNotInThread = await buildUser({
|
||||
teamId: document.teamId,
|
||||
notificationSettings: { [NotificationEventType.CreateComment]: true },
|
||||
});
|
||||
const notificationDisabledUser = await buildUser({
|
||||
teamId: document.teamId,
|
||||
notificationSettings: {
|
||||
[NotificationEventType.CreateComment]: false,
|
||||
},
|
||||
});
|
||||
await Promise.all([
|
||||
buildSubscription({
|
||||
userId: documentAuthor.id,
|
||||
documentId: document.id,
|
||||
}),
|
||||
buildSubscription({
|
||||
userId: notificationEnabledUserInThread.id,
|
||||
documentId: document.id,
|
||||
}),
|
||||
buildSubscription({
|
||||
userId: notificationEnabledUserNotInThread.id,
|
||||
documentId: document.id,
|
||||
}),
|
||||
buildSubscription({
|
||||
userId: notificationDisabledUser.id,
|
||||
documentId: document.id,
|
||||
}),
|
||||
]);
|
||||
const parentComment = await buildComment({
|
||||
documentId: document.id,
|
||||
userId: notificationEnabledUserInThread.id,
|
||||
});
|
||||
const childComment = await buildComment({
|
||||
documentId: document.id,
|
||||
userId: documentAuthor.id,
|
||||
parentCommentId: parentComment.id,
|
||||
});
|
||||
|
||||
const recipients =
|
||||
await NotificationHelper.getDocumentNotificationRecipients({
|
||||
await NotificationHelper.getCommentNotificationRecipients(
|
||||
document,
|
||||
notificationType: NotificationEventType.UpdateDocument,
|
||||
onlySubscribers: false,
|
||||
actorId: documentAuthor.id,
|
||||
});
|
||||
childComment,
|
||||
childComment.createdById
|
||||
);
|
||||
|
||||
expect(recipients.length).toEqual(1);
|
||||
expect(recipients[0].id).toEqual(notificationEnabledUser.id);
|
||||
expect(recipients.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("should return users who have notification enabled and are in the thread but not explicitly subscribed to document", async () => {
|
||||
const documentAuthor = await buildUser();
|
||||
const document = await buildDocument({
|
||||
userId: documentAuthor.id,
|
||||
teamId: documentAuthor.teamId,
|
||||
});
|
||||
const notificationEnabledUserInThread = await buildUser({
|
||||
teamId: document.teamId,
|
||||
notificationSettings: { [NotificationEventType.CreateComment]: true },
|
||||
});
|
||||
await buildUser({
|
||||
teamId: document.teamId,
|
||||
notificationSettings: {
|
||||
[NotificationEventType.CreateComment]: false,
|
||||
},
|
||||
});
|
||||
const parentComment = await buildComment({
|
||||
documentId: document.id,
|
||||
userId: notificationEnabledUserInThread.id,
|
||||
});
|
||||
const childComment = await buildComment({
|
||||
documentId: document.id,
|
||||
userId: documentAuthor.id,
|
||||
parentCommentId: parentComment.id,
|
||||
});
|
||||
|
||||
const recipients =
|
||||
await NotificationHelper.getCommentNotificationRecipients(
|
||||
document,
|
||||
childComment,
|
||||
childComment.createdById
|
||||
);
|
||||
|
||||
expect(recipients.length).toEqual(1);
|
||||
expect(recipients[0].id).toEqual(notificationEnabledUserInThread.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDocumentNotificationRecipients", () => {
|
||||
it("should return users who have subscribed to the document", async () => {
|
||||
const documentAuthor = await buildUser();
|
||||
const document = await buildDocument({
|
||||
@@ -150,11 +222,17 @@ describe("NotificationHelper", () => {
|
||||
documentId: document.id,
|
||||
});
|
||||
|
||||
const deletedUser = await buildUser({ teamId: document.teamId });
|
||||
await buildSubscription({
|
||||
userId: deletedUser.id,
|
||||
documentId: document.id,
|
||||
});
|
||||
await deletedUser.destroy();
|
||||
|
||||
const recipients =
|
||||
await NotificationHelper.getDocumentNotificationRecipients({
|
||||
document,
|
||||
notificationType: NotificationEventType.UpdateDocument,
|
||||
onlySubscribers: true,
|
||||
actorId: documentAuthor.id,
|
||||
});
|
||||
|
||||
@@ -178,7 +256,6 @@ describe("NotificationHelper", () => {
|
||||
await NotificationHelper.getDocumentNotificationRecipients({
|
||||
document,
|
||||
notificationType: NotificationEventType.UpdateDocument,
|
||||
onlySubscribers: true,
|
||||
actorId: documentAuthor.id,
|
||||
});
|
||||
|
||||
@@ -216,7 +293,6 @@ describe("NotificationHelper", () => {
|
||||
await NotificationHelper.getDocumentNotificationRecipients({
|
||||
document,
|
||||
notificationType: NotificationEventType.UpdateDocument,
|
||||
onlySubscribers: true,
|
||||
actorId: documentAuthor.id,
|
||||
});
|
||||
|
||||
@@ -235,20 +311,19 @@ describe("NotificationHelper", () => {
|
||||
});
|
||||
const notificationEnabledUser = await buildUser({
|
||||
teamId: document.teamId,
|
||||
notificationSettings: { [NotificationEventType.UpdateDocument]: true },
|
||||
notificationSettings: { [NotificationEventType.PublishDocument]: true },
|
||||
});
|
||||
// suspended user
|
||||
await buildUser({
|
||||
suspendedAt: new Date(),
|
||||
teamId: document.teamId,
|
||||
notificationSettings: { [NotificationEventType.UpdateDocument]: true },
|
||||
notificationSettings: { [NotificationEventType.PublishDocument]: true },
|
||||
});
|
||||
|
||||
const recipients =
|
||||
await NotificationHelper.getDocumentNotificationRecipients({
|
||||
document,
|
||||
notificationType: NotificationEventType.UpdateDocument,
|
||||
onlySubscribers: false,
|
||||
notificationType: NotificationEventType.PublishDocument,
|
||||
actorId: documentAuthor.id,
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
Comment,
|
||||
View,
|
||||
} from "@server/models";
|
||||
import { can } from "@server/policies";
|
||||
import { canUserAccessDocument } from "@server/utils/permissions";
|
||||
import { ProsemirrorHelper } from "./ProsemirrorHelper";
|
||||
|
||||
export default class NotificationHelper {
|
||||
@@ -60,18 +60,12 @@ export default class NotificationHelper {
|
||||
comment: Comment,
|
||||
actorId: string
|
||||
): Promise<User[]> => {
|
||||
let recipients = await this.getDocumentNotificationRecipients({
|
||||
document,
|
||||
notificationType: NotificationEventType.CreateComment,
|
||||
onlySubscribers: !comment.parentCommentId,
|
||||
actorId,
|
||||
});
|
||||
let recipients: User[];
|
||||
|
||||
recipients = recipients.filter((recipient) =>
|
||||
recipient.subscribedToEventType(NotificationEventType.CreateComment)
|
||||
);
|
||||
|
||||
if (recipients.length > 0 && comment.parentCommentId) {
|
||||
// If this is a reply to another comment, we want to notify all users
|
||||
// that are involved in the thread of this comment (i.e. the original
|
||||
// comment and all replies to it).
|
||||
if (comment.parentCommentId) {
|
||||
const contextComments = await Comment.findAll({
|
||||
attributes: ["createdById", "data"],
|
||||
where: {
|
||||
@@ -95,13 +89,37 @@ export default class NotificationHelper {
|
||||
const userIdsInThread = uniq([
|
||||
...createdUserIdsInThread,
|
||||
...mentionedUserIdsInThread,
|
||||
]);
|
||||
recipients = recipients.filter((r) => userIdsInThread.includes(r.id));
|
||||
]).filter((userId) => userId !== actorId);
|
||||
|
||||
recipients = await User.findAll({
|
||||
where: {
|
||||
id: {
|
||||
[Op.in]: userIdsInThread,
|
||||
},
|
||||
teamId: document.teamId,
|
||||
},
|
||||
});
|
||||
|
||||
recipients = recipients.filter((recipient) =>
|
||||
recipient.subscribedToEventType(NotificationEventType.CreateComment)
|
||||
);
|
||||
} else {
|
||||
recipients = await this.getDocumentNotificationRecipients({
|
||||
document,
|
||||
notificationType: NotificationEventType.CreateComment,
|
||||
actorId,
|
||||
// We will check below, this just prevents duplicate queries
|
||||
disableAccessCheck: true,
|
||||
});
|
||||
}
|
||||
|
||||
const filtered: User[] = [];
|
||||
|
||||
for (const recipient of recipients) {
|
||||
if (recipient.isSuspended) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If this recipient has viewed the document since the comment was made
|
||||
// then we can avoid sending them a useless notification, yay.
|
||||
const view = await View.findOne({
|
||||
@@ -119,7 +137,13 @@ export default class NotificationHelper {
|
||||
"processor",
|
||||
`suppressing notification to ${recipient.id} because doc viewed`
|
||||
);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check the recipient has access to the collection this document is in. Just
|
||||
// because they are subscribed doesn't mean they still have access to read
|
||||
// the document.
|
||||
if (await canUserAccessDocument(recipient, document.id)) {
|
||||
filtered.push(recipient);
|
||||
}
|
||||
}
|
||||
@@ -132,74 +156,77 @@ export default class NotificationHelper {
|
||||
*
|
||||
* @param document The document to get recipients for.
|
||||
* @param notificationType The notification type for which to find the recipients.
|
||||
* @param onlySubscribers Whether to consider only the users who have active subscription to the document.
|
||||
* @param actorId The id of the user that performed the action.
|
||||
* @param disableAccessCheck Whether to disable the access check for the document.
|
||||
* @returns A list of recipients
|
||||
*/
|
||||
public static getDocumentNotificationRecipients = async ({
|
||||
document,
|
||||
notificationType,
|
||||
onlySubscribers,
|
||||
actorId,
|
||||
disableAccessCheck = false,
|
||||
}: {
|
||||
document: Document;
|
||||
notificationType: NotificationEventType;
|
||||
onlySubscribers: boolean;
|
||||
actorId: string;
|
||||
disableAccessCheck?: boolean;
|
||||
}): Promise<User[]> => {
|
||||
// First find all the users that have notifications enabled for this event
|
||||
// type at all and aren't the one that performed the action.
|
||||
let recipients = await User.findAll({
|
||||
where: {
|
||||
id: {
|
||||
[Op.ne]: actorId,
|
||||
let recipients: User[];
|
||||
|
||||
if (notificationType === NotificationEventType.PublishDocument) {
|
||||
recipients = await User.findAll({
|
||||
where: {
|
||||
id: {
|
||||
[Op.ne]: actorId,
|
||||
},
|
||||
teamId: document.teamId,
|
||||
notificationSettings: {
|
||||
[notificationType]: true,
|
||||
},
|
||||
},
|
||||
teamId: document.teamId,
|
||||
},
|
||||
});
|
||||
|
||||
recipients = recipients.filter((recipient) =>
|
||||
recipient.subscribedToEventType(notificationType)
|
||||
);
|
||||
|
||||
// Filter further to only those that have a subscription to the document…
|
||||
if (onlySubscribers) {
|
||||
});
|
||||
} else {
|
||||
const subscriptions = await Subscription.findAll({
|
||||
attributes: ["userId"],
|
||||
where: {
|
||||
userId: recipients.map((recipient) => recipient.id),
|
||||
userId: {
|
||||
[Op.ne]: actorId,
|
||||
},
|
||||
event: SubscriptionType.Document,
|
||||
[Op.or]: [
|
||||
{ collectionId: document.collectionId },
|
||||
{ documentId: document.id },
|
||||
],
|
||||
},
|
||||
include: [
|
||||
{
|
||||
association: "user",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const subscribedUserIds = subscriptions.map(
|
||||
(subscription) => subscription.userId
|
||||
);
|
||||
|
||||
recipients = recipients.filter((recipient) =>
|
||||
subscribedUserIds.includes(recipient.id)
|
||||
);
|
||||
recipients = subscriptions.map((s) => s.user);
|
||||
}
|
||||
|
||||
recipients = recipients.filter((recipient) =>
|
||||
recipient.subscribedToEventType(notificationType)
|
||||
);
|
||||
|
||||
const filtered = [];
|
||||
|
||||
for (const recipient of recipients) {
|
||||
if (!recipient.email || recipient.isSuspended) {
|
||||
if (recipient.isSuspended) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check the recipient has access to the collection this document is in. Just
|
||||
// because they are subscribed doesn't mean they still have access to read
|
||||
// the document.
|
||||
const doc = await Document.findByPk(document.id, {
|
||||
userId: recipient.id,
|
||||
});
|
||||
|
||||
if (can(recipient, "read", doc)) {
|
||||
if (
|
||||
disableAccessCheck ||
|
||||
(await canUserAccessDocument(recipient, document.id))
|
||||
) {
|
||||
filtered.push(recipient);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import env from "@server/env";
|
||||
import SubscriptionHelper from "./SubscriptionHelper";
|
||||
|
||||
describe("SubscriptionHelper", () => {
|
||||
describe("unsubscribeUrl", () => {
|
||||
it("should return a valid unsubscribe URL", () => {
|
||||
const userId = uuidv4();
|
||||
const documentId = uuidv4();
|
||||
|
||||
const unsubscribeUrl = SubscriptionHelper.unsubscribeUrl(
|
||||
userId,
|
||||
documentId
|
||||
);
|
||||
expect(unsubscribeUrl).toContain(`${env.URL}/api/subscriptions.delete`);
|
||||
expect(unsubscribeUrl).toContain(`userId=${userId}`);
|
||||
expect(unsubscribeUrl).toContain(`documentId=${documentId}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import crypto from "crypto";
|
||||
import queryString from "query-string";
|
||||
import env from "@server/env";
|
||||
|
||||
/**
|
||||
@@ -15,12 +16,22 @@ export default class SubscriptionHelper {
|
||||
* @returns The unsubscribe URL
|
||||
*/
|
||||
public static unsubscribeUrl(userId: string, documentId: string) {
|
||||
return `${env.URL}/api/subscriptions.delete?token=${this.unsubscribeToken(
|
||||
const token = this.unsubscribeToken(userId, documentId);
|
||||
|
||||
return `${env.URL}/api/subscriptions.delete?${queryString.stringify({
|
||||
token,
|
||||
userId,
|
||||
documentId
|
||||
)}&userId=${userId}&documentId=${documentId}`;
|
||||
documentId,
|
||||
})}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a token for unsubscribing a user from a document or collection.
|
||||
*
|
||||
* @param userId The user ID to unsubscribe
|
||||
* @param documentId The document ID to unsubscribe from
|
||||
* @returns The unsubscribe token
|
||||
*/
|
||||
public static unsubscribeToken(userId: string, documentId: string) {
|
||||
const hash = crypto.createHash("sha256");
|
||||
hash.update(`${userId}-${env.SECRET_KEY}-${documentId}`);
|
||||
|
||||
@@ -24,6 +24,10 @@ export { default as Group } from "./Group";
|
||||
|
||||
export { default as GroupUser } from "./GroupUser";
|
||||
|
||||
export { default as Import } from "./Import";
|
||||
|
||||
export { default as ImportTask } from "./ImportTask";
|
||||
|
||||
export { default as Integration } from "./Integration";
|
||||
|
||||
export { default as IntegrationAuthentication } from "./IntegrationAuthentication";
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
buildDocument,
|
||||
buildDraftDocument,
|
||||
buildCollection,
|
||||
buildAdmin,
|
||||
} from "@server/test/factories";
|
||||
import { serialize } from "./index";
|
||||
|
||||
@@ -355,7 +356,10 @@ describe("read document", () => {
|
||||
});
|
||||
|
||||
describe("read_write document", () => {
|
||||
for (const role of Object.values(UserRole)) {
|
||||
const nonAdminRoles = Object.values(UserRole).filter(
|
||||
(role) => role !== UserRole.Admin
|
||||
);
|
||||
for (const role of nonAdminRoles) {
|
||||
it(`should allow write permissions for ${role}`, async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id, role });
|
||||
@@ -391,6 +395,41 @@ describe("read_write document", () => {
|
||||
expect(abilities.move).toEqual(false);
|
||||
});
|
||||
}
|
||||
|
||||
it(`should allow write permissions for admin`, async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildAdmin({ teamId: team.id });
|
||||
const collection = await buildCollection({
|
||||
teamId: team.id,
|
||||
permission: null,
|
||||
});
|
||||
const doc = await buildDocument({
|
||||
teamId: team.id,
|
||||
collectionId: collection.id,
|
||||
});
|
||||
await UserMembership.create({
|
||||
userId: user.id,
|
||||
documentId: doc.id,
|
||||
permission: DocumentPermission.ReadWrite,
|
||||
createdById: user.id,
|
||||
});
|
||||
|
||||
// reload to get membership
|
||||
const document = await Document.findByPk(doc.id, { userId: user.id });
|
||||
const abilities = serialize(user, document);
|
||||
expect(abilities.read).toBeTruthy();
|
||||
expect(abilities.download).toBeTruthy();
|
||||
expect(abilities.update).toBeTruthy();
|
||||
expect(abilities.delete).toBeTruthy();
|
||||
expect(abilities.subscribe).toBeTruthy();
|
||||
expect(abilities.unsubscribe).toBeTruthy();
|
||||
expect(abilities.comment).toBeTruthy();
|
||||
expect(abilities.createChildDocument).toBeTruthy();
|
||||
expect(abilities.manageUsers).toBeTruthy();
|
||||
expect(abilities.archive).toBeTruthy();
|
||||
expect(abilities.share).toEqual(false);
|
||||
expect(abilities.move).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("manage document", () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user