mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bab2729669 | |||
| 90f9721b40 | |||
| dc474573c6 | |||
| a3910ce6d1 | |||
| f9476770ce | |||
| 2e018e74b8 | |||
| a11ab56117 | |||
| 66e4ec32ed | |||
| bde9d5fbf4 | |||
| 70bb878a8c | |||
| 4237377d47 | |||
| a30f6b717b | |||
| 1edc23c5ae | |||
| ff6ec3a5b8 | |||
| 52c2729490 | |||
| 82f4281a02 | |||
| 12b6e30e3a | |||
| 567ca7e3f1 | |||
| 97c3ea7da8 | |||
| 4af2b032dd | |||
| c52d9a850d | |||
| 588e5bc17f | |||
| a2bd0edd82 | |||
| ca0f0638c9 | |||
| f13e6a3691 | |||
| dcb7b86df8 | |||
| 45c6e72c6d | |||
| a51456deb3 | |||
| 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 |
+1
-5
@@ -205,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'
|
||||
@@ -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;
|
||||
|
||||
@@ -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}</>;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -228,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
|
||||
|
||||
+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)`);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}): {
|
||||
|
||||
@@ -15,6 +15,9 @@ class Import extends Model {
|
||||
/** The name of the import. */
|
||||
name: string;
|
||||
|
||||
/** Descriptive error message when the import errors out. */
|
||||
error: string | null;
|
||||
|
||||
/** The current state of the import. */
|
||||
@Field
|
||||
@observable
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
+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 />}
|
||||
|
||||
@@ -15,6 +15,7 @@ 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. */
|
||||
@@ -29,6 +30,10 @@ export const ImportListItem = observer(({ importModel }: Props) => {
|
||||
const showProgress =
|
||||
importModel.state !== ImportState.Canceled &&
|
||||
importModel.state !== ImportState.Errored;
|
||||
const showErrorInfo =
|
||||
!isCloudHosted &&
|
||||
importModel.state === ImportState.Errored &&
|
||||
!!importModel.error;
|
||||
|
||||
const stateMap = React.useMemo(
|
||||
() => ({
|
||||
@@ -114,6 +119,12 @@ export const ImportListItem = observer(({ importModel }: Props) => {
|
||||
subtitle={
|
||||
<>
|
||||
{stateMap[importModel.state]} •
|
||||
{showErrorInfo && (
|
||||
<>
|
||||
{importModel.error}
|
||||
{`. ${t("Check server logs for more details.")}`} •
|
||||
</>
|
||||
)}
|
||||
{t(`{{userName}} requested`, {
|
||||
userName:
|
||||
user.id === importModel.createdBy.id
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { FileOperationFormat } from "@shared/types";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import DropToImport from "./DropToImport";
|
||||
import HelpDisclosure from "./HelpDisclosure";
|
||||
|
||||
function ImportNotionDialog() {
|
||||
const { t } = useTranslation();
|
||||
const { dialogs } = useStores();
|
||||
|
||||
return (
|
||||
<>
|
||||
<HelpDisclosure title={<Trans>Where do I find the file?</Trans>}>
|
||||
<Trans
|
||||
defaults="In Notion, click <em>Settings & Members</em> in the left sidebar and open Settings. Look for the Export section, and click <em>Export all workspace content</em>. Choose <em>HTML</em> as the format for the best data compatability."
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</HelpDisclosure>
|
||||
<DropToImport
|
||||
onSubmit={dialogs.closeAllModals}
|
||||
format={FileOperationFormat.Notion}
|
||||
>
|
||||
<>
|
||||
{t(
|
||||
`Drag and drop the zip file from Notion's HTML export option, or click to upload`
|
||||
)}
|
||||
</>
|
||||
</DropToImport>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImportNotionDialog;
|
||||
+23
-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",
|
||||
@@ -89,7 +89,7 @@
|
||||
"@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",
|
||||
@@ -110,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",
|
||||
@@ -129,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",
|
||||
@@ -172,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",
|
||||
@@ -247,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",
|
||||
@@ -256,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",
|
||||
@@ -295,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",
|
||||
@@ -359,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",
|
||||
@@ -377,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({
|
||||
|
||||
@@ -81,7 +81,12 @@ export const Notion = observer(() => {
|
||||
}, [t, appName, oauthError]);
|
||||
|
||||
return (
|
||||
<Button type="submit" onClick={() => redirectTo(authUrl)} neutral>
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={() => redirectTo(authUrl)}
|
||||
disabled={!env.NOTION_CLIENT_ID}
|
||||
neutral
|
||||
>
|
||||
{t("Import")}…
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -5,8 +5,8 @@ import environment from "@server/utils/environment";
|
||||
import { CannotUseWithout } from "@server/utils/validators";
|
||||
|
||||
class NotionPluginEnvironment extends Environment {
|
||||
@IsOptional()
|
||||
@Public
|
||||
@IsOptional()
|
||||
public NOTION_CLIENT_ID = this.toOptionalString(environment.NOTION_CLIENT_ID);
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import {
|
||||
APIErrorCode,
|
||||
APIResponseError,
|
||||
Client,
|
||||
isFullPage,
|
||||
isFullPageOrDatabase,
|
||||
@@ -247,19 +249,30 @@ export class NotionClient {
|
||||
|
||||
private async fetchUsername(userId: string) {
|
||||
await this.limiter();
|
||||
const user = await this.client.users.retrieve({ user_id: userId });
|
||||
try {
|
||||
const user = await this.client.users.retrieve({ user_id: userId });
|
||||
|
||||
if (user.type === "person" || !user.bot.owner) {
|
||||
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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
private parseTitle(item: PageObjectResponse | DatabaseObjectResponse) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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, {
|
||||
@@ -39,7 +41,10 @@ export default class NotionAPIImportTask extends APIImportTask<IntegrationServic
|
||||
importTask.input.map(async (item) => this.processPage({ item, client }))
|
||||
);
|
||||
|
||||
const taskOutput: ImportTaskOutput = parsedPages.map((parsedPage) => ({
|
||||
// Filter out any null results (from pages/databases that couldn't be accessed)
|
||||
const validParsedPages = parsedPages.filter(Boolean) as ParsePageOutput[];
|
||||
|
||||
const taskOutput: ImportTaskOutput = validParsedPages.map((parsedPage) => ({
|
||||
externalId: parsedPage.externalId,
|
||||
title: parsedPage.title,
|
||||
emoji: parsedPage.emoji,
|
||||
@@ -50,7 +55,7 @@ export default class NotionAPIImportTask extends APIImportTask<IntegrationServic
|
||||
}));
|
||||
|
||||
const childTasksInput: ImportTaskInput<IntegrationService.Notion> =
|
||||
parsedPages.flatMap((parsedPage) =>
|
||||
validParsedPages.flatMap((parsedPage) =>
|
||||
parsedPage.children.map((childPage) => ({
|
||||
type: childPage.type,
|
||||
externalId: childPage.externalId,
|
||||
@@ -88,36 +93,55 @@ export default class NotionAPIImportTask extends APIImportTask<IntegrationServic
|
||||
}: {
|
||||
item: ImportTaskInput<IntegrationService.Notion>[number];
|
||||
client: NotionClient;
|
||||
}): Promise<ParsePageOutput> {
|
||||
}): Promise<ParsePageOutput | null> {
|
||||
const collectionExternalId = item.collectionExternalId ?? item.externalId;
|
||||
|
||||
// Convert Notion database to an empty page with "pages in database" as its children.
|
||||
if (item.type === PageType.Database) {
|
||||
const { pages, ...databaseInfo } = await client.fetchDatabase(
|
||||
item.externalId
|
||||
);
|
||||
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 {
|
||||
...databaseInfo,
|
||||
...pageInfo,
|
||||
externalId: item.externalId,
|
||||
content: ProsemirrorHelper.getEmptyDocument() as ProsemirrorDoc,
|
||||
content: NotionConverter.page({ children: blocks } as NotionPage),
|
||||
collectionExternalId,
|
||||
children: pages.map((page) => ({
|
||||
type: page.type,
|
||||
externalId: page.id,
|
||||
})),
|
||||
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;
|
||||
}
|
||||
|
||||
const { blocks, ...pageInfo } = await client.fetchPage(item.externalId);
|
||||
|
||||
return {
|
||||
...pageInfo,
|
||||
externalId: item.externalId,
|
||||
content: NotionConverter.page({ children: blocks } as NotionPage),
|
||||
collectionExternalId,
|
||||
children: this.parseChildPages(blocks),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import { Node } from "prosemirror-model";
|
||||
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
|
||||
import data from "@server/test/fixtures/notion-page.json";
|
||||
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: data,
|
||||
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();
|
||||
|
||||
@@ -179,11 +179,15 @@ export class NotionConverter {
|
||||
}
|
||||
|
||||
private static bookmark(item: BookmarkBlockObjectResponse) {
|
||||
const caption = item.bookmark.caption
|
||||
.map(this.rich_text_to_plaintext)
|
||||
.join("");
|
||||
|
||||
return {
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
text: item.bookmark.caption.map(this.rich_text_to_plaintext).join(""),
|
||||
text: caption || item.bookmark.url,
|
||||
type: "text",
|
||||
marks: [
|
||||
{
|
||||
@@ -221,17 +225,14 @@ export class NotionConverter {
|
||||
}
|
||||
|
||||
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: [
|
||||
{
|
||||
type: "text",
|
||||
text: item.code.rich_text.map(this.rich_text_to_plaintext).join(""),
|
||||
},
|
||||
],
|
||||
content: text ? [{ type: "text", text }] : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -364,12 +365,14 @@ export class NotionConverter {
|
||||
private static equation(item: EquationBlockObjectResponse) {
|
||||
return {
|
||||
type: "math_block",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: item.equation.expression,
|
||||
},
|
||||
],
|
||||
content: item.equation.expression
|
||||
? [
|
||||
{
|
||||
type: "text",
|
||||
text: item.equation.expression,
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1983,3 +1983,148 @@ exports[`NotionConverter converts a page 1`] = `
|
||||
"type": "doc",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`NotionConverter converts a page with empty text nodes 1`] = `
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"content": [],
|
||||
"type": "paragraph",
|
||||
},
|
||||
{
|
||||
"attrs": {
|
||||
"language": "javascript",
|
||||
},
|
||||
"content": undefined,
|
||||
"type": "code_fence",
|
||||
},
|
||||
{
|
||||
"content": [],
|
||||
"type": "paragraph",
|
||||
},
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"text": "E",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"type": "math_inline",
|
||||
},
|
||||
],
|
||||
"type": "paragraph",
|
||||
},
|
||||
{
|
||||
"content": [],
|
||||
"type": "paragraph",
|
||||
},
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"marks": [
|
||||
{
|
||||
"attrs": {
|
||||
"href": "http://github.com/outline/",
|
||||
},
|
||||
"type": "link",
|
||||
},
|
||||
],
|
||||
"text": "http://github.com/outline/",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"type": "paragraph",
|
||||
},
|
||||
{
|
||||
"content": [],
|
||||
"type": "paragraph",
|
||||
},
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"marks": [
|
||||
{
|
||||
"attrs": {
|
||||
"href": "https://github.com/outline/outline",
|
||||
},
|
||||
"type": "link",
|
||||
},
|
||||
],
|
||||
"text": "https://github.com/outline/outline",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"type": "paragraph",
|
||||
},
|
||||
{
|
||||
"content": [],
|
||||
"type": "paragraph",
|
||||
},
|
||||
{
|
||||
"content": undefined,
|
||||
"type": "math_block",
|
||||
},
|
||||
{
|
||||
"content": [],
|
||||
"type": "paragraph",
|
||||
},
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"marks": [
|
||||
{
|
||||
"attrs": {
|
||||
"href": "https://google.com",
|
||||
"title": null,
|
||||
},
|
||||
"type": "link",
|
||||
},
|
||||
],
|
||||
"text": "https://google.com",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"type": "paragraph",
|
||||
},
|
||||
{
|
||||
"content": [],
|
||||
"type": "paragraph",
|
||||
},
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"marks": [
|
||||
{
|
||||
"attrs": {
|
||||
"href": "https://github.com/outline/outline",
|
||||
},
|
||||
"type": "link",
|
||||
},
|
||||
],
|
||||
"text": "https://github.com/outline/outline",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"type": "paragraph",
|
||||
},
|
||||
{
|
||||
"content": [],
|
||||
"type": "paragraph",
|
||||
},
|
||||
{
|
||||
"attrs": {
|
||||
"href": "https://prod-files-secure.s3.us-west-2.amazonaws.com/2f3fcad6-fc32-434b-b6b2-a03ca7893c4d/49bfa851-95c1-458b-abb0-88ed591f7712/Empty_pdf.pdf",
|
||||
"title": "",
|
||||
},
|
||||
"type": "attachment",
|
||||
},
|
||||
{
|
||||
"content": [],
|
||||
"type": "paragraph",
|
||||
},
|
||||
],
|
||||
"type": "doc",
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Server } from "@hocuspocus/server";
|
||||
import WebSocket from "ws";
|
||||
import EDITOR_VERSION from "@shared/editor/version";
|
||||
import { sleep } from "@server/utils/timers";
|
||||
import { ConnectionLimitExtension } from "./ConnectionLimitExtension";
|
||||
import { EditorVersionExtension } from "./EditorVersionExtension";
|
||||
|
||||
jest.mock("@server/env", () => ({
|
||||
COLLABORATION_MAX_CLIENTS_PER_DOCUMENT: 2,
|
||||
}));
|
||||
|
||||
describe("ConnectionLimitExtension", () => {
|
||||
let server: typeof Server;
|
||||
let extension: ConnectionLimitExtension;
|
||||
const port = 12345;
|
||||
const url = `ws://localhost:${port}`;
|
||||
const documentName = "test";
|
||||
|
||||
beforeEach(async () => {
|
||||
extension = new ConnectionLimitExtension();
|
||||
server = Server.configure({
|
||||
port,
|
||||
extensions: [extension, new EditorVersionExtension()],
|
||||
});
|
||||
await server.listen();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await server.destroy();
|
||||
});
|
||||
|
||||
const getConnections = () =>
|
||||
extension.connectionsByDocument.get(documentName)?.size ?? 0;
|
||||
|
||||
const createWebSocket = (editorVersion = EDITOR_VERSION) =>
|
||||
new Promise<WebSocket>((resolve, reject) => {
|
||||
const ws = new WebSocket(
|
||||
`${url}/${documentName}?editorVersion=${editorVersion}`
|
||||
);
|
||||
ws.on("open", () => resolve(ws));
|
||||
ws.on("error", reject);
|
||||
});
|
||||
|
||||
it("should allow connections within limit", async () => {
|
||||
const ws1 = await createWebSocket();
|
||||
const ws2 = await createWebSocket();
|
||||
|
||||
expect(ws1.readyState).toBe(WebSocket.OPEN);
|
||||
expect(ws2.readyState).toBe(WebSocket.OPEN);
|
||||
expect(getConnections()).toBe(2);
|
||||
|
||||
ws1.close();
|
||||
ws2.close();
|
||||
|
||||
await sleep(250);
|
||||
expect(getConnections()).toBe(0);
|
||||
});
|
||||
|
||||
it("should close connections exceeding limit", async () => {
|
||||
const ws1 = await createWebSocket();
|
||||
const ws2 = await createWebSocket();
|
||||
|
||||
const ws3 = await createWebSocket();
|
||||
await sleep(250);
|
||||
|
||||
expect(ws3.readyState).toBe(WebSocket.CLOSED);
|
||||
expect(ws2.readyState).toBe(WebSocket.OPEN);
|
||||
expect(ws1.readyState).toBe(WebSocket.OPEN);
|
||||
expect(getConnections()).toBe(2);
|
||||
|
||||
ws1.close();
|
||||
ws2.close();
|
||||
|
||||
await sleep(250);
|
||||
expect(getConnections()).toBe(0);
|
||||
});
|
||||
|
||||
it("should handle connections closed by other extensions", async () => {
|
||||
const ws1 = await createWebSocket();
|
||||
|
||||
// Create a connection that will be closed by the EditorVersionExtension
|
||||
const ws2 = await createWebSocket("1.0.0");
|
||||
|
||||
ws1.close();
|
||||
ws2.close();
|
||||
|
||||
await sleep(250);
|
||||
expect(getConnections()).toBe(0);
|
||||
});
|
||||
|
||||
it("should allow new connection after disconnect", async () => {
|
||||
const ws1 = await createWebSocket();
|
||||
const ws2 = await createWebSocket();
|
||||
|
||||
ws1.close();
|
||||
await sleep(250);
|
||||
expect(getConnections()).toBe(1);
|
||||
|
||||
const ws3 = await createWebSocket();
|
||||
expect(ws3.readyState).toBe(WebSocket.OPEN);
|
||||
expect(getConnections()).toBe(2);
|
||||
|
||||
ws2.close();
|
||||
ws3.close();
|
||||
|
||||
await sleep(250);
|
||||
expect(getConnections()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
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";
|
||||
@@ -14,7 +16,7 @@ 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();
|
||||
|
||||
/**
|
||||
* On disconnect hook
|
||||
@@ -34,23 +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();
|
||||
}
|
||||
|
||||
/**
|
||||
* On connect hook
|
||||
* onConnect hook is called when a new connection has been established.
|
||||
* This is where we can check if the document has reached the maximum number of
|
||||
* connections and reject the connection if it has.
|
||||
*
|
||||
* @param data The connect payload
|
||||
* @returns Promise, resolving will allow the connection, rejecting will drop it
|
||||
* @param data The onConnect payload
|
||||
* @returns Promise, resolving will allow the connection, rejecting will drop.
|
||||
*/
|
||||
onConnect({ documentName, socketId }: withContext<onConnectPayload>) {
|
||||
onConnect({ documentName }: withContext<onConnectPayload>) {
|
||||
const connections =
|
||||
this.connectionsByDocument.get(documentName) || new Set();
|
||||
|
||||
if (connections?.size >= env.COLLABORATION_MAX_CLIENTS_PER_DOCUMENT) {
|
||||
Logger.info(
|
||||
"multiplayer",
|
||||
@@ -61,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();
|
||||
|
||||
@@ -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 */
|
||||
@@ -29,20 +28,14 @@ type Props = {
|
||||
|
||||
async function teamCreator({
|
||||
name,
|
||||
domain,
|
||||
subdomain,
|
||||
avatarUrl,
|
||||
authenticationProviders,
|
||||
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 (!avatarUrl || !avatarUrl.startsWith("http")) {
|
||||
avatarUrl = await generateAvatarUrl({
|
||||
domain,
|
||||
id: subdomain,
|
||||
});
|
||||
if (!avatarUrl?.startsWith("http")) {
|
||||
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);
|
||||
|
||||
/**
|
||||
|
||||
@@ -48,9 +48,7 @@ class Metrics {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
ddMetrics.flush(resolve, reject);
|
||||
});
|
||||
return ddMetrics.flush();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,7 +46,9 @@ 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";
|
||||
@@ -217,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;
|
||||
@@ -324,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,
|
||||
@@ -343,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")
|
||||
|
||||
@@ -60,6 +60,9 @@ class Import<T extends ImportableIntegrationService> extends ParanoidModel<
|
||||
@Column(DataType.INTEGER)
|
||||
documentCount: number;
|
||||
|
||||
@Column
|
||||
error: string | null;
|
||||
|
||||
// associations
|
||||
|
||||
@BelongsTo(() => Integration, "integrationId")
|
||||
|
||||
@@ -45,6 +45,9 @@ class ImportTask<T extends ImportableIntegrationService> extends IdModel<
|
||||
@Column(DataType.JSONB)
|
||||
output: ImportTaskOutput | null;
|
||||
|
||||
@Column
|
||||
error: string | null;
|
||||
|
||||
// associations
|
||||
|
||||
@BelongsTo(() => Import, "importId")
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export default function presentImport(
|
||||
service: importModel.service,
|
||||
state: importModel.state,
|
||||
documentCount: importModel.documentCount,
|
||||
error: importModel.error,
|
||||
createdBy: presentUser(importModel.createdBy),
|
||||
createdById: importModel.createdById,
|
||||
createdAt: importModel.createdAt,
|
||||
|
||||
@@ -1,44 +1,85 @@
|
||||
import { Op } from "sequelize";
|
||||
import { GroupUser } from "@server/models";
|
||||
import { DocumentGroupEvent, DocumentUserEvent, Event } from "@server/types";
|
||||
import DocumentSubscriptionTask from "../tasks/DocumentSubscriptionTask";
|
||||
import {
|
||||
CollectionGroupEvent,
|
||||
CollectionUserEvent,
|
||||
DocumentGroupEvent,
|
||||
DocumentUserEvent,
|
||||
Event,
|
||||
} from "@server/types";
|
||||
import CollectionSubscriptionRemoveUserTask from "../tasks/CollectionSubscriptionRemoveUserTask";
|
||||
import DocumentSubscriptionRemoveUserTask from "../tasks/DocumentSubscriptionRemoveUserTask";
|
||||
import BaseProcessor from "./BaseProcessor";
|
||||
|
||||
type ReceivedEvent =
|
||||
| CollectionUserEvent
|
||||
| CollectionGroupEvent
|
||||
| DocumentUserEvent
|
||||
| DocumentGroupEvent;
|
||||
|
||||
export default class DocumentSubscriptionProcessor extends BaseProcessor {
|
||||
static applicableEvents: Event["name"][] = [
|
||||
"collections.remove_user",
|
||||
"collections.remove_group",
|
||||
"documents.remove_user",
|
||||
"documents.remove_group",
|
||||
];
|
||||
|
||||
async perform(event: DocumentUserEvent | DocumentGroupEvent) {
|
||||
async perform(event: ReceivedEvent) {
|
||||
switch (event.name) {
|
||||
case "collections.remove_user": {
|
||||
await CollectionSubscriptionRemoveUserTask.schedule(event);
|
||||
return;
|
||||
}
|
||||
|
||||
case "collections.remove_group":
|
||||
return this.handleRemoveGroupFromCollection(event);
|
||||
|
||||
case "documents.remove_user": {
|
||||
await DocumentSubscriptionTask.schedule(event);
|
||||
await DocumentSubscriptionRemoveUserTask.schedule(event);
|
||||
return;
|
||||
}
|
||||
|
||||
case "documents.remove_group":
|
||||
return this.handleGroup(event);
|
||||
return this.handleRemoveGroupFromDocument(event);
|
||||
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
private async handleGroup(event: DocumentGroupEvent) {
|
||||
private async handleRemoveGroupFromCollection(event: CollectionGroupEvent) {
|
||||
await GroupUser.findAllInBatches<GroupUser>(
|
||||
{
|
||||
where: {
|
||||
groupId: event.modelId,
|
||||
userId: {
|
||||
[Op.ne]: event.actorId,
|
||||
},
|
||||
},
|
||||
batchLimit: 10,
|
||||
},
|
||||
async (groupUsers) => {
|
||||
await Promise.all(
|
||||
groupUsers.map((groupUser) =>
|
||||
DocumentSubscriptionTask.schedule({
|
||||
CollectionSubscriptionRemoveUserTask.schedule({
|
||||
...event,
|
||||
name: "collections.remove_user",
|
||||
userId: groupUser.userId,
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private async handleRemoveGroupFromDocument(event: DocumentGroupEvent) {
|
||||
await GroupUser.findAllInBatches<GroupUser>(
|
||||
{
|
||||
where: {
|
||||
groupId: event.modelId,
|
||||
},
|
||||
batchLimit: 10,
|
||||
},
|
||||
async (groupUsers) => {
|
||||
await Promise.all(
|
||||
groupUsers.map((groupUser) =>
|
||||
DocumentSubscriptionRemoveUserTask.schedule({
|
||||
...event,
|
||||
name: "documents.remove_user",
|
||||
userId: groupUser.userId,
|
||||
|
||||
@@ -6,7 +6,6 @@ import ExportJSONTask from "../tasks/ExportJSONTask";
|
||||
import ExportMarkdownZipTask from "../tasks/ExportMarkdownZipTask";
|
||||
import ImportJSONTask from "../tasks/ImportJSONTask";
|
||||
import ImportMarkdownZipTask from "../tasks/ImportMarkdownZipTask";
|
||||
import ImportNotionTask from "../tasks/ImportNotionTask";
|
||||
import BaseProcessor from "./BaseProcessor";
|
||||
|
||||
export default class FileOperationCreatedProcessor extends BaseProcessor {
|
||||
@@ -25,11 +24,6 @@ export default class FileOperationCreatedProcessor extends BaseProcessor {
|
||||
fileOperationId: event.modelId,
|
||||
});
|
||||
break;
|
||||
case FileOperationFormat.Notion:
|
||||
await ImportNotionTask.schedule({
|
||||
fileOperationId: event.modelId,
|
||||
});
|
||||
break;
|
||||
case FileOperationFormat.JSON:
|
||||
await ImportJSONTask.schedule({
|
||||
fileOperationId: event.modelId,
|
||||
|
||||
@@ -3,7 +3,12 @@ import chunk from "lodash/chunk";
|
||||
import keyBy from "lodash/keyBy";
|
||||
import truncate from "lodash/truncate";
|
||||
import { Fragment, Node } from "prosemirror-model";
|
||||
import { CreateOptions, CreationAttributes, Transaction } from "sequelize";
|
||||
import {
|
||||
CreateOptions,
|
||||
CreationAttributes,
|
||||
Transaction,
|
||||
UniqueConstraintError,
|
||||
} from "sequelize";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { randomElement } from "@shared/random";
|
||||
import { ImportInput, ImportTaskInput } from "@shared/schema";
|
||||
@@ -49,33 +54,46 @@ export default abstract class ImportsProcessor<
|
||||
* @param event The import event
|
||||
*/
|
||||
public async perform(event: ImportEvent) {
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
const importModel = await Import.findByPk<Import<T>>(event.modelId, {
|
||||
rejectOnEmpty: true,
|
||||
paranoid: false,
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
try {
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
const importModel = await Import.findByPk<Import<T>>(event.modelId, {
|
||||
rejectOnEmpty: true,
|
||||
paranoid: false,
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
});
|
||||
|
||||
if (
|
||||
!this.canProcess(importModel) ||
|
||||
importModel.state === ImportState.Errored ||
|
||||
importModel.state === ImportState.Canceled
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.name) {
|
||||
case "imports.create":
|
||||
return this.onCreation(importModel, transaction);
|
||||
|
||||
case "imports.processed":
|
||||
return this.onProcessed(importModel, transaction);
|
||||
|
||||
case "imports.delete":
|
||||
return this.onDeletion(importModel, event, transaction);
|
||||
}
|
||||
});
|
||||
|
||||
if (
|
||||
!this.canProcess(importModel) ||
|
||||
importModel.state === ImportState.Errored ||
|
||||
importModel.state === ImportState.Canceled
|
||||
) {
|
||||
return;
|
||||
} catch (err) {
|
||||
if (event.name !== "imports.delete" && err instanceof Error) {
|
||||
const importModel = await Import.findByPk<Import<T>>(event.modelId, {
|
||||
rejectOnEmpty: true,
|
||||
paranoid: false,
|
||||
});
|
||||
importModel.error = truncate(err.message, { length: 255 });
|
||||
await importModel.save();
|
||||
}
|
||||
|
||||
switch (event.name) {
|
||||
case "imports.create":
|
||||
return this.onCreation(importModel, transaction);
|
||||
|
||||
case "imports.processed":
|
||||
return this.onProcessed(importModel, transaction);
|
||||
|
||||
case "imports.delete":
|
||||
return this.onDeletion(importModel, event, transaction);
|
||||
}
|
||||
});
|
||||
throw err; // throw error for retry.
|
||||
}
|
||||
}
|
||||
|
||||
public async onFailed(event: ImportEvent) {
|
||||
@@ -141,44 +159,59 @@ export default abstract class ImportsProcessor<
|
||||
* @returns Promise that resolves when mapping and persistence is completed.
|
||||
*/
|
||||
private async onProcessed(importModel: Import<T>, transaction: Transaction) {
|
||||
const { collections } = await this.createCollectionsAndDocuments({
|
||||
importModel,
|
||||
transaction,
|
||||
});
|
||||
|
||||
// Once all collections and documents are created, update collection's document structure.
|
||||
// This ensures the root documents have the whole subtree available in the structure.
|
||||
for (const collection of collections) {
|
||||
await Document.unscoped().findAllInBatches<Document>(
|
||||
{
|
||||
where: { parentDocumentId: null, collectionId: collection.id },
|
||||
order: [
|
||||
["createdAt", "DESC"],
|
||||
["id", "ASC"],
|
||||
],
|
||||
transaction,
|
||||
},
|
||||
async (documents) => {
|
||||
for (const document of documents) {
|
||||
await collection.addDocumentToStructure(document, 0, {
|
||||
save: false,
|
||||
silent: true,
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await collection.save({ silent: true, transaction });
|
||||
}
|
||||
|
||||
importModel.state = ImportState.Completed;
|
||||
await importModel.saveWithCtx(
|
||||
createContext({
|
||||
user: importModel.createdBy,
|
||||
try {
|
||||
const { collections } = await this.createCollectionsAndDocuments({
|
||||
importModel,
|
||||
transaction,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Once all collections and documents are created, update collection's document structure.
|
||||
// This ensures the root documents have the whole subtree available in the structure.
|
||||
for (const collection of collections) {
|
||||
await Document.unscoped().findAllInBatches<Document>(
|
||||
{
|
||||
where: { parentDocumentId: null, collectionId: collection.id },
|
||||
order: [
|
||||
["createdAt", "DESC"],
|
||||
["id", "ASC"],
|
||||
],
|
||||
transaction,
|
||||
},
|
||||
async (documents) => {
|
||||
for (const document of documents) {
|
||||
await collection.addDocumentToStructure(document, 0, {
|
||||
save: false,
|
||||
silent: true,
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await collection.save({ silent: true, transaction });
|
||||
}
|
||||
|
||||
importModel.state = ImportState.Completed;
|
||||
importModel.error = null; // unset any error from previous attempts.
|
||||
await importModel.saveWithCtx(
|
||||
createContext({
|
||||
user: importModel.createdBy,
|
||||
transaction,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof UniqueConstraintError) {
|
||||
Logger.error(
|
||||
"ImportsProcessor persistence failed due to unique constraint error",
|
||||
err,
|
||||
{
|
||||
fields: err.fields,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -290,6 +323,15 @@ export default abstract class ImportsProcessor<
|
||||
|
||||
const output = outputMap[externalId];
|
||||
|
||||
// Skip this item if it has no output (likely due to an error during processing)
|
||||
if (!output) {
|
||||
Logger.debug(
|
||||
"processor",
|
||||
`Skipping item with no output: ${externalId}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const collectionItem = importInput[externalId];
|
||||
|
||||
const attachments = await Attachment.findAll({
|
||||
@@ -430,7 +472,7 @@ export default abstract class ImportsProcessor<
|
||||
importInput: Record<string, ImportInput<any>[number]>;
|
||||
actorId: string;
|
||||
}): ProsemirrorDoc {
|
||||
// special case when the doc content is empty
|
||||
// special case when the doc content is empty.
|
||||
if (!content.content.length) {
|
||||
return content;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { JobOptions } from "bull";
|
||||
import chunk from "lodash/chunk";
|
||||
import truncate from "lodash/truncate";
|
||||
import uniqBy from "lodash/uniqBy";
|
||||
import { Fragment, Node } from "prosemirror-model";
|
||||
import { Transaction, WhereOptions } from "sequelize";
|
||||
@@ -63,20 +64,29 @@ export default abstract class APIImportTask<
|
||||
return;
|
||||
}
|
||||
|
||||
switch (importTask.state) {
|
||||
case ImportTaskState.Created: {
|
||||
importTask.state = ImportTaskState.InProgress;
|
||||
importTask = await importTask.save();
|
||||
return await this.onProcess(importTask);
|
||||
try {
|
||||
switch (importTask.state) {
|
||||
case ImportTaskState.Created: {
|
||||
importTask.state = ImportTaskState.InProgress;
|
||||
importTask = await importTask.save();
|
||||
return await this.onProcess(importTask);
|
||||
}
|
||||
|
||||
case ImportTaskState.InProgress:
|
||||
return await this.onProcess(importTask);
|
||||
|
||||
case ImportTaskState.Completed:
|
||||
return await this.onCompletion(importTask);
|
||||
|
||||
default:
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
importTask.error = truncate(err.message, { length: 255 });
|
||||
await importTask.save();
|
||||
}
|
||||
|
||||
case ImportTaskState.InProgress:
|
||||
return await this.onProcess(importTask);
|
||||
|
||||
case ImportTaskState.Completed:
|
||||
return await this.onCompletion(importTask);
|
||||
|
||||
default:
|
||||
throw err; // throw error for retry.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +118,7 @@ export default abstract class APIImportTask<
|
||||
await importTask.save({ transaction });
|
||||
|
||||
const associatedImport = importTask.import;
|
||||
associatedImport.error = importTask.error; // copy error from ImportTask that caused the failure.
|
||||
associatedImport.state = ImportState.Errored;
|
||||
await associatedImport.saveWithCtx(
|
||||
createContext({
|
||||
@@ -155,10 +166,11 @@ export default abstract class APIImportTask<
|
||||
|
||||
importTask.output = taskOutputWithReplacements;
|
||||
importTask.state = ImportTaskState.Completed;
|
||||
importTask.error = null; // unset any error from previous attempts.
|
||||
await importTask.save({ transaction });
|
||||
|
||||
const associatedImport = importTask.import;
|
||||
associatedImport.documentCount += importTask.input.length;
|
||||
associatedImport.documentCount += taskOutputWithReplacements.length;
|
||||
await associatedImport.saveWithCtx(
|
||||
createContext({
|
||||
user: associatedImport.createdBy,
|
||||
|
||||
@@ -41,6 +41,7 @@ export default class CleanupOldImportsTask extends BaseTask<Props> {
|
||||
],
|
||||
batchLimit: 50,
|
||||
totalLimit: maxImportsPerTask,
|
||||
paranoid: false,
|
||||
},
|
||||
async (imports) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Transaction } from "sequelize";
|
||||
import { SubscriptionType } from "@shared/types";
|
||||
import { createContext } from "@server/context";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { Collection, Subscription, User } from "@server/models";
|
||||
import { can } from "@server/policies";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
import { CollectionUserEvent } from "@server/types";
|
||||
import BaseTask from "./BaseTask";
|
||||
|
||||
export default class CollectionSubscriptionRemoveUserTask extends BaseTask<CollectionUserEvent> {
|
||||
public async perform(event: CollectionUserEvent) {
|
||||
const user = await User.findByPk(event.userId);
|
||||
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(event.collectionId);
|
||||
|
||||
if (can(user, "read", collection)) {
|
||||
Logger.debug(
|
||||
"task",
|
||||
`Skip unsubscribing user ${user.id} as they have permission to the collection ${event.collectionId} through other means`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
const subscription = await Subscription.findOne({
|
||||
where: {
|
||||
userId: user.id,
|
||||
collectionId: event.collectionId,
|
||||
event: SubscriptionType.Document,
|
||||
},
|
||||
transaction,
|
||||
lock: Transaction.LOCK.UPDATE,
|
||||
});
|
||||
|
||||
await subscription?.destroyWithCtx(
|
||||
createContext({
|
||||
user,
|
||||
authType: event.authType,
|
||||
ip: event.ip,
|
||||
transaction,
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,6 @@ export default class DocumentPublishedNotificationsTask extends BaseTask<Documen
|
||||
await NotificationHelper.getDocumentNotificationRecipients({
|
||||
document,
|
||||
notificationType: NotificationEventType.PublishDocument,
|
||||
onlySubscribers: false,
|
||||
actorId: document.lastModifiedById,
|
||||
})
|
||||
).filter((recipient) => !userIdsMentioned.includes(recipient.id));
|
||||
|
||||
+2
-2
@@ -8,11 +8,11 @@ import { sequelize } from "@server/storage/database";
|
||||
import { DocumentUserEvent } from "@server/types";
|
||||
import BaseTask from "./BaseTask";
|
||||
|
||||
export default class DocumentSubscriptionTask extends BaseTask<DocumentUserEvent> {
|
||||
export default class DocumentSubscriptionRemoveUserTask extends BaseTask<DocumentUserEvent> {
|
||||
public async perform(event: DocumentUserEvent) {
|
||||
const user = await User.findByPk(event.userId);
|
||||
|
||||
if (!user || event.name !== "documents.remove_user") {
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -56,11 +56,13 @@ export default class ErrorTimedOutImportsTask extends BaseTask<Props> {
|
||||
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
importTask.state = ImportTaskState.Errored;
|
||||
importTask.error = "Timed out";
|
||||
await importTask.save({ transaction });
|
||||
|
||||
// this import could have been seen before in another import_task.
|
||||
if (!importsErrored[associatedImport.id]) {
|
||||
associatedImport.state = ImportState.Errored;
|
||||
associatedImport.error = "Timed out";
|
||||
await associatedImport.save({ transaction });
|
||||
importsErrored[associatedImport.id] = true;
|
||||
}
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-empty-function */
|
||||
import path from "path";
|
||||
import { FileOperation } from "@server/models";
|
||||
import { buildFileOperation } from "@server/test/factories";
|
||||
import ImportNotionTask from "./ImportNotionTask";
|
||||
|
||||
describe("ImportNotionTask", () => {
|
||||
it("should import successfully from a Markdown export", async () => {
|
||||
const fileOperation = await buildFileOperation();
|
||||
Object.defineProperty(fileOperation, "handle", {
|
||||
get() {
|
||||
return {
|
||||
path: path.resolve(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"test",
|
||||
"fixtures",
|
||||
"notion-markdown.zip"
|
||||
),
|
||||
cleanup: async () => {},
|
||||
};
|
||||
},
|
||||
});
|
||||
jest.spyOn(FileOperation, "findByPk").mockResolvedValue(fileOperation);
|
||||
|
||||
const props = {
|
||||
fileOperationId: fileOperation.id,
|
||||
};
|
||||
|
||||
const task = new ImportNotionTask();
|
||||
const response = await task.perform(props);
|
||||
|
||||
expect(response.collections.size).toEqual(2);
|
||||
expect(response.documents.size).toEqual(6);
|
||||
expect(response.attachments.size).toEqual(1);
|
||||
|
||||
// Check that the image url was replaced in the text with a redirect
|
||||
const attachments = Array.from(response.attachments.values());
|
||||
const documents = Array.from(response.documents.values());
|
||||
expect(documents.map((d) => d.text).join("")).toContain(
|
||||
attachments[0].redirectUrl
|
||||
);
|
||||
});
|
||||
|
||||
it("should import successfully from a HTML export", async () => {
|
||||
const fileOperation = await buildFileOperation();
|
||||
Object.defineProperty(fileOperation, "handle", {
|
||||
get() {
|
||||
return {
|
||||
path: path.resolve(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"test",
|
||||
"fixtures",
|
||||
"notion-html.zip"
|
||||
),
|
||||
cleanup: async () => {},
|
||||
};
|
||||
},
|
||||
});
|
||||
jest.spyOn(FileOperation, "findByPk").mockResolvedValue(fileOperation);
|
||||
|
||||
const props = {
|
||||
fileOperationId: fileOperation.id,
|
||||
};
|
||||
|
||||
const task = new ImportNotionTask();
|
||||
const response = await task.perform(props);
|
||||
|
||||
expect(response.collections.size).toEqual(2);
|
||||
expect(response.documents.size).toEqual(6);
|
||||
expect(response.attachments.size).toEqual(4);
|
||||
|
||||
// Check that the image url was replaced in the text with a redirect
|
||||
const attachments = Array.from(response.attachments.values());
|
||||
const attachment = attachments.find((att) =>
|
||||
att.key.endsWith("Screen_Shot_2022-04-21_at_2.23.26_PM.png")
|
||||
);
|
||||
|
||||
const documents = Array.from(response.documents.values());
|
||||
expect(documents.map((d) => d.text).join("")).toContain(
|
||||
attachment?.redirectUrl
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,354 +0,0 @@
|
||||
import path from "path";
|
||||
import fs from "fs-extra";
|
||||
import compact from "lodash/compact";
|
||||
import escapeRegExp from "lodash/escapeRegExp";
|
||||
import mime from "mime-types";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import documentImporter from "@server/commands/documentImporter";
|
||||
import { createContext } from "@server/context";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { FileOperation, User } from "@server/models";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
import ImportHelper, { FileTreeNode } from "@server/utils/ImportHelper";
|
||||
import ImportTask, { StructuredImportData } from "./ImportTask";
|
||||
|
||||
export default class ImportNotionTask extends ImportTask {
|
||||
public async parseData(
|
||||
dirPath: string,
|
||||
fileOperation: FileOperation
|
||||
): Promise<StructuredImportData> {
|
||||
const tree = await ImportHelper.toFileTree(dirPath);
|
||||
if (!tree) {
|
||||
throw new Error("Could not find valid content in zip file");
|
||||
}
|
||||
|
||||
// New Notion exports have a single folder with the name of the export, we must skip this
|
||||
// folder and go directly to the children.
|
||||
let parsed;
|
||||
if (
|
||||
tree.children.length === 1 &&
|
||||
tree.children[0].children.find((child) => child.title === "index")
|
||||
) {
|
||||
parsed = await this.parseFileTree(
|
||||
fileOperation,
|
||||
tree.children[0].children.filter((child) => child.title !== "index")
|
||||
);
|
||||
} else {
|
||||
parsed = await this.parseFileTree(fileOperation, tree.children);
|
||||
}
|
||||
|
||||
if (parsed.documents.length === 0 && parsed.collections.length === 1) {
|
||||
const collection = parsed.collections[0];
|
||||
const collectionId = uuidv4();
|
||||
if (collection.description) {
|
||||
parsed.documents.push({
|
||||
title: collection.name,
|
||||
icon: collection.icon,
|
||||
color: collection.color,
|
||||
path: "",
|
||||
text: String(collection.description),
|
||||
id: collection.id,
|
||||
externalId: collection.externalId,
|
||||
mimeType: "text/html",
|
||||
collectionId,
|
||||
});
|
||||
}
|
||||
|
||||
collection.name = "Notion";
|
||||
collection.icon = undefined;
|
||||
collection.color = undefined;
|
||||
collection.externalId = undefined;
|
||||
collection.description = undefined;
|
||||
collection.id = collectionId;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the file structure from zipAsFileTree into documents,
|
||||
* collections, and attachments.
|
||||
*
|
||||
* @param fileOperation The file operation
|
||||
* @param tree An array of FileTreeNode representing root files in the zip
|
||||
* @returns A StructuredImportData object
|
||||
*/
|
||||
private async parseFileTree(
|
||||
fileOperation: FileOperation,
|
||||
tree: FileTreeNode[]
|
||||
): Promise<StructuredImportData> {
|
||||
const user = await User.findByPk(fileOperation.userId, {
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
|
||||
const output: StructuredImportData = {
|
||||
collections: [],
|
||||
documents: [],
|
||||
attachments: [],
|
||||
};
|
||||
|
||||
const parseNodeChildren = async (
|
||||
children: FileTreeNode[],
|
||||
collectionId: string,
|
||||
parentDocumentId?: string
|
||||
): Promise<void> => {
|
||||
await Promise.all(
|
||||
children.map(async (child) => {
|
||||
// Ignore the CSV's for databases upfront
|
||||
if (child.path.endsWith(".csv")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = uuidv4();
|
||||
const match = child.title.match(this.NotionUUIDRegex);
|
||||
const name = child.title.replace(this.NotionUUIDRegex, "");
|
||||
const externalId = match ? match[0].trim() : undefined;
|
||||
|
||||
// If it's not a text file we're going to treat it as an attachment.
|
||||
const mimeType = mime.lookup(child.name);
|
||||
const isDocument =
|
||||
mimeType === "text/markdown" ||
|
||||
mimeType === "text/plain" ||
|
||||
mimeType === "text/html";
|
||||
|
||||
// If it's not a document and not a folder, treat it as an attachment
|
||||
if (!isDocument && mimeType) {
|
||||
output.attachments.push({
|
||||
id,
|
||||
name: child.name,
|
||||
path: child.path,
|
||||
mimeType,
|
||||
buffer: () => fs.readFile(child.path),
|
||||
externalId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.debug("task", `Processing ${name} as ${mimeType}`);
|
||||
|
||||
const { title, icon, text } = await sequelize.transaction(
|
||||
async (transaction) =>
|
||||
documentImporter({
|
||||
mimeType: mimeType || "text/markdown",
|
||||
fileName: name,
|
||||
content:
|
||||
child.children.length > 0
|
||||
? ""
|
||||
: await fs.readFile(child.path, "utf8"),
|
||||
user,
|
||||
ctx: createContext({ user, transaction }),
|
||||
})
|
||||
);
|
||||
|
||||
const existingDocumentIndex = output.documents.findIndex(
|
||||
(doc) => doc.externalId === externalId
|
||||
);
|
||||
|
||||
const existingDocument = output.documents[existingDocumentIndex];
|
||||
|
||||
// If there is an existing document with the same externalId that means
|
||||
// we've already parsed either a folder or a file referencing the same
|
||||
// document, as such we should merge.
|
||||
if (existingDocument) {
|
||||
if (existingDocument.text === "") {
|
||||
output.documents[existingDocumentIndex].text = text;
|
||||
}
|
||||
|
||||
await parseNodeChildren(
|
||||
child.children,
|
||||
collectionId,
|
||||
existingDocument.id
|
||||
);
|
||||
} else {
|
||||
output.documents.push({
|
||||
id,
|
||||
title,
|
||||
icon,
|
||||
text,
|
||||
collectionId,
|
||||
parentDocumentId,
|
||||
path: child.path,
|
||||
mimeType: mimeType || "text/markdown",
|
||||
externalId,
|
||||
});
|
||||
await parseNodeChildren(child.children, collectionId, id);
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const replaceInternalLinksAndImages = (text: string) => {
|
||||
// Find if there are any images in this document
|
||||
const imagesInText = this.parseImages(text);
|
||||
|
||||
for (const image of imagesInText) {
|
||||
const name = path.basename(image.src);
|
||||
const attachment = output.attachments.find(
|
||||
(att) =>
|
||||
att.path.endsWith(image.src) ||
|
||||
encodeURI(att.path).endsWith(image.src)
|
||||
);
|
||||
|
||||
if (!attachment) {
|
||||
if (!image.src.startsWith("http")) {
|
||||
Logger.info(
|
||||
"task",
|
||||
`Could not find referenced attachment with name ${name} and src ${image.src}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
text = text.replace(
|
||||
new RegExp(escapeRegExp(image.src), "g"),
|
||||
`<<${attachment.id}>>`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// With Notion's HTML import, images sometimes come wrapped in anchor tags
|
||||
// This isn't supported in Outline's editor, so we need to strip them.
|
||||
text = text.replace(/\[!\[([^[]+)]/g, "![]");
|
||||
|
||||
// Find if there are any links in this document pointing to other documents
|
||||
const internalLinksInText = this.parseInternalLinks(text);
|
||||
|
||||
// For each link update to the standardized format of <<documentId>>
|
||||
// instead of a relative or absolute URL within the original zip file.
|
||||
for (const link of internalLinksInText) {
|
||||
const doc = output.documents.find(
|
||||
(doc) => doc.externalId === link.externalId
|
||||
);
|
||||
|
||||
if (!doc) {
|
||||
Logger.info(
|
||||
"task",
|
||||
`Could not find referenced document with externalId ${link.externalId}`
|
||||
);
|
||||
} else {
|
||||
text = text.replace(link.href, `<<${doc.id}>>`);
|
||||
}
|
||||
}
|
||||
|
||||
return text;
|
||||
};
|
||||
|
||||
// All nodes in the root level should become collections
|
||||
for (const node of tree) {
|
||||
const match = node.title.match(this.NotionUUIDRegex);
|
||||
const name = node.title.replace(this.NotionUUIDRegex, "");
|
||||
const externalId = match ? match[0].trim() : undefined;
|
||||
const mimeType = mime.lookup(node.name);
|
||||
|
||||
const existingCollectionIndex = output.collections.findIndex(
|
||||
(collection) => collection.externalId === externalId
|
||||
);
|
||||
const existingCollection = output.collections[existingCollectionIndex];
|
||||
const collectionId = existingCollection?.id || uuidv4();
|
||||
let description;
|
||||
|
||||
// Root level docs become the descriptions of collections
|
||||
if (
|
||||
mimeType === "text/markdown" ||
|
||||
mimeType === "text/plain" ||
|
||||
mimeType === "text/html"
|
||||
) {
|
||||
const { text } = await sequelize.transaction(async (transaction) =>
|
||||
documentImporter({
|
||||
mimeType,
|
||||
fileName: name,
|
||||
content: await fs.readFile(node.path, "utf8"),
|
||||
user,
|
||||
ctx: createContext({ user, transaction }),
|
||||
})
|
||||
);
|
||||
|
||||
description = text;
|
||||
} else if (node.children.length > 0) {
|
||||
await parseNodeChildren(node.children, collectionId);
|
||||
} else {
|
||||
Logger.debug("task", `Unhandled file in zip: ${node.path}`, {
|
||||
fileOperationId: fileOperation.id,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existingCollectionIndex !== -1) {
|
||||
if (description) {
|
||||
output.collections[existingCollectionIndex].description = description;
|
||||
}
|
||||
} else {
|
||||
output.collections.push({
|
||||
id: collectionId,
|
||||
name,
|
||||
description,
|
||||
externalId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const document of output.documents) {
|
||||
document.text = replaceInternalLinksAndImages(document.text);
|
||||
}
|
||||
|
||||
for (const collection of output.collections) {
|
||||
if (typeof collection.description === "string") {
|
||||
collection.description = replaceInternalLinksAndImages(
|
||||
collection.description
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts internal links from a markdown document, taking into account the
|
||||
* externalId of the document, which is part of the link title.
|
||||
*
|
||||
* @param text The markdown text to parse
|
||||
* @returns An array of internal links
|
||||
*/
|
||||
private parseInternalLinks(
|
||||
text: string
|
||||
): { title: string; href: string; externalId: string }[] {
|
||||
return compact(
|
||||
[...text.matchAll(this.NotionLinkRegex)].map((match) => ({
|
||||
title: match[1],
|
||||
href: match[2],
|
||||
externalId: match[3],
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts images from the markdown document
|
||||
*
|
||||
* @param text The markdown text to parse
|
||||
* @returns An array of internal links
|
||||
*/
|
||||
private parseImages(text: string): { alt: string; src: string }[] {
|
||||
return compact(
|
||||
[...text.matchAll(this.ImageRegex)].map((match) => ({
|
||||
alt: match[1],
|
||||
src: match[2],
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Regex to find markdown images of all types
|
||||
*/
|
||||
private ImageRegex =
|
||||
/!\[(?<alt>[^\][]*?)]\((?<filename>[^\][]*?)(?=“|\))“?(?<title>[^\][”]+)?”?\)/g;
|
||||
|
||||
/**
|
||||
* Regex to find markdown links containing ID's that look like UUID's with the
|
||||
* "-"'s removed, Notion's externalId format.
|
||||
*/
|
||||
private NotionLinkRegex = /\[([^[]+)]\((.*?([0-9a-fA-F]{32})\..*?)\)/g;
|
||||
|
||||
/**
|
||||
* Regex to find Notion document UUID's in the title of a document.
|
||||
*/
|
||||
private NotionUUIDRegex =
|
||||
/\s([0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}|[0-9a-fA-F]{32})$/;
|
||||
}
|
||||
@@ -76,7 +76,6 @@ export default class RevisionCreatedNotificationsTask extends BaseTask<RevisionE
|
||||
await NotificationHelper.getDocumentNotificationRecipients({
|
||||
document,
|
||||
notificationType: NotificationEventType.UpdateDocument,
|
||||
onlySubscribers: true,
|
||||
actorId: document.lastModifiedById,
|
||||
})
|
||||
).filter((recipient) => !userIdsMentioned.includes(recipient.id));
|
||||
|
||||
@@ -1327,6 +1327,32 @@ describe("#collections.create", () => {
|
||||
expect(body.policies[0].abilities.read).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should ensure unique index across the team", async () => {
|
||||
const team = await buildTeam();
|
||||
const [adminA, adminB] = await Promise.all([
|
||||
buildAdmin({ teamId: team.id }),
|
||||
buildAdmin({ teamId: team.id }),
|
||||
]);
|
||||
|
||||
const resA = await server.post("/api/collections.create", {
|
||||
body: {
|
||||
token: adminA.getJwtToken(),
|
||||
name: "Test A",
|
||||
},
|
||||
});
|
||||
const resB = await server.post("/api/collections.create", {
|
||||
body: {
|
||||
token: adminB.getJwtToken(),
|
||||
name: "Test B",
|
||||
},
|
||||
});
|
||||
const [bodyA, bodyB] = await Promise.all([resA.json(), resB.json()]);
|
||||
|
||||
expect(resA.status).toEqual(200);
|
||||
expect(resB.status).toEqual(200);
|
||||
expect(bodyA.data.index).not.toEqual(bodyB.data.index);
|
||||
});
|
||||
|
||||
it("if index collision, should updated index of other collection", async () => {
|
||||
const user = await buildUser();
|
||||
const createdCollectionAResponse = await server.post(
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import fractionalIndex from "fractional-index";
|
||||
import invariant from "invariant";
|
||||
import Router from "koa-router";
|
||||
import { Sequelize, Op, WhereOptions } from "sequelize";
|
||||
@@ -42,7 +41,6 @@ import {
|
||||
import { APIContext } from "@server/types";
|
||||
import { RateLimiterStrategy } from "@server/utils/RateLimiter";
|
||||
import { collectionIndexing } from "@server/utils/indexing";
|
||||
import removeIndexCollision from "@server/utils/removeIndexCollision";
|
||||
import pagination from "../middlewares/pagination";
|
||||
import * as T from "./schema";
|
||||
|
||||
@@ -55,23 +53,21 @@ router.post(
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.CollectionsCreateReq>) => {
|
||||
const { transaction } = ctx.state;
|
||||
const { name, color, description, data, permission, sharing, icon, sort } =
|
||||
ctx.input.body;
|
||||
let { index } = ctx.input.body;
|
||||
const {
|
||||
name,
|
||||
color,
|
||||
description,
|
||||
data,
|
||||
permission,
|
||||
sharing,
|
||||
icon,
|
||||
sort,
|
||||
index,
|
||||
} = ctx.input.body;
|
||||
|
||||
const { user } = ctx.state.auth;
|
||||
authorize(user, "createCollection", user.team);
|
||||
|
||||
if (index) {
|
||||
index = await removeIndexCollision(user.teamId, index, { transaction });
|
||||
} else {
|
||||
const first = await Collection.findFirstCollectionForUser(user, {
|
||||
attributes: ["id", "index"],
|
||||
transaction,
|
||||
});
|
||||
index = fractionalIndex(null, first ? first.index : null);
|
||||
}
|
||||
|
||||
const collection = Collection.build({
|
||||
name,
|
||||
content: data,
|
||||
@@ -601,6 +597,7 @@ router.post(
|
||||
createdById: user.id,
|
||||
},
|
||||
transaction,
|
||||
hooks: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -959,18 +956,16 @@ router.post(
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.CollectionsMoveReq>) => {
|
||||
const { transaction } = ctx.state;
|
||||
const { id } = ctx.input.body;
|
||||
let { index } = ctx.input.body;
|
||||
const { id, index } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
|
||||
const collection = await Collection.findByPk(id, {
|
||||
let collection = await Collection.findByPk(id, {
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
});
|
||||
authorize(user, "move", collection);
|
||||
|
||||
index = await removeIndexCollision(user.teamId, index, { transaction });
|
||||
await collection.update(
|
||||
collection = await collection.update(
|
||||
{
|
||||
index,
|
||||
},
|
||||
@@ -982,14 +977,14 @@ router.post(
|
||||
name: "collections.move",
|
||||
collectionId: collection.id,
|
||||
data: {
|
||||
index,
|
||||
index: collection.index,
|
||||
},
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
data: {
|
||||
index,
|
||||
index: collection.index,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import queryString from "query-string";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { randomElement } from "@shared/random";
|
||||
import { NotificationEventType } from "@shared/types";
|
||||
import NotificationSettingsHelper from "@server/models/helpers/NotificationSettingsHelper";
|
||||
import {
|
||||
buildCollection,
|
||||
buildDocument,
|
||||
@@ -697,3 +698,40 @@ describe("#notifications.update_all", () => {
|
||||
expect(body.data.total).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#notifications.unsubscribe", () => {
|
||||
it("should allow unsubscribe with valid token", async () => {
|
||||
const user = await buildUser();
|
||||
const token = NotificationSettingsHelper.unsubscribeToken(
|
||||
user.id,
|
||||
NotificationEventType.UpdateDocument
|
||||
);
|
||||
|
||||
const res = await server.get(
|
||||
`/api/notifications.unsubscribe?userId=${user.id}&token=${token}&eventType=documents.update&follow=true`,
|
||||
{
|
||||
redirect: "manual",
|
||||
}
|
||||
);
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get("location")).toContain(
|
||||
"/settings/notifications?success"
|
||||
);
|
||||
|
||||
const events = (await user.reload()).notificationSettings;
|
||||
expect(events).not.toContain("documents.update");
|
||||
});
|
||||
|
||||
it("should not allow unsubscribe with invalid token", async () => {
|
||||
const user = await buildUser();
|
||||
|
||||
const res = await server.get(
|
||||
`/api/notifications.unsubscribe?userId=${user.id}&token=invalid-token&eventType=documents.update&follow=true`,
|
||||
{
|
||||
redirect: "manual",
|
||||
}
|
||||
);
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get("location")).toContain("?notice=invalid-auth");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,7 +53,7 @@ const handleUnsubscribe = async (
|
||||
});
|
||||
|
||||
user.setNotificationEventType(eventType, false);
|
||||
await user.save();
|
||||
await user.save({ transaction });
|
||||
ctx.redirect(`${user.team.url}/settings/notifications?success`);
|
||||
};
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ import { safeEqual } from "@server/utils/crypto";
|
||||
import * as T from "./schema";
|
||||
|
||||
const router = new Router();
|
||||
const emailEnabled = !!(env.SMTP_HOST || env.isDevelopment);
|
||||
|
||||
const handleTeamUpdate = async (ctx: APIContext<T.TeamsUpdateSchemaReq>) => {
|
||||
const { transaction } = ctx.state;
|
||||
@@ -68,7 +67,7 @@ router.post(
|
||||
rateLimiter(RateLimiterStrategy.FivePerHour),
|
||||
auth(),
|
||||
async (ctx: APIContext) => {
|
||||
if (!emailEnabled) {
|
||||
if (!env.EMAIL_ENABLED) {
|
||||
throw ValidationError("Email support is not setup for this instance");
|
||||
}
|
||||
|
||||
@@ -101,7 +100,7 @@ router.post(
|
||||
|
||||
authorize(user, "delete", team);
|
||||
|
||||
if (emailEnabled) {
|
||||
if (env.EMAIL_ENABLED) {
|
||||
const deleteConfirmationCode = team.getDeleteConfirmationCode(user);
|
||||
|
||||
if (!safeEqual(code, deleteConfirmationCode)) {
|
||||
|
||||
@@ -30,7 +30,6 @@ import pagination from "../middlewares/pagination";
|
||||
import * as T from "./schema";
|
||||
|
||||
const router = new Router();
|
||||
const emailEnabled = !!(env.SMTP_HOST || env.isDevelopment);
|
||||
|
||||
router.post(
|
||||
"users.list",
|
||||
@@ -210,7 +209,7 @@ router.post(
|
||||
auth(),
|
||||
validate(T.UsersUpdateEmailSchema),
|
||||
async (ctx: APIContext<T.UsersUpdateEmailReq>) => {
|
||||
if (!emailEnabled) {
|
||||
if (!env.EMAIL_ENABLED) {
|
||||
throw ValidationError("Email support is not setup for this instance");
|
||||
}
|
||||
|
||||
@@ -252,7 +251,7 @@ router.get(
|
||||
transaction(),
|
||||
validate(T.UsersUpdateEmailConfirmSchema),
|
||||
async (ctx: APIContext<T.UsersUpdateEmailConfirmReq>) => {
|
||||
if (!emailEnabled) {
|
||||
if (!env.EMAIL_ENABLED) {
|
||||
throw ValidationError("Email support is not setup for this instance");
|
||||
}
|
||||
|
||||
@@ -626,7 +625,7 @@ router.post(
|
||||
rateLimiter(RateLimiterStrategy.FivePerHour),
|
||||
auth(),
|
||||
async (ctx: APIContext) => {
|
||||
if (!emailEnabled) {
|
||||
if (!env.EMAIL_ENABLED) {
|
||||
throw ValidationError("Email support is not setup for this instance");
|
||||
}
|
||||
|
||||
@@ -671,7 +670,7 @@ router.post(
|
||||
|
||||
// If we're attempting to delete our own account then a confirmation code
|
||||
// is required. This acts as CSRF protection.
|
||||
if ((!id || id === actor.id) && emailEnabled) {
|
||||
if ((!id || id === actor.id) && env.EMAIL_ENABLED) {
|
||||
const deleteConfirmationCode = user.deleteConfirmationCode;
|
||||
|
||||
if (!safeEqual(code, deleteConfirmationCode)) {
|
||||
|
||||
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,509 @@
|
||||
[
|
||||
{
|
||||
"object": "block",
|
||||
"id": "1c12c2bb-bca8-803b-a8dc-d10fdf86fd60",
|
||||
"parent": {
|
||||
"type": "page_id",
|
||||
"page_id": "1c12c2bb-bca8-8023-ba9a-f8fafab74e48"
|
||||
},
|
||||
"created_time": "2025-03-25T07:49:00.000Z",
|
||||
"last_edited_time": "2025-03-25T07:49:00.000Z",
|
||||
"created_by": {
|
||||
"object": "user",
|
||||
"id": "b69e71d0-c6c2-428c-ae38-5c020343b36b"
|
||||
},
|
||||
"last_edited_by": {
|
||||
"object": "user",
|
||||
"id": "b69e71d0-c6c2-428c-ae38-5c020343b36b"
|
||||
},
|
||||
"has_children": false,
|
||||
"archived": false,
|
||||
"in_trash": false,
|
||||
"type": "paragraph",
|
||||
"paragraph": {
|
||||
"rich_text": [],
|
||||
"color": "default"
|
||||
}
|
||||
},
|
||||
{
|
||||
"object": "block",
|
||||
"id": "1c12c2bb-bca8-809a-a9e5-f33faf274c5f",
|
||||
"parent": {
|
||||
"type": "page_id",
|
||||
"page_id": "1c12c2bb-bca8-8023-ba9a-f8fafab74e48"
|
||||
},
|
||||
"created_time": "2025-03-25T07:56:00.000Z",
|
||||
"last_edited_time": "2025-03-25T08:09:00.000Z",
|
||||
"created_by": {
|
||||
"object": "user",
|
||||
"id": "b69e71d0-c6c2-428c-ae38-5c020343b36b"
|
||||
},
|
||||
"last_edited_by": {
|
||||
"object": "user",
|
||||
"id": "b69e71d0-c6c2-428c-ae38-5c020343b36b"
|
||||
},
|
||||
"has_children": false,
|
||||
"archived": false,
|
||||
"in_trash": false,
|
||||
"type": "code",
|
||||
"code": {
|
||||
"caption": [],
|
||||
"rich_text": [],
|
||||
"language": "javascript"
|
||||
}
|
||||
},
|
||||
{
|
||||
"object": "block",
|
||||
"id": "1c12c2bb-bca8-80c2-986a-ef8ae792a0f9",
|
||||
"parent": {
|
||||
"type": "page_id",
|
||||
"page_id": "1c12c2bb-bca8-8023-ba9a-f8fafab74e48"
|
||||
},
|
||||
"created_time": "2025-03-25T08:04:00.000Z",
|
||||
"last_edited_time": "2025-03-25T08:04:00.000Z",
|
||||
"created_by": {
|
||||
"object": "user",
|
||||
"id": "b69e71d0-c6c2-428c-ae38-5c020343b36b"
|
||||
},
|
||||
"last_edited_by": {
|
||||
"object": "user",
|
||||
"id": "b69e71d0-c6c2-428c-ae38-5c020343b36b"
|
||||
},
|
||||
"has_children": false,
|
||||
"archived": false,
|
||||
"in_trash": false,
|
||||
"type": "paragraph",
|
||||
"paragraph": {
|
||||
"rich_text": [],
|
||||
"color": "default"
|
||||
}
|
||||
},
|
||||
{
|
||||
"object": "block",
|
||||
"id": "1c12c2bb-bca8-80b3-9180-c6bc089c00fc",
|
||||
"parent": {
|
||||
"type": "page_id",
|
||||
"page_id": "1c12c2bb-bca8-8023-ba9a-f8fafab74e48"
|
||||
},
|
||||
"created_time": "2025-03-25T07:56:00.000Z",
|
||||
"last_edited_time": "2025-03-25T08:05:00.000Z",
|
||||
"created_by": {
|
||||
"object": "user",
|
||||
"id": "b69e71d0-c6c2-428c-ae38-5c020343b36b"
|
||||
},
|
||||
"last_edited_by": {
|
||||
"object": "user",
|
||||
"id": "b69e71d0-c6c2-428c-ae38-5c020343b36b"
|
||||
},
|
||||
"has_children": false,
|
||||
"archived": false,
|
||||
"in_trash": false,
|
||||
"type": "paragraph",
|
||||
"paragraph": {
|
||||
"rich_text": [
|
||||
{
|
||||
"type": "equation",
|
||||
"equation": {
|
||||
"expression": "E"
|
||||
},
|
||||
"annotations": {
|
||||
"bold": false,
|
||||
"italic": false,
|
||||
"strikethrough": false,
|
||||
"underline": false,
|
||||
"code": false,
|
||||
"color": "default"
|
||||
},
|
||||
"plain_text": "E",
|
||||
"href": null
|
||||
}
|
||||
],
|
||||
"color": "default"
|
||||
}
|
||||
},
|
||||
{
|
||||
"object": "block",
|
||||
"id": "1c12c2bb-bca8-80ca-974e-c226a1a1ae76",
|
||||
"parent": {
|
||||
"type": "page_id",
|
||||
"page_id": "1c12c2bb-bca8-8023-ba9a-f8fafab74e48"
|
||||
},
|
||||
"created_time": "2025-03-25T08:10:00.000Z",
|
||||
"last_edited_time": "2025-03-25T08:10:00.000Z",
|
||||
"created_by": {
|
||||
"object": "user",
|
||||
"id": "b69e71d0-c6c2-428c-ae38-5c020343b36b"
|
||||
},
|
||||
"last_edited_by": {
|
||||
"object": "user",
|
||||
"id": "b69e71d0-c6c2-428c-ae38-5c020343b36b"
|
||||
},
|
||||
"has_children": false,
|
||||
"archived": false,
|
||||
"in_trash": false,
|
||||
"type": "paragraph",
|
||||
"paragraph": {
|
||||
"rich_text": [],
|
||||
"color": "default"
|
||||
}
|
||||
},
|
||||
{
|
||||
"object": "block",
|
||||
"id": "1c12c2bb-bca8-8028-944b-e2593f20e09a",
|
||||
"parent": {
|
||||
"type": "page_id",
|
||||
"page_id": "1c12c2bb-bca8-8023-ba9a-f8fafab74e48"
|
||||
},
|
||||
"created_time": "2025-03-25T08:09:00.000Z",
|
||||
"last_edited_time": "2025-03-25T08:10:00.000Z",
|
||||
"created_by": {
|
||||
"object": "user",
|
||||
"id": "b69e71d0-c6c2-428c-ae38-5c020343b36b"
|
||||
},
|
||||
"last_edited_by": {
|
||||
"object": "user",
|
||||
"id": "b69e71d0-c6c2-428c-ae38-5c020343b36b"
|
||||
},
|
||||
"has_children": false,
|
||||
"archived": false,
|
||||
"in_trash": false,
|
||||
"type": "paragraph",
|
||||
"paragraph": {
|
||||
"rich_text": [
|
||||
{
|
||||
"type": "mention",
|
||||
"mention": {
|
||||
"type": "link_mention",
|
||||
"link_mention": {
|
||||
"href": "http://github.com/outline/",
|
||||
"title": "Outline",
|
||||
"icon_url": "https://github.com/fluidicon.png",
|
||||
"description": "We're building an open source collaborative knowledge base for modern teams - Outline",
|
||||
"link_provider": "GitHub",
|
||||
"thumbnail_url": "https://avatars.githubusercontent.com/u/1765001?s=280&v=4"
|
||||
}
|
||||
},
|
||||
"annotations": {
|
||||
"bold": false,
|
||||
"italic": false,
|
||||
"strikethrough": false,
|
||||
"underline": false,
|
||||
"code": false,
|
||||
"color": "default"
|
||||
},
|
||||
"plain_text": "http://github.com/outline/",
|
||||
"href": "http://github.com/outline/"
|
||||
}
|
||||
],
|
||||
"color": "default"
|
||||
}
|
||||
},
|
||||
{
|
||||
"object": "block",
|
||||
"id": "1c12c2bb-bca8-80f8-a272-c519ce56de27",
|
||||
"parent": {
|
||||
"type": "page_id",
|
||||
"page_id": "1c12c2bb-bca8-8023-ba9a-f8fafab74e48"
|
||||
},
|
||||
"created_time": "2025-03-25T08:12:00.000Z",
|
||||
"last_edited_time": "2025-03-25T08:12:00.000Z",
|
||||
"created_by": {
|
||||
"object": "user",
|
||||
"id": "b69e71d0-c6c2-428c-ae38-5c020343b36b"
|
||||
},
|
||||
"last_edited_by": {
|
||||
"object": "user",
|
||||
"id": "b69e71d0-c6c2-428c-ae38-5c020343b36b"
|
||||
},
|
||||
"has_children": false,
|
||||
"archived": false,
|
||||
"in_trash": false,
|
||||
"type": "paragraph",
|
||||
"paragraph": {
|
||||
"rich_text": [],
|
||||
"color": "default"
|
||||
}
|
||||
},
|
||||
{
|
||||
"object": "block",
|
||||
"id": "1c12c2bb-bca8-8096-8a94-e44b32127e73",
|
||||
"parent": {
|
||||
"type": "page_id",
|
||||
"page_id": "1c12c2bb-bca8-8023-ba9a-f8fafab74e48"
|
||||
},
|
||||
"created_time": "2025-03-25T08:12:00.000Z",
|
||||
"last_edited_time": "2025-03-25T08:12:00.000Z",
|
||||
"created_by": {
|
||||
"object": "user",
|
||||
"id": "b69e71d0-c6c2-428c-ae38-5c020343b36b"
|
||||
},
|
||||
"last_edited_by": {
|
||||
"object": "user",
|
||||
"id": "b69e71d0-c6c2-428c-ae38-5c020343b36b"
|
||||
},
|
||||
"has_children": false,
|
||||
"archived": false,
|
||||
"in_trash": false,
|
||||
"type": "paragraph",
|
||||
"paragraph": {
|
||||
"rich_text": [
|
||||
{
|
||||
"type": "mention",
|
||||
"mention": {
|
||||
"type": "link_preview",
|
||||
"link_preview": {
|
||||
"url": "https://github.com/outline/outline"
|
||||
}
|
||||
},
|
||||
"annotations": {
|
||||
"bold": false,
|
||||
"italic": false,
|
||||
"strikethrough": false,
|
||||
"underline": false,
|
||||
"code": false,
|
||||
"color": "default"
|
||||
},
|
||||
"plain_text": "https://github.com/outline/outline",
|
||||
"href": "https://github.com/outline/outline"
|
||||
}
|
||||
],
|
||||
"color": "default"
|
||||
}
|
||||
},
|
||||
{
|
||||
"object": "block",
|
||||
"id": "1c12c2bb-bca8-807b-98de-ff2c1d0080f2",
|
||||
"parent": {
|
||||
"type": "page_id",
|
||||
"page_id": "1c12c2bb-bca8-8023-ba9a-f8fafab74e48"
|
||||
},
|
||||
"created_time": "2025-03-25T08:13:00.000Z",
|
||||
"last_edited_time": "2025-03-25T08:13:00.000Z",
|
||||
"created_by": {
|
||||
"object": "user",
|
||||
"id": "b69e71d0-c6c2-428c-ae38-5c020343b36b"
|
||||
},
|
||||
"last_edited_by": {
|
||||
"object": "user",
|
||||
"id": "b69e71d0-c6c2-428c-ae38-5c020343b36b"
|
||||
},
|
||||
"has_children": false,
|
||||
"archived": false,
|
||||
"in_trash": false,
|
||||
"type": "paragraph",
|
||||
"paragraph": {
|
||||
"rich_text": [],
|
||||
"color": "default"
|
||||
}
|
||||
},
|
||||
{
|
||||
"object": "block",
|
||||
"id": "1c12c2bb-bca8-80dd-b816-cb4dd04d4f46",
|
||||
"parent": {
|
||||
"type": "page_id",
|
||||
"page_id": "1c12c2bb-bca8-8023-ba9a-f8fafab74e48"
|
||||
},
|
||||
"created_time": "2025-03-25T08:13:00.000Z",
|
||||
"last_edited_time": "2025-03-25T08:13:00.000Z",
|
||||
"created_by": {
|
||||
"object": "user",
|
||||
"id": "b69e71d0-c6c2-428c-ae38-5c020343b36b"
|
||||
},
|
||||
"last_edited_by": {
|
||||
"object": "user",
|
||||
"id": "b69e71d0-c6c2-428c-ae38-5c020343b36b"
|
||||
},
|
||||
"has_children": false,
|
||||
"archived": false,
|
||||
"in_trash": false,
|
||||
"type": "equation",
|
||||
"equation": {
|
||||
"expression": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"object": "block",
|
||||
"id": "1c12c2bb-bca8-8074-a5e0-f0ba30bd0510",
|
||||
"parent": {
|
||||
"type": "page_id",
|
||||
"page_id": "1c12c2bb-bca8-8023-ba9a-f8fafab74e48"
|
||||
},
|
||||
"created_time": "2025-03-25T08:18:00.000Z",
|
||||
"last_edited_time": "2025-03-25T08:18:00.000Z",
|
||||
"created_by": {
|
||||
"object": "user",
|
||||
"id": "b69e71d0-c6c2-428c-ae38-5c020343b36b"
|
||||
},
|
||||
"last_edited_by": {
|
||||
"object": "user",
|
||||
"id": "b69e71d0-c6c2-428c-ae38-5c020343b36b"
|
||||
},
|
||||
"has_children": false,
|
||||
"archived": false,
|
||||
"in_trash": false,
|
||||
"type": "paragraph",
|
||||
"paragraph": {
|
||||
"rich_text": [],
|
||||
"color": "default"
|
||||
}
|
||||
},
|
||||
{
|
||||
"object": "block",
|
||||
"id": "1c12c2bb-bca8-80dc-8a14-db8b714450f6",
|
||||
"parent": {
|
||||
"type": "page_id",
|
||||
"page_id": "1c12c2bb-bca8-8023-ba9a-f8fafab74e48"
|
||||
},
|
||||
"created_time": "2025-03-25T08:18:00.000Z",
|
||||
"last_edited_time": "2025-03-25T08:30:00.000Z",
|
||||
"created_by": {
|
||||
"object": "user",
|
||||
"id": "b69e71d0-c6c2-428c-ae38-5c020343b36b"
|
||||
},
|
||||
"last_edited_by": {
|
||||
"object": "user",
|
||||
"id": "b69e71d0-c6c2-428c-ae38-5c020343b36b"
|
||||
},
|
||||
"has_children": false,
|
||||
"archived": false,
|
||||
"in_trash": false,
|
||||
"type": "bookmark",
|
||||
"bookmark": {
|
||||
"caption": [],
|
||||
"url": "https://google.com"
|
||||
}
|
||||
},
|
||||
{
|
||||
"object": "block",
|
||||
"id": "1c12c2bb-bca8-80f2-94b4-df422d9cd18e",
|
||||
"parent": {
|
||||
"type": "page_id",
|
||||
"page_id": "1c12c2bb-bca8-8023-ba9a-f8fafab74e48"
|
||||
},
|
||||
"created_time": "2025-03-25T08:24:00.000Z",
|
||||
"last_edited_time": "2025-03-25T08:24:00.000Z",
|
||||
"created_by": {
|
||||
"object": "user",
|
||||
"id": "b69e71d0-c6c2-428c-ae38-5c020343b36b"
|
||||
},
|
||||
"last_edited_by": {
|
||||
"object": "user",
|
||||
"id": "b69e71d0-c6c2-428c-ae38-5c020343b36b"
|
||||
},
|
||||
"has_children": false,
|
||||
"archived": false,
|
||||
"in_trash": false,
|
||||
"type": "paragraph",
|
||||
"paragraph": {
|
||||
"rich_text": [],
|
||||
"color": "default"
|
||||
}
|
||||
},
|
||||
{
|
||||
"object": "block",
|
||||
"id": "1c12c2bb-bca8-8046-946c-d89c2bc7827c",
|
||||
"parent": {
|
||||
"type": "page_id",
|
||||
"page_id": "1c12c2bb-bca8-8023-ba9a-f8fafab74e48"
|
||||
},
|
||||
"created_time": "2025-03-25T08:24:00.000Z",
|
||||
"last_edited_time": "2025-03-25T08:24:00.000Z",
|
||||
"created_by": {
|
||||
"object": "user",
|
||||
"id": "b69e71d0-c6c2-428c-ae38-5c020343b36b"
|
||||
},
|
||||
"last_edited_by": {
|
||||
"object": "user",
|
||||
"id": "1b32c2bb-bca8-816a-99c0-0027ab5c4cb0"
|
||||
},
|
||||
"has_children": false,
|
||||
"archived": false,
|
||||
"in_trash": false,
|
||||
"type": "link_preview",
|
||||
"link_preview": {
|
||||
"url": "https://github.com/outline/outline"
|
||||
}
|
||||
},
|
||||
{
|
||||
"object": "block",
|
||||
"id": "1c12c2bb-bca8-80e6-80d3-f9501fc1a6fb",
|
||||
"parent": {
|
||||
"type": "page_id",
|
||||
"page_id": "1c12c2bb-bca8-8023-ba9a-f8fafab74e48"
|
||||
},
|
||||
"created_time": "2025-03-25T08:24:00.000Z",
|
||||
"last_edited_time": "2025-03-25T08:24:00.000Z",
|
||||
"created_by": {
|
||||
"object": "user",
|
||||
"id": "b69e71d0-c6c2-428c-ae38-5c020343b36b"
|
||||
},
|
||||
"last_edited_by": {
|
||||
"object": "user",
|
||||
"id": "b69e71d0-c6c2-428c-ae38-5c020343b36b"
|
||||
},
|
||||
"has_children": false,
|
||||
"archived": false,
|
||||
"in_trash": false,
|
||||
"type": "paragraph",
|
||||
"paragraph": {
|
||||
"rich_text": [],
|
||||
"color": "default"
|
||||
}
|
||||
},
|
||||
{
|
||||
"object": "block",
|
||||
"id": "1c12c2bb-bca8-8070-9c41-ca4c3aa1a419",
|
||||
"parent": {
|
||||
"type": "page_id",
|
||||
"page_id": "1c12c2bb-bca8-8023-ba9a-f8fafab74e48"
|
||||
},
|
||||
"created_time": "2025-03-25T08:24:00.000Z",
|
||||
"last_edited_time": "2025-03-25T08:30:00.000Z",
|
||||
"created_by": {
|
||||
"object": "user",
|
||||
"id": "b69e71d0-c6c2-428c-ae38-5c020343b36b"
|
||||
},
|
||||
"last_edited_by": {
|
||||
"object": "user",
|
||||
"id": "b69e71d0-c6c2-428c-ae38-5c020343b36b"
|
||||
},
|
||||
"has_children": false,
|
||||
"archived": false,
|
||||
"in_trash": false,
|
||||
"type": "pdf",
|
||||
"pdf": {
|
||||
"caption": [],
|
||||
"type": "file",
|
||||
"file": {
|
||||
"url": "https://prod-files-secure.s3.us-west-2.amazonaws.com/2f3fcad6-fc32-434b-b6b2-a03ca7893c4d/49bfa851-95c1-458b-abb0-88ed591f7712/Empty_pdf.pdf",
|
||||
"expiry_time": "2025-03-25T09:32:41.690Z"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"object": "block",
|
||||
"id": "1c12c2bb-bca8-800c-8857-eca03207279f",
|
||||
"parent": {
|
||||
"type": "page_id",
|
||||
"page_id": "1c12c2bb-bca8-8023-ba9a-f8fafab74e48"
|
||||
},
|
||||
"created_time": "2025-03-25T08:26:00.000Z",
|
||||
"last_edited_time": "2025-03-25T08:26:00.000Z",
|
||||
"created_by": {
|
||||
"object": "user",
|
||||
"id": "b69e71d0-c6c2-428c-ae38-5c020343b36b"
|
||||
},
|
||||
"last_edited_by": {
|
||||
"object": "user",
|
||||
"id": "b69e71d0-c6c2-428c-ae38-5c020343b36b"
|
||||
},
|
||||
"has_children": false,
|
||||
"archived": false,
|
||||
"in_trash": false,
|
||||
"type": "paragraph",
|
||||
"paragraph": {
|
||||
"rich_text": [],
|
||||
"color": "default"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -54,7 +54,9 @@ export class CacheHelper {
|
||||
}
|
||||
return value;
|
||||
} finally {
|
||||
await lock?.release();
|
||||
if (lock && lock.expiration > new Date().getTime()) {
|
||||
await lock.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { generateAvatarUrl } from "./avatars";
|
||||
|
||||
it("should return clearbit url if available", async () => {
|
||||
const url = await generateAvatarUrl({
|
||||
id: "google",
|
||||
domain: "google.com",
|
||||
});
|
||||
expect(url).toBe("https://logo.clearbit.com/google.com");
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
import crypto from "crypto";
|
||||
import fetch from "./fetch";
|
||||
|
||||
export async function generateAvatarUrl({
|
||||
id,
|
||||
domain,
|
||||
}: {
|
||||
id: string;
|
||||
domain?: string;
|
||||
}) {
|
||||
// attempt to get logo from Clearbit API. If one doesn't exist then
|
||||
// fall back to using tiley to generate a placeholder logo
|
||||
const hash = crypto.createHash("sha256");
|
||||
hash.update(id);
|
||||
let cbResponse, cbUrl;
|
||||
|
||||
if (domain) {
|
||||
cbUrl = `https://logo.clearbit.com/${domain}`;
|
||||
|
||||
try {
|
||||
cbResponse = await fetch(cbUrl);
|
||||
} catch (err) {
|
||||
// okay
|
||||
}
|
||||
}
|
||||
|
||||
return cbUrl && cbResponse && cbResponse.status === 200 ? cbUrl : null;
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
import crypto from "crypto";
|
||||
import { addMinutes, subMinutes } from "date-fns";
|
||||
import type { Context } from "koa";
|
||||
// Allowed for trusted server<->server connections
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import fetch from "node-fetch";
|
||||
import {
|
||||
StateStoreStoreCallback,
|
||||
StateStoreVerifyCallback,
|
||||
@@ -13,6 +10,7 @@ import { getCookieDomain, parseDomain } from "@shared/utils/domains";
|
||||
import env from "@server/env";
|
||||
import { Team } from "@server/models";
|
||||
import { InternalError, OAuthStateMismatchError } from "../errors";
|
||||
import fetch from "./fetch";
|
||||
|
||||
export class StateStore {
|
||||
key = "state";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import fractionalIndex from "fractional-index";
|
||||
import { Op, Sequelize, type FindOptions } from "sequelize";
|
||||
import { Sequelize, type FindOptions } from "sequelize";
|
||||
import Collection from "@server/models/Collection";
|
||||
|
||||
/**
|
||||
@@ -31,9 +31,7 @@ export default async function removeIndexCollision(
|
||||
where: {
|
||||
teamId,
|
||||
deletedAt: null,
|
||||
index: {
|
||||
[Op.gt]: index,
|
||||
},
|
||||
index: Sequelize.literal(`"collection"."index" collate "C" > '${index}'`),
|
||||
},
|
||||
attributes: ["id", "index"],
|
||||
limit: 1,
|
||||
|
||||
@@ -29,3 +29,31 @@ export function CannotUseWithout(
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function CannotUseWith(
|
||||
property: string,
|
||||
validationOptions?: ValidationOptions
|
||||
) {
|
||||
return function (object: Object, propertyName: string) {
|
||||
registerDecorator({
|
||||
name: "cannotUseWith",
|
||||
target: object.constructor,
|
||||
propertyName,
|
||||
constraints: [property],
|
||||
options: validationOptions,
|
||||
validator: {
|
||||
validate<T>(value: T, args: ValidationArguments) {
|
||||
if (value === undefined) {
|
||||
return true;
|
||||
}
|
||||
const obj = args.object as unknown as T;
|
||||
const forbidden = args.constraints[0] as keyof T;
|
||||
return obj[forbidden] === undefined;
|
||||
},
|
||||
defaultMessage(args: ValidationArguments) {
|
||||
return `${propertyName} cannot be used with ${args.constraints[0]}.`;
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -232,7 +232,7 @@ export class ValidateDocumentId {
|
||||
export class ValidateIndex {
|
||||
public static regex = new RegExp("^[\x20-\x7E]+$");
|
||||
public static message = "Must be between x20 to x7E ASCII";
|
||||
public static maxLength = 100;
|
||||
public static maxLength = 256;
|
||||
}
|
||||
|
||||
export class ValidateURL {
|
||||
|
||||
@@ -10,6 +10,10 @@ type Props = {
|
||||
captureEvents?: "all" | "pointer" | "click";
|
||||
};
|
||||
|
||||
/**
|
||||
* EventBoundary is a component that prevents events from propagating to parent elements.
|
||||
* This is useful for preventing clicks or other interactions from bubbling up the DOM tree.
|
||||
*/
|
||||
const EventBoundary: React.FC<Props> = ({
|
||||
children,
|
||||
className,
|
||||
|
||||
@@ -5,14 +5,26 @@ type JustifyValues = CSSProperties["justifyContent"];
|
||||
|
||||
type AlignValues = CSSProperties["alignItems"];
|
||||
|
||||
/**
|
||||
* Flex is a styled component that provides a flexible box layout with convenient props.
|
||||
* It simplifies the use of flexbox CSS properties with a clean, declarative API.
|
||||
*/
|
||||
const Flex = styled.div<{
|
||||
/** Makes the component grow to fill available space */
|
||||
auto?: boolean;
|
||||
/** Changes flex direction to column */
|
||||
column?: boolean;
|
||||
/** Sets the align-items CSS property */
|
||||
align?: AlignValues;
|
||||
/** Sets the justify-content CSS property */
|
||||
justify?: JustifyValues;
|
||||
/** Enables flex-wrap */
|
||||
wrap?: boolean;
|
||||
/** Controls flex-shrink behavior */
|
||||
shrink?: boolean;
|
||||
/** Reverses the direction (row-reverse or column-reverse) */
|
||||
reverse?: boolean;
|
||||
/** Sets gap between flex items in pixels */
|
||||
gap?: number;
|
||||
}>`
|
||||
display: flex;
|
||||
|
||||
@@ -11,6 +11,11 @@ type Props = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Squircle is a component that renders a square with rounded corners (squircle shape).
|
||||
* It's commonly used for app icons, avatars, and other UI elements where a softer
|
||||
* square shape is desired.
|
||||
*/
|
||||
const Squircle: React.FC<Props> = ({
|
||||
color,
|
||||
size = 28,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NodeType } from "prosemirror-model";
|
||||
import { wrapInList, liftListItem } from "prosemirror-schema-list";
|
||||
import { liftListItem, wrapInList } from "prosemirror-schema-list";
|
||||
import { Command } from "prosemirror-state";
|
||||
import { chainTransactions } from "../lib/chainTransactions";
|
||||
import { findParentNode } from "../queries/findParentNode";
|
||||
@@ -29,6 +29,14 @@ export default function toggleList(
|
||||
return liftListItem(itemType)(state, dispatch);
|
||||
}
|
||||
|
||||
const currentItemType = parentList.node.content.firstChild?.type;
|
||||
if (currentItemType && currentItemType !== itemType) {
|
||||
return chainTransactions(clearNodes(), wrapInList(listType))(
|
||||
state,
|
||||
dispatch
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
isList(parentList.node, schema) &&
|
||||
listType.validContent(parentList.node.content)
|
||||
|
||||
@@ -313,6 +313,10 @@ width: 100%;
|
||||
background: ${props.theme.mentionHoverBackground};
|
||||
}
|
||||
|
||||
&[data-type="user"] {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
&.mention-user::before {
|
||||
content: "@";
|
||||
}
|
||||
|
||||
@@ -4,62 +4,10 @@ import { Node } from "prosemirror-model";
|
||||
import { Plugin, PluginKey, Transaction } from "prosemirror-state";
|
||||
import { Decoration, DecorationSet } from "prosemirror-view";
|
||||
import refractor from "refractor/core";
|
||||
import { getPrismLangForLanguage } from "../lib/code";
|
||||
import { isRemoteTransaction } from "../lib/multiplayer";
|
||||
import { findBlockNodes } from "../queries/findChildren";
|
||||
|
||||
export const LANGUAGES = {
|
||||
none: "Plain text", // additional entry to disable highlighting
|
||||
bash: "Bash",
|
||||
clike: "C",
|
||||
cpp: "C++",
|
||||
csharp: "C#",
|
||||
css: "CSS",
|
||||
docker: "Docker",
|
||||
elixir: "Elixir",
|
||||
erlang: "Erlang",
|
||||
go: "Go",
|
||||
graphql: "GraphQL",
|
||||
groovy: "Groovy",
|
||||
haskell: "Haskell",
|
||||
hcl: "HCL",
|
||||
markup: "HTML",
|
||||
ini: "INI",
|
||||
java: "Java",
|
||||
javascript: "JavaScript",
|
||||
json: "JSON",
|
||||
jsx: "JSX",
|
||||
kotlin: "Kotlin",
|
||||
lisp: "Lisp",
|
||||
lua: "Lua",
|
||||
mermaidjs: "Mermaid Diagram",
|
||||
nginx: "Nginx",
|
||||
nix: "Nix",
|
||||
objectivec: "Objective-C",
|
||||
ocaml: "OCaml",
|
||||
perl: "Perl",
|
||||
php: "PHP",
|
||||
powershell: "Powershell",
|
||||
protobuf: "Protobuf",
|
||||
python: "Python",
|
||||
r: "R",
|
||||
ruby: "Ruby",
|
||||
rust: "Rust",
|
||||
scala: "Scala",
|
||||
sass: "Sass",
|
||||
scss: "SCSS",
|
||||
sql: "SQL",
|
||||
solidity: "Solidity",
|
||||
swift: "Swift",
|
||||
toml: "TOML",
|
||||
tsx: "TSX",
|
||||
typescript: "TypeScript",
|
||||
vb: "Visual Basic",
|
||||
verilog: "Verilog",
|
||||
vhdl: "VHDL",
|
||||
yaml: "YAML",
|
||||
zig: "Zig",
|
||||
};
|
||||
|
||||
type ParsedNode = {
|
||||
text: string;
|
||||
classes: string[];
|
||||
@@ -109,12 +57,9 @@ function getDecorations({
|
||||
|
||||
blocks.forEach((block) => {
|
||||
let startPos = block.pos + 1;
|
||||
const language = (
|
||||
block.node.attrs.language === "mermaidjs"
|
||||
? "mermaid"
|
||||
: block.node.attrs.language
|
||||
) as string;
|
||||
if (!language || language === "none" || !refractor.registered(language)) {
|
||||
const language = getPrismLangForLanguage(block.node.attrs.language);
|
||||
|
||||
if (!language || !refractor.registered(language)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { getPrismLangForLanguage, getLabelForLanguage } from "./code";
|
||||
|
||||
describe("getPrismLangForLanguage", () => {
|
||||
it("should return the correct Prism language identifier for a given language", () => {
|
||||
expect(getPrismLangForLanguage("javascript")).toBe("javascript");
|
||||
expect(getPrismLangForLanguage("mermaidjs")).toBe("mermaid");
|
||||
expect(getPrismLangForLanguage("xml")).toBe("markup");
|
||||
expect(getPrismLangForLanguage("unknown")).toBeUndefined();
|
||||
expect(getPrismLangForLanguage("")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLabelForLanguage", () => {
|
||||
it("should return the correct human-readable label for a given language", () => {
|
||||
expect(getLabelForLanguage("javascript")).toBe("JavaScript");
|
||||
expect(getLabelForLanguage("mermaidjs")).toBe("Mermaid Diagram");
|
||||
expect(getLabelForLanguage("xml")).toBe("XML");
|
||||
expect(getLabelForLanguage("unknown")).toBe("Plain text");
|
||||
expect(getLabelForLanguage("none")).toBe("Plain text");
|
||||
expect(getLabelForLanguage("")).toBe("Plain text");
|
||||
});
|
||||
});
|
||||
+105
-11
@@ -1,14 +1,97 @@
|
||||
import Storage from "../../utils/Storage";
|
||||
import { LANGUAGES } from "../extensions/Prism";
|
||||
|
||||
const RecentStorageKey = "rme-code-language";
|
||||
const StorageKey = "frequent-code-languages";
|
||||
const frequentLanguagesToGet = 5;
|
||||
const frequentLanguagesToTrack = 10;
|
||||
|
||||
export const FrequentlyUsedCount = {
|
||||
Get: 5,
|
||||
Track: 10,
|
||||
/**
|
||||
* List of supported code languages.
|
||||
*
|
||||
* Object key is the language identifier used in the editor, lang is the
|
||||
* language identifier used by Prism. Note mismatches such as `markup` and
|
||||
* `mermaid`.
|
||||
*/
|
||||
export const codeLanguages = {
|
||||
none: { lang: "", label: "Plain text" },
|
||||
bash: { lang: "bash", label: "Bash" },
|
||||
clike: { lang: "clike", label: "C" },
|
||||
cpp: { lang: "cpp", label: "C++" },
|
||||
csharp: { lang: "csharp", label: "C#" },
|
||||
css: { lang: "css", label: "CSS" },
|
||||
docker: { lang: "docker", label: "Docker" },
|
||||
elixir: { lang: "elixir", label: "Elixir" },
|
||||
erlang: { lang: "erlang", label: "Erlang" },
|
||||
go: { lang: "go", label: "Go" },
|
||||
graphql: { lang: "graphql", label: "GraphQL" },
|
||||
groovy: { lang: "groovy", label: "Groovy" },
|
||||
haskell: { lang: "haskell", label: "Haskell" },
|
||||
hcl: { lang: "hcl", label: "HCL" },
|
||||
markup: { lang: "markup", label: "HTML" },
|
||||
ini: { lang: "ini", label: "INI" },
|
||||
java: { lang: "java", label: "Java" },
|
||||
javascript: { lang: "javascript", label: "JavaScript" },
|
||||
json: { lang: "json", label: "JSON" },
|
||||
jsx: { lang: "jsx", label: "JSX" },
|
||||
kotlin: { lang: "kotlin", label: "Kotlin" },
|
||||
lisp: { lang: "lisp", label: "Lisp" },
|
||||
lua: { lang: "lua", label: "Lua" },
|
||||
mermaidjs: { lang: "mermaid", label: "Mermaid Diagram" },
|
||||
nginx: { lang: "nginx", label: "Nginx" },
|
||||
nix: { lang: "nix", label: "Nix" },
|
||||
objectivec: { lang: "objectivec", label: "Objective-C" },
|
||||
ocaml: { lang: "ocaml", label: "OCaml" },
|
||||
perl: { lang: "perl", label: "Perl" },
|
||||
php: { lang: "php", label: "PHP" },
|
||||
powershell: { lang: "powershell", label: "Powershell" },
|
||||
protobuf: { lang: "protobuf", label: "Protobuf" },
|
||||
python: { lang: "python", label: "Python" },
|
||||
r: { lang: "r", label: "R" },
|
||||
ruby: { lang: "ruby", label: "Ruby" },
|
||||
rust: { lang: "rust", label: "Rust" },
|
||||
scala: { lang: "scala", label: "Scala" },
|
||||
sass: { lang: "sass", label: "Sass" },
|
||||
scss: { lang: "scss", label: "SCSS" },
|
||||
sql: { lang: "sql", label: "SQL" },
|
||||
solidity: { lang: "solidity", label: "Solidity" },
|
||||
swift: { lang: "swift", label: "Swift" },
|
||||
toml: { lang: "toml", label: "TOML" },
|
||||
tsx: { lang: "tsx", label: "TSX" },
|
||||
typescript: { lang: "typescript", label: "TypeScript" },
|
||||
vb: { lang: "vb", label: "Visual Basic" },
|
||||
verilog: { lang: "verilog", label: "Verilog" },
|
||||
vhdl: { lang: "vhdl", label: "VHDL" },
|
||||
yaml: { lang: "yaml", label: "YAML" },
|
||||
xml: { lang: "markup", label: "XML" },
|
||||
zig: { lang: "zig", label: "Zig" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the human-readable label for a given language.
|
||||
*
|
||||
* @param language The language identifier.
|
||||
* @returns The human-readable label for the language.
|
||||
*/
|
||||
export const getLabelForLanguage = (language: string) => {
|
||||
const lang =
|
||||
codeLanguages[language as keyof typeof codeLanguages] ?? codeLanguages.none;
|
||||
return lang.label;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the Prism language identifier for a given language.
|
||||
*
|
||||
* @param language The language identifier.
|
||||
* @returns The Prism language identifier for the language.
|
||||
*/
|
||||
export const getPrismLangForLanguage = (language: string): string | undefined =>
|
||||
codeLanguages[language as keyof typeof codeLanguages]?.lang;
|
||||
|
||||
/**
|
||||
* Set the most recent code language used.
|
||||
*
|
||||
* @param language The language identifier.
|
||||
*/
|
||||
export const setRecentCodeLanguage = (language: string) => {
|
||||
const frequentLangs = (Storage.get(StorageKey) ?? {}) as Record<
|
||||
string,
|
||||
@@ -26,14 +109,14 @@ export const setRecentCodeLanguage = (language: string) => {
|
||||
|
||||
const frequentLangEntries = Object.entries(frequentLangs);
|
||||
|
||||
if (frequentLangEntries.length > FrequentlyUsedCount.Track) {
|
||||
if (frequentLangEntries.length > frequentLanguagesToTrack) {
|
||||
sortFrequencies(frequentLangEntries);
|
||||
|
||||
const lastEntry = frequentLangEntries[FrequentlyUsedCount.Track];
|
||||
const lastEntry = frequentLangEntries[frequentLanguagesToTrack];
|
||||
if (lastEntry[0] === language) {
|
||||
frequentLangEntries.splice(FrequentlyUsedCount.Track - 1, 1);
|
||||
frequentLangEntries.splice(frequentLanguagesToTrack - 1, 1);
|
||||
} else {
|
||||
frequentLangEntries.splice(FrequentlyUsedCount.Track);
|
||||
frequentLangEntries.splice(frequentLanguagesToTrack);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,17 +124,28 @@ export const setRecentCodeLanguage = (language: string) => {
|
||||
Storage.set(RecentStorageKey, language);
|
||||
};
|
||||
|
||||
export const getRecentCodeLanguage = () => Storage.get(RecentStorageKey);
|
||||
/**
|
||||
* Get the most recent code language used.
|
||||
*
|
||||
* @returns The most recent code language used, or undefined if none is set.
|
||||
*/
|
||||
export const getRecentCodeLanguage = () =>
|
||||
Storage.get(RecentStorageKey) as keyof typeof codeLanguages | undefined;
|
||||
|
||||
/**
|
||||
* Get the most frequent code languages used.
|
||||
*
|
||||
* @returns An array of the most frequent code languages used.
|
||||
*/
|
||||
export const getFrequentCodeLanguages = () => {
|
||||
const recentLang = Storage.get(RecentStorageKey);
|
||||
const frequentLangEntries = Object.entries(Storage.get(StorageKey) ?? {}) as [
|
||||
keyof typeof LANGUAGES,
|
||||
keyof typeof codeLanguages,
|
||||
number
|
||||
][];
|
||||
|
||||
const frequentLangs = sortFrequencies(frequentLangEntries)
|
||||
.slice(0, FrequentlyUsedCount.Get)
|
||||
.slice(0, frequentLanguagesToGet)
|
||||
.map(([lang]) => lang);
|
||||
|
||||
const isRecentLangPresent = frequentLangs.includes(recentLang);
|
||||
|
||||
@@ -28,9 +28,10 @@ export function getCellAttrs(dom: HTMLElement | string): Attrs {
|
||||
const widthAttr = dom.getAttribute("data-colwidth");
|
||||
const widths =
|
||||
widthAttr && /^\d+(,\d+)*$/.test(widthAttr)
|
||||
? widthAttr.split(",").map((s) => Number(s))
|
||||
? widthAttr.split(",").map(Number)
|
||||
: null;
|
||||
const colspan = Number(dom.getAttribute("colspan") || 1);
|
||||
|
||||
return {
|
||||
colspan,
|
||||
rowspan: Number(dom.getAttribute("rowspan") || 1),
|
||||
@@ -63,10 +64,11 @@ export function setCellAttrs(node: Node): Attrs {
|
||||
}
|
||||
if (node.attrs.colwidth) {
|
||||
if (isBrowser) {
|
||||
attrs["data-colwidth"] = node.attrs.colwidth.join(",");
|
||||
attrs["data-colwidth"] = node.attrs.colwidth.map(parseInt).join(",");
|
||||
} else {
|
||||
attrs.style =
|
||||
(attrs.style ?? "") + `min-width: ${node.attrs.colwidth}px;`;
|
||||
(attrs.style ?? "") +
|
||||
`min-width: ${parseInt(node.attrs.colwidth[0])}px;`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,9 +21,10 @@ export default class Code extends Mark {
|
||||
|
||||
get schema(): MarkSpec {
|
||||
return {
|
||||
excludes: "mention placeholder highlight em strong",
|
||||
excludes: "mention placeholder highlight",
|
||||
parseDOM: [{ tag: "code", preserveWhitespace: true }],
|
||||
toDOM: () => ["code", { class: "inline", spellCheck: "false" }],
|
||||
code: true,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,11 @@ export default class Mention extends Node {
|
||||
}
|
||||
|
||||
get schema(): NodeSpec {
|
||||
const toPlainText = (node: ProsemirrorNode) =>
|
||||
node.attrs.type === MentionType.User
|
||||
? `@${node.attrs.label}`
|
||||
: node.attrs.label;
|
||||
|
||||
return {
|
||||
attrs: {
|
||||
type: {
|
||||
@@ -88,12 +93,9 @@ export default class Mention extends Node {
|
||||
"data-actorid": node.attrs.actorId,
|
||||
"data-url": `mention://${node.attrs.id}/${node.attrs.type}/${node.attrs.modelId}`,
|
||||
},
|
||||
String(node.attrs.label),
|
||||
toPlainText(node),
|
||||
],
|
||||
toPlainText: (node) =>
|
||||
node.attrs.type === MentionType.User
|
||||
? `@${node.attrs.label}`
|
||||
: node.attrs.label,
|
||||
toPlainText,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ type Options = {
|
||||
onlyBlock?: boolean;
|
||||
/** Only check if the selection is inside a code mark. */
|
||||
onlyMark?: boolean;
|
||||
/** If true then code must contain entire selection */
|
||||
inclusive?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -20,17 +22,29 @@ export function isInCode(state: EditorState, options?: Options): boolean {
|
||||
const { nodes, marks } = state.schema;
|
||||
|
||||
if (!options?.onlyMark) {
|
||||
if (nodes.code_block && isNodeActive(nodes.code_block)(state)) {
|
||||
if (
|
||||
nodes.code_block &&
|
||||
isNodeActive(nodes.code_block, undefined, {
|
||||
inclusive: options?.inclusive,
|
||||
})(state)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (nodes.code_fence && isNodeActive(nodes.code_fence)(state)) {
|
||||
if (
|
||||
nodes.code_fence &&
|
||||
isNodeActive(nodes.code_fence, undefined, {
|
||||
inclusive: options?.inclusive,
|
||||
})(state)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!options?.onlyBlock) {
|
||||
if (marks.code_inline) {
|
||||
return isMarkActive(marks.code_inline)(state);
|
||||
return isMarkActive(marks.code_inline, undefined, {
|
||||
inclusive: options?.inclusive,
|
||||
})(state);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import { getMarksBetween } from "./getMarksBetween";
|
||||
type Options = {
|
||||
/** Only return match if the range and attrs is exact */
|
||||
exact?: boolean;
|
||||
/** If true then mark must contain entire selection */
|
||||
inclusive?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -40,7 +42,8 @@ export const isMarkActive =
|
||||
Object.keys(attrs).every(
|
||||
(key) => mark.attrs[key] === attrs[key]
|
||||
)) &&
|
||||
(!options?.exact || (start === from && end === to))
|
||||
(!options?.exact || (start === from && end === to)) &&
|
||||
(!options?.inclusive || (start <= from && end >= to))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,31 +3,55 @@ import { EditorState } from "prosemirror-state";
|
||||
import { Primitive } from "utility-types";
|
||||
import { findParentNode } from "./findParentNode";
|
||||
|
||||
type Options = {
|
||||
/** Only return match if the range and attrs is exact */
|
||||
exact?: boolean;
|
||||
/** If true then node must contain entire selection */
|
||||
inclusive?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a node is active in the current selection or not.
|
||||
*
|
||||
* @param type The node type to check.
|
||||
* @param attrs The attributes to check.
|
||||
* @param options The options to use.
|
||||
* @returns A function that checks if a node is active in the current selection or not.
|
||||
*/
|
||||
export const isNodeActive =
|
||||
(type: NodeType, attrs: Record<string, Primitive> = {}) =>
|
||||
(state: EditorState) => {
|
||||
(type: NodeType, attrs?: Record<string, Primitive>, options?: Options) =>
|
||||
(state: EditorState): boolean => {
|
||||
if (!type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nodeAfter = state.selection.$from.nodeAfter;
|
||||
let node = nodeAfter?.type === type ? nodeAfter : undefined;
|
||||
const { from, to } = state.selection;
|
||||
const nodeWithPos = findParentNode(
|
||||
(node) =>
|
||||
node.type === type &&
|
||||
(!attrs ||
|
||||
Object.keys(attrs).every((key) => node.attrs[key] === attrs[key]))
|
||||
)(state.selection);
|
||||
|
||||
if (!node) {
|
||||
const parent = findParentNode((n) => n.type === type)(state.selection);
|
||||
node = parent?.node;
|
||||
if (!nodeWithPos) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Object.keys(attrs).length || !node) {
|
||||
return !!node;
|
||||
if (options?.inclusive) {
|
||||
// Check if the node's position contains the entire selection
|
||||
return (
|
||||
nodeWithPos.pos <= from &&
|
||||
nodeWithPos.pos + nodeWithPos.node.nodeSize >= to
|
||||
);
|
||||
}
|
||||
|
||||
return node.hasMarkup(type, { ...node.attrs, ...attrs });
|
||||
if (options?.exact) {
|
||||
// Check if node's range exactly matches selection
|
||||
return (
|
||||
nodeWithPos.pos === from &&
|
||||
nodeWithPos.pos + nodeWithPos.node.nodeSize === to
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
import { useState, useLayoutEffect } from "react";
|
||||
|
||||
const defaultRect = {
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* A hook that returns the size of an element or ref.
|
||||
*
|
||||
@@ -19,19 +8,11 @@ const defaultRect = {
|
||||
*/
|
||||
export function useComponentSize(
|
||||
input: HTMLElement | null | React.RefObject<HTMLElement | null>
|
||||
): DOMRect | typeof defaultRect {
|
||||
) {
|
||||
const element = input instanceof HTMLElement ? input : input?.current;
|
||||
const [size, setSize] = useState(() => element?.getBoundingClientRect());
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const sizeObserver = new ResizeObserver(() => {
|
||||
element?.dispatchEvent(new CustomEvent("resize"));
|
||||
});
|
||||
if (element) {
|
||||
sizeObserver.observe(element);
|
||||
}
|
||||
return () => sizeObserver.disconnect();
|
||||
}, [element]);
|
||||
const [size, setSize] = useState<DOMRect | undefined>(
|
||||
() => element?.getBoundingClientRect() || new DOMRect()
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const handleResize = () => {
|
||||
@@ -55,6 +36,7 @@ export function useComponentSize(
|
||||
window.addEventListener("click", handleResize);
|
||||
window.addEventListener("resize", handleResize);
|
||||
element?.addEventListener("resize", handleResize);
|
||||
handleResize();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("click", handleResize);
|
||||
@@ -63,5 +45,15 @@ export function useComponentSize(
|
||||
};
|
||||
});
|
||||
|
||||
return size ?? defaultRect;
|
||||
useLayoutEffect(() => {
|
||||
const sizeObserver = new ResizeObserver(() => {
|
||||
element?.dispatchEvent(new CustomEvent("resize"));
|
||||
});
|
||||
if (element) {
|
||||
sizeObserver.observe(element);
|
||||
}
|
||||
return () => sizeObserver.disconnect();
|
||||
}, [element]);
|
||||
|
||||
return size ?? new DOMRect();
|
||||
}
|
||||
|
||||
@@ -903,9 +903,6 @@
|
||||
"{{ count }} document imported_plural": "{{ count }} documents imported",
|
||||
"You can import a zip file that was previously exported from an Outline installation – collections, documents, and images will be imported. In Outline, open <em>Export</em> in the Settings sidebar and click on <em>Export Data</em>.": "You can import a zip file that was previously exported from an Outline installation – collections, documents, and images will be imported. In Outline, open <em>Export</em> in the Settings sidebar and click on <em>Export Data</em>.",
|
||||
"Drag and drop the zip file from the Markdown export option in {{appName}}, or click to upload": "Drag and drop the zip file from the Markdown export option in {{appName}}, or click to upload",
|
||||
"Where do I find the file?": "Where do I find the file?",
|
||||
"In Notion, click <em>Settings & Members</em> in the left sidebar and open Settings. Look for the Export section, and click <em>Export all workspace content</em>. Choose <em>HTML</em> as the format for the best data compatability.": "In Notion, click <em>Settings & Members</em> in the left sidebar and open Settings. Look for the Export section, and click <em>Export all workspace content</em>. Choose <em>HTML</em> as the format for the best data compatability.",
|
||||
"Drag and drop the zip file from Notion's HTML export option, or click to upload": "Drag and drop the zip file from Notion's HTML export option, or click to upload",
|
||||
"Last active": "Last active",
|
||||
"Guest": "Guest",
|
||||
"Shared by": "Shared by",
|
||||
|
||||
Reference in New Issue
Block a user