Compare commits

..

1 Commits

Author SHA1 Message Date
Tom Moor a67234ed4f chore: Bump dd-trace 2025-03-08 18:24:33 -07:00
216 changed files with 1596 additions and 17729 deletions
+5 -5
View File
@@ -147,10 +147,6 @@ DISCORD_SERVER_ID=
# DISCORD_SERVER_ID and DISCORD_SERVER_ROLES must be set together.
DISCORD_SERVER_ROLES=
# –––––––––––––– IMPORTS ––––––––––––––
NOTION_CLIENT_ID=
NOTION_CLIENT_SECRET=
# –––––––––––––––– OPTIONAL ––––––––––––––––
# Base64 encoded private key and certificate for HTTPS termination. This is only
@@ -205,10 +201,14 @@ 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_SERVICE=
SMTP_HOST=
SMTP_PORT=
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.
-2
View File
@@ -15,8 +15,6 @@ requestInfoDefaultTitles:
requestInfoLabelToAdd: more information needed
requestInfoUserstoExclude:
- tommoor
# Configuration for new-pr-welcome - https://github.com/behaviorbot/new-pr-welcome
-30
View File
@@ -1,30 +0,0 @@
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'
-4
View File
@@ -171,10 +171,6 @@
"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 -1
View File
@@ -149,7 +149,7 @@ export const searchInCollection = createAction({
},
perform: ({ activeCollectionId }) => {
history.push(searchPath({ collectionId: activeCollectionId }));
history.push(searchPath(undefined, { collectionId: activeCollectionId }));
},
});
+4 -4
View File
@@ -693,7 +693,7 @@ export const searchInDocument = createAction({
return !!document?.isActive;
},
perform: ({ activeDocumentId }) => {
history.push(searchPath({ documentId: activeDocumentId }));
history.push(searchPath(undefined, { documentId: activeDocumentId }));
},
});
@@ -806,15 +806,15 @@ export const openRandomDocument = createAction({
},
});
export const searchDocumentsForQuery = (query: string) =>
export const searchDocumentsForQuery = (searchQuery: string) =>
createAction({
id: "search",
name: ({ t }) =>
t(`Search documents for "{{searchQuery}}"`, { searchQuery: query }),
t(`Search documents for "{{searchQuery}}"`, { searchQuery }),
analyticsName: "Search documents",
section: DocumentSection,
icon: <SearchIcon />,
perform: () => history.push(searchPath({ query })),
perform: () => history.push(searchPath(searchQuery)),
visible: ({ location }) => location.pathname !== searchPath(),
});
+1 -10
View File
@@ -50,7 +50,7 @@ export const navigateToRecentSearchQuery = (searchQuery: SearchQuery) =>
name: searchQuery.query,
analyticsName: "Navigate to recent search query",
icon: <SearchIcon />,
perform: () => history.push(searchPath({ query: searchQuery.query })),
perform: () => history.push(searchPath(searchQuery.query)),
});
export const navigateToDrafts = createAction({
@@ -62,15 +62,6 @@ export const navigateToDrafts = createAction({
visible: ({ location }) => location.pathname !== draftsPath(),
});
export const navigateToSearch = createAction({
name: ({ t }) => t("Search"),
analyticsName: "Navigate to search",
section: NavigationSection,
icon: <SearchIcon />,
perform: () => history.push(searchPath()),
visible: ({ location }) => location.pathname !== searchPath(),
});
export const navigateToArchive = createAction({
name: ({ t }) => t("Archive"),
analyticsName: "Navigate to archive",
+1 -1
View File
@@ -3,7 +3,7 @@ import { observer } from "mobx-react";
import * as React from "react";
import { Switch, Route, useLocation, matchPath } from "react-router-dom";
import { TeamPreference } from "@shared/types";
import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
import ErrorSuspended from "~/scenes/ErrorSuspended";
import Layout from "~/components/Layout";
import RegisterKeyDown from "~/components/RegisterKeyDown";
import Sidebar from "~/components/Sidebar";
@@ -7,43 +7,17 @@ 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,
@@ -90,33 +64,16 @@ 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;
-4
View File
@@ -80,10 +80,6 @@ const RealButton = styled(ActionButton)<RealProps>`
} 0 0 0 1px inset;
}
&:focus-visible {
box-shadow: ${`rgba(0, 0, 0, 0.07) 0px 1px 2px, ${props.theme.inputBorderFocused} 0 0 0 1px inset`};
}
&:disabled {
color: ${props.theme.textTertiary};
background: none;
+2 -2
View File
@@ -49,7 +49,7 @@ function Collaborators(props: Props) {
() =>
orderBy(
filter(
users.all,
users.orderedData,
(u) =>
(presentIds.includes(u.id) ||
document.collaboratorIds.includes(u.id)) &&
@@ -58,7 +58,7 @@ function Collaborators(props: Props) {
[(u) => presentIds.includes(u.id), "id"],
["asc", "asc"]
),
[document.collaboratorIds, users.all, presentIds]
[document.collaboratorIds, users.orderedData, presentIds]
);
// load any users we don't yet have in memory
-3
View File
@@ -11,7 +11,6 @@ import Collection from "~/models/Collection";
import Editor from "~/components/Editor";
import LoadingIndicator from "~/components/LoadingIndicator";
import { withUIExtensions } from "~/editor/extensions";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import Text from "./Text";
@@ -25,7 +24,6 @@ type Props = {
function CollectionDescription({ collection }: Props) {
const { collections } = useStores();
const { t } = useTranslation();
const user = useCurrentUser({ rejectOnEmpty: true });
const can = usePolicy(collection);
const handleSave = React.useMemo(
@@ -67,7 +65,6 @@ function CollectionDescription({ collection }: Props) {
maxLength={CollectionValidation.maxDescriptionLength}
canUpdate={can.update}
readOnly={!can.update}
userId={user.id}
editorStyle={editorStyle}
embedsDisabled
/>
+4 -10
View File
@@ -4,12 +4,6 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import {
AuthenticationFailed,
AuthorizationFailed,
DocumentTooLarge,
TooManyConnections,
} from "@shared/collaboration/CloseEvents";
import Fade from "~/components/Fade";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
@@ -20,21 +14,21 @@ function ConnectionStatus() {
const { t } = useTranslation();
const codeToMessage = {
[DocumentTooLarge.code]: {
1009: {
title: t("Document is too large"),
body: t(
"This document has reached the maximum size and can no longer be edited"
),
},
[AuthenticationFailed.code]: {
4401: {
title: t("Authentication failed"),
body: t("Please try logging out and back in again"),
},
[AuthorizationFailed.code]: {
4403: {
title: t("Authorization failed"),
body: t("You may have lost access to this document, try reloading"),
},
[TooManyConnections.code]: {
4503: {
title: t("Too many users connected to document"),
body: t("Your edits will sync once other users leave the document"),
},
+29 -14
View File
@@ -2,11 +2,16 @@ import { HomeIcon } from "outline-icons";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Optional } from "utility-types";
import Flex from "~/components/Flex";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { InputSelectNew, Option } from "~/components/InputSelectNew";
import InputSelect from "~/components/InputSelect";
import { IconWrapper } from "~/components/Sidebar/components/SidebarLink";
import useStores from "~/hooks/useStores";
type DefaultCollectionInputSelectProps = {
type DefaultCollectionInputSelectProps = Optional<
React.ComponentProps<typeof InputSelect>
> & {
onSelectCollection: (collection: string) => void;
defaultCollectionId: string | null;
};
@@ -14,6 +19,7 @@ type DefaultCollectionInputSelectProps = {
const DefaultCollectionInputSelect = ({
onSelectCollection,
defaultCollectionId,
...rest
}: DefaultCollectionInputSelectProps) => {
const { t } = useTranslation();
const { collections } = useStores();
@@ -41,26 +47,36 @@ const DefaultCollectionInputSelect = ({
void fetchData();
}, [fetchError, t, fetching, collections]);
const options: Option[] = React.useMemo(
const options = React.useMemo(
() =>
collections.nonPrivate.reduce(
(acc, collection) => [
...acc,
{
type: "item",
label: collection.name,
label: (
<Flex align="center">
<IconWrapper>
<CollectionIcon collection={collection} />
</IconWrapper>
{collection.name}
</Flex>
),
value: collection.id,
icon: <CollectionIcon collection={collection} />,
},
],
[
{
type: "item",
label: t("Home"),
label: (
<Flex align="center">
<IconWrapper>
<HomeIcon />
</IconWrapper>
{t("Home")}
</Flex>
),
value: "home",
icon: <HomeIcon />,
},
] satisfies Option[]
]
),
[collections.nonPrivate, t]
);
@@ -70,14 +86,13 @@ const DefaultCollectionInputSelect = ({
}
return (
<InputSelectNew
options={options}
<InputSelect
value={defaultCollectionId ?? "home"}
options={options}
onChange={onSelectCollection}
ariaLabel={t("Default collection")}
label={t("Start view")}
hideLabel
short
{...rest}
/>
);
};
+1 -4
View File
@@ -23,10 +23,7 @@ function Dialogs() {
key={id}
isOpen={modal.isOpen}
fullscreen={modal.fullscreen ?? false}
onRequestClose={() => {
modal.onClose?.();
dialogs.closeModal(id);
}}
onRequestClose={() => dialogs.closeModal(id)}
title={modal.title}
style={modal.style}
>
+13 -39
View File
@@ -6,9 +6,7 @@ import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import { Optional } from "utility-types";
import insertFiles from "@shared/editor/commands/insertFiles";
import EditorContainer from "@shared/editor/components/Styles";
import { AttachmentPreset } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { getDataTransferFiles } from "@shared/utils/files";
import { AttachmentValidation } from "@shared/validations";
import ClickablePadding from "~/components/ClickablePadding";
@@ -185,46 +183,22 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
[updateComments]
);
const paragraphs = React.useMemo(() => {
if (props.readOnly && typeof props.value === "object") {
return ProsemirrorHelper.getPlainParagraphs(props.value);
}
return undefined;
}, [props.readOnly, props.value]);
return (
<ErrorBoundary component="div" reloadOnChunkMissing>
<>
{paragraphs ? (
<EditorContainer
rtl={props.dir === "rtl"}
grow={props.grow}
style={props.style}
editorStyle={props.editorStyle}
>
<div className="ProseMirror">
{paragraphs.map((paragraph, index) => (
<p key={index} dir="auto">
{paragraph.content?.map((content) => content.text)}
</p>
))}
</div>
</EditorContainer>
) : (
<LazyLoadedEditor
key={props.extensions?.length || 0}
ref={mergeRefs([ref, localRef, handleRefChanged])}
uploadFile={handleUploadFile}
embeds={embeds}
userPreferences={preferences}
dictionary={dictionary}
{...props}
onClickLink={handleClickLink}
onChange={handleChange}
placeholder={props.placeholder || ""}
defaultValue={props.defaultValue || ""}
/>
)}
<LazyLoadedEditor
key={props.extensions?.length || 0}
ref={mergeRefs([ref, localRef, handleRefChanged])}
uploadFile={handleUploadFile}
embeds={embeds}
userPreferences={preferences}
dictionary={dictionary}
{...props}
onClickLink={handleClickLink}
onChange={handleChange}
placeholder={props.placeholder || ""}
defaultValue={props.defaultValue || ""}
/>
{props.editorStyle?.paddingBottom && !props.readOnly && (
<ClickablePadding
onClick={props.readOnly ? undefined : focusAtEnd}
+5 -5
View File
@@ -1,9 +1,9 @@
import styled from "styled-components";
import Text from "~/components/Text";
import { s } from "@shared/styles";
const Empty = styled(Text).attrs({
type: "tertiary",
selectable: false,
})``;
const Empty = styled.p`
color: ${s("textTertiary")};
user-select: none;
`;
export default Empty;
+4 -5
View File
@@ -7,7 +7,6 @@ import { s } from "@shared/styles";
import { UrlHelper } from "@shared/utils/UrlHelper";
import Button from "~/components/Button";
import CenteredContent from "~/components/CenteredContent";
import Heading from "~/components/Heading";
import PageTitle from "~/components/PageTitle";
import Text from "~/components/Text";
import env from "~/env";
@@ -78,9 +77,9 @@ class ErrorBoundary extends React.Component<Props> {
{showTitle && (
<>
<PageTitle title={t("Module failed to load")} />
<Heading>
<h1>
<Trans>Loading Failed</Trans>
</Heading>
</h1>
</>
)}
<Text as="p" type="secondary">
@@ -102,9 +101,9 @@ class ErrorBoundary extends React.Component<Props> {
{showTitle && (
<>
<PageTitle title={t("Something Unexpected Happened")} />
<Heading>
<h1>
<Trans>Something Unexpected Happened</Trans>
</Heading>
</h1>
</>
)}
<Text as="p" type="secondary">
+2 -2
View File
@@ -234,7 +234,7 @@ const lineStyle = css`
width: 1px;
height: calc(50% - 14px + 8px);
background: ${s("divider")};
mix-blend-mode: ${(props) => (props.theme.isDark ? "lighten" : "multiply")};
mix-blend-mode: multiply;
z-index: 1;
}
@@ -255,7 +255,7 @@ const lineStyle = css`
width: 1px;
height: calc(50% - 14px);
background: ${s("divider")};
mix-blend-mode: ${(props) => (props.theme.isDark ? "lighten" : "multiply")};
mix-blend-mode: multiply;
z-index: 1;
}
+1 -1
View File
@@ -73,7 +73,7 @@ const Backdrop = styled.div`
right: 0;
bottom: 0;
background-color: ${s("backdrop")} !important;
z-index: ${depths.overlay};
z-index: ${depths.modalOverlay};
transition: opacity 200ms ease-in-out;
opacity: 0;
+1 -2
View File
@@ -60,8 +60,7 @@ function InputSearchPage({
if (ev.key === "Enter") {
ev.preventDefault();
history.push(
searchPath({
query: ev.currentTarget.value,
searchPath(ev.currentTarget.value, {
collectionId,
ref: source,
})
-354
View File
@@ -1,354 +0,0 @@
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
import { transparentize } from "polished";
import React from "react";
import styled from "styled-components";
import Text from "~/components/Text";
import useMobile from "~/hooks/useMobile";
import Separator from "./ContextMenu/Separator";
import Flex from "./Flex";
import { LabelText } from "./Input";
import Scrollable from "./Scrollable";
import { IconWrapper } from "./Sidebar/components/SidebarLink";
import {
Drawer,
DrawerContent,
DrawerTitle,
DrawerTrigger,
} from "./primitives/Drawer";
import {
InputSelectRoot,
InputSelectContent,
InputSelectItem,
InputSelectSeparator,
InputSelectTrigger,
type TriggerButtonProps,
} from "./primitives/InputSelect";
import {
SelectItemIndicator,
SelectItem as SelectItemWrapper,
SelectButton,
} from "./primitives/components/InputSelect";
type Separator = {
/* Denotes a horizontal divider line to be rendered in the menu, */
type: "separator";
};
export type Item = {
/* Denotes a selectable option in the menu. */
type: "item";
/* Representative text shown in the menu for this option. */
label: string;
/* Actual value of this option. */
value: string;
/* Additional info shown alongside the label. */
description?: string;
/* An icon shown alongside the label. */
icon?: React.ReactElement;
};
export type Option = Item | Separator;
type Props = {
/* Options to display in the select menu. */
options: Option[];
/* Current chosen value. */
value?: string;
/* Callback when an option is selected. */
onChange: (value: string) => void;
/* ARIA label for accessibility. */
ariaLabel: string;
/* Label for the select menu. */
label: string;
/* When true, label is hidden in an accessible manner. */
hideLabel?: boolean;
/* When true, menu is disabled. */
disabled?: boolean;
/* When true, width of the menu trigger is restricted. Otherwise, takes up the full width of parent. */
short?: boolean;
} & TriggerButtonProps;
export function InputSelectNew(props: Props) {
const {
options,
value,
onChange,
ariaLabel,
label,
hideLabel,
disabled,
short,
...triggerProps
} = props;
const [localValue, setLocalValue] = React.useState(value);
const [open, setOpen] = React.useState(false);
const triggerRef =
React.useRef<React.ElementRef<typeof InputSelectTrigger>>(null);
const contentRef =
React.useRef<React.ElementRef<typeof InputSelectContent>>(null);
const isMobile = useMobile();
const placeholder = `Select a ${ariaLabel.toLowerCase()}`;
const optionsHaveIcon = options.some(
(opt) => opt.type === "item" && !!opt.icon
);
const renderOption = React.useCallback(
(option: Option) => {
if (option.type === "separator") {
return <InputSelectSeparator />;
}
return (
<InputSelectItem key={option.value} value={option.value}>
<Option option={option} optionsHaveIcon={optionsHaveIcon} />
</InputSelectItem>
);
},
[optionsHaveIcon]
);
const onValueChange = React.useCallback(
async (val: string) => {
setLocalValue(val);
onChange(val);
},
[onChange, setLocalValue]
);
const enablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "auto";
}
}, []);
const disablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "none";
}
}, []);
React.useEffect(() => {
setLocalValue(value);
}, [value]);
if (isMobile) {
return (
<MobileSelect
{...props}
value={localValue}
onChange={onValueChange}
placeholder={placeholder}
optionsHaveIcon={optionsHaveIcon}
/>
);
}
return (
<Wrapper short={short}>
<Label text={label} hidden={hideLabel ?? false} />
<InputSelectRoot
open={open}
onOpenChange={setOpen}
value={localValue}
onValueChange={onValueChange}
>
<InputSelectTrigger
ref={triggerRef}
placeholder={placeholder}
{...triggerProps}
/>
<InputSelectContent
ref={contentRef}
aria-label={ariaLabel}
onAnimationStart={disablePointerEvents}
onAnimationEnd={enablePointerEvents}
>
{options.map(renderOption)}
</InputSelectContent>
</InputSelectRoot>
</Wrapper>
);
}
type MobileSelectProps = Props & {
placeholder: string;
optionsHaveIcon: boolean;
};
function MobileSelect(props: MobileSelectProps) {
const {
options,
value,
onChange,
ariaLabel,
label,
hideLabel,
disabled,
short,
placeholder,
optionsHaveIcon,
...triggerProps
} = props;
const [open, setOpen] = React.useState(false);
const contentRef = React.useRef<React.ElementRef<typeof DrawerContent>>(null);
const selectedOption = React.useMemo(
() =>
value
? options.find((opt) => opt.type === "item" && opt.value === value)
: undefined,
[value, options]
);
const handleSelect = React.useCallback(
async (val: string) => {
setOpen(false);
onChange(val);
},
[onChange]
);
const renderOption = React.useCallback(
(option: Option) => {
if (option.type === "separator") {
return <Separator />;
}
const isSelected = option === selectedOption;
return (
<SelectItemWrapper
key={option.value}
onClick={() => handleSelect(option.value)}
data-state={isSelected ? "checked" : "unchecked"}
>
<Option option={option} optionsHaveIcon={optionsHaveIcon} />
{isSelected && <SelectItemIndicator />}
</SelectItemWrapper>
);
},
[handleSelect, selectedOption, optionsHaveIcon]
);
const enablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "auto";
}
}, []);
const disablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "none";
}
}, []);
return (
<Wrapper>
<Label text={label} hidden={hideLabel ?? false} />
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<SelectButton
{...triggerProps}
neutral
disclosure
data-placeholder={selectedOption ? false : ""}
>
{selectedOption ? (
<Option
option={selectedOption as Item}
optionsHaveIcon={optionsHaveIcon}
/>
) : (
<>{placeholder}</>
)}
</SelectButton>
</DrawerTrigger>
<DrawerContent
ref={contentRef}
aria-label={ariaLabel}
onAnimationStart={disablePointerEvents}
onAnimationEnd={enablePointerEvents}
>
<DrawerTitle hidden={!label}>{label ?? ariaLabel}</DrawerTitle>
<StyledScrollable hiddenScrollbars>
{options.map(renderOption)}
</StyledScrollable>
</DrawerContent>
</Drawer>
</Wrapper>
);
}
function Label({ text, hidden }: { text: string; hidden: boolean }) {
const labelText = <LabelText>{text}</LabelText>;
return hidden ? (
<VisuallyHidden.Root>{labelText}</VisuallyHidden.Root>
) : (
labelText
);
}
function Option({
option,
optionsHaveIcon,
}: {
option: Item;
optionsHaveIcon: boolean;
}) {
const icon = optionsHaveIcon ? (
option.icon ? (
<IconWrapper>{option.icon}</IconWrapper>
) : (
<IconSpacer />
)
) : null;
return (
<OptionContainer align="center">
{icon}
{option.label}
{option.description && (
<>
&nbsp;
<Description type="tertiary" size="small" ellipsis>
{option.description}
</Description>
</>
)}
</OptionContainer>
);
}
const Wrapper = styled.label<{ short?: boolean }>`
display: block;
max-width: ${(props) => (props.short ? "350px" : "100%")};
`;
const OptionContainer = styled(Flex)`
min-height: 24px;
`;
const Description = styled(Text)`
@media (hover: hover) {
&:hover,
&:focus {
color: ${(props) => transparentize(0.5, props.theme.accentText)};
}
}
`;
const IconSpacer = styled.div`
width: 24px;
height: 24px;
flex-shrink: 0;
`;
const StyledScrollable = styled(Scrollable)`
max-height: 75vh;
`;
+5 -3
View File
@@ -223,13 +223,14 @@ const Heading = styled.p<{ $small?: boolean; $ellipsis?: boolean }>`
const Content = styled(Flex)<{ $selected: boolean }>`
flex-direction: column;
flex-grow: 1;
color: ${s("text")};
color: ${(props) => (props.$selected ? props.theme.white : props.theme.text)};
`;
const Subtitle = styled.p<{ $small?: boolean; $selected?: boolean }>`
margin: 0;
font-size: ${(props) => (props.$small ? 13 : 14)}px;
color: ${s("textTertiary")};
color: ${(props) =>
props.$selected ? props.theme.white50 : props.theme.textTertiary};
margin-top: -2px;
`;
@@ -237,7 +238,8 @@ export const Actions = styled(Flex)<{ $selected?: boolean }>`
align-self: center;
justify-content: center;
flex-shrink: 0;
color: ${s("textSecondary")};
color: ${(props) =>
props.$selected ? props.theme.white : props.theme.textSecondary};
`;
export default React.forwardRef(ListItem);
+1 -1
View File
@@ -147,7 +147,7 @@ const Backdrop = styled(Flex)<{ $fullscreen?: boolean }>`
props.$fullscreen
? transparentize(0.25, props.theme.background)
: props.theme.modalBackdrop} !important;
z-index: ${depths.overlay};
z-index: ${depths.modalOverlay};
transition: opacity 50ms ease-in-out;
opacity: 0;
@@ -96,7 +96,7 @@ export const Suggestions = observer(
? users.notInDocument(document.id, query)
: collection
? users.notInCollection(collection.id, query)
: users.activeOrInvited
: users.orderedData
).filter((u) => !u.isSuspended && u.id !== user.id);
if (isEmail(query)) {
@@ -114,7 +114,7 @@ export const Suggestions = observer(
}, [
getSuggestionForEmail,
users,
users.activeOrInvited,
users.orderedData,
groups,
groups.orderedData,
document?.id,
+18 -29
View File
@@ -1,16 +1,15 @@
import invariant from "invariant";
import find from "lodash/find";
import isObject from "lodash/isObject";
import { action, observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { withTranslation, WithTranslation } from "react-i18next";
import semver from "semver";
import { io, Socket } from "socket.io-client";
import { toast } from "sonner";
import {
FileOperationState,
FileOperationType,
ImportState,
} from "@shared/types";
import EDITOR_VERSION from "@shared/editor/version";
import { FileOperationState, FileOperationType } from "@shared/types";
import RootStore from "~/stores/RootStore";
import Collection from "~/models/Collection";
import Comment from "~/models/Comment";
@@ -19,7 +18,6 @@ import FileOperation from "~/models/FileOperation";
import Group from "~/models/Group";
import GroupMembership from "~/models/GroupMembership";
import GroupUser from "~/models/GroupUser";
import Import from "~/models/Import";
import Membership from "~/models/Membership";
import Notification from "~/models/Notification";
import Pin from "~/models/Pin";
@@ -105,7 +103,6 @@ class WebsocketProvider extends React.Component<Props> {
subscriptions,
fileOperations,
notifications,
imports,
} = this.props;
const currentUserId = auth?.user?.id;
@@ -120,10 +117,23 @@ class WebsocketProvider extends React.Component<Props> {
}
});
this.socket.on("authenticated", () => {
this.socket.on("authenticated", (data) => {
if (this.socket) {
this.socket.authenticated = true;
}
if (isObject(data) && "editorVersion" in data) {
const parsedClientVersion = semver.parse(EDITOR_VERSION);
const parsedCurrentVersion = semver.parse(String(data.editorVersion));
if (
parsedClientVersion &&
parsedCurrentVersion &&
(parsedClientVersion.major < parsedCurrentVersion.major ||
parsedClientVersion.minor < parsedCurrentVersion.minor)
) {
window.location.reload();
}
}
});
this.socket.on("unauthorized", (err: Error) => {
@@ -626,23 +636,6 @@ class WebsocketProvider extends React.Component<Props> {
}
);
this.socket.on("imports.create", (event: PartialExcept<Import, "id">) => {
imports.add(event);
});
this.socket.on("imports.update", (event: PartialExcept<Import, "id">) => {
imports.add(event);
if (
event.state === ImportState.Completed &&
event.createdBy?.id === auth.user?.id
) {
toast.success(event.name, {
description: this.props.t("Your import completed"),
});
}
});
this.socket.on(
"subscriptions.create",
(event: PartialExcept<Subscription, "id">) => {
@@ -668,10 +661,6 @@ class WebsocketProvider extends React.Component<Props> {
}
});
this.socket.on("users.delete", (event: WebsocketEntityDeletedEvent) => {
users.remove(event.modelId);
});
this.socket.on(
"userMemberships.update",
async (event: PartialExcept<UserMembership, "id">) => {
-88
View File
@@ -1,88 +0,0 @@
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
import React from "react";
import styled from "styled-components";
import { Drawer as DrawerPrimitive } from "vaul";
import { depths, s } from "@shared/styles";
import Flex from "../Flex";
import Text from "../Text";
import { Overlay } from "./components/Overlay";
/** Root Drawer component - all the other components are rendered inside it. */
const Drawer = (props: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root {...props} />
);
Drawer.displayName = "Drawer";
/** Drawer's trigger. */
const DrawerTrigger = DrawerPrimitive.Trigger;
/** Drawer's content - renders the overlay and the actual content. */
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>((props, ref) => {
const { children, ...rest } = props;
return (
<DrawerPrimitive.Portal>
<DrawerPrimitive.Overlay asChild>
<Overlay />
</DrawerPrimitive.Overlay>
<StyledContent ref={ref} {...rest}>
{children}
</StyledContent>
</DrawerPrimitive.Portal>
);
});
DrawerContent.displayName = DrawerPrimitive.Content.displayName;
/** Drawer's title shown in the center. */
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>((props, ref) => {
const { hidden, children, ...rest } = props;
const title = (
<TitleWrapper justify="center">
<Text size="medium" weight="bold">
{children}
</Text>
</TitleWrapper>
);
return (
<DrawerPrimitive.Title ref={ref} {...rest} asChild>
{hidden ? (
<VisuallyHidden.Root>{title}</VisuallyHidden.Root>
) : (
<>{title}</>
)}
</DrawerPrimitive.Title>
);
});
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
/** Styled components. */
const StyledContent = styled(DrawerPrimitive.Content)`
z-index: ${depths.menu};
position: fixed;
left: 0;
right: 0;
bottom: 0;
min-width: 180px;
max-width: 100%;
min-height: 44px;
max-height: 90vh;
padding: 6px;
border-radius: 6px;
background: ${s("menuBackground")};
`;
const TitleWrapper = styled(Flex)`
padding: 8px 0;
`;
export { Drawer, DrawerTrigger, DrawerContent, DrawerTitle };
-135
View File
@@ -1,135 +0,0 @@
import * as InputSelectPrimitive from "@radix-ui/react-select";
import React from "react";
import styled from "styled-components";
import { depths, s } from "@shared/styles";
import { Props as ButtonProps } from "~/components/Button";
import Separator from "~/components/ContextMenu/Separator";
import { fadeAndSlideDown, fadeAndSlideUp } from "~/styles/animations";
import {
SelectItemIndicator,
SelectItem as SelectItemWrapper,
SelectButton,
} from "./components/InputSelect";
/** Root InputSelect component - all the other components are rendered inside it. */
const InputSelectRoot = InputSelectPrimitive.Root;
/** InputSelect's trigger. */
export type TriggerButtonProps = {
/** When true, "nude" variant of Button is rendered. */
nude?: boolean;
/** Optional css class names to pass to the trigger. */
className?: string;
} & Pick<ButtonProps<unknown>, "borderOnHover">;
type InputSelectTriggerProps = { placeholder: string } & TriggerButtonProps &
React.ComponentPropsWithoutRef<typeof InputSelectPrimitive.Trigger>;
const InputSelectTrigger = React.forwardRef<
React.ElementRef<typeof InputSelectPrimitive.Trigger>,
InputSelectTriggerProps
>((props, ref) => {
const { placeholder, children, ...buttonProps } = props;
return (
<InputSelectPrimitive.Trigger ref={ref} asChild>
<SelectButton neutral disclosure {...buttonProps}>
<InputSelectPrimitive.Value placeholder={placeholder} />
</SelectButton>
</InputSelectPrimitive.Trigger>
);
});
InputSelectTrigger.displayName = InputSelectPrimitive.Trigger.displayName;
/** InputSelect's content - renders the options in a scrollable element. */
type ContentProps = Omit<
React.ComponentPropsWithoutRef<typeof InputSelectPrimitive.Content>,
"position"
>;
const InputSelectContent = React.forwardRef<
React.ElementRef<typeof InputSelectPrimitive.Content>,
ContentProps
>((props, ref) => {
const { children, ...rest } = props;
return (
<InputSelectPrimitive.Portal>
<StyledContent ref={ref} position={"popper"} {...rest}>
<InputSelectPrimitive.Viewport style={{ overscrollBehavior: "none" }}>
{children}
</InputSelectPrimitive.Viewport>
</StyledContent>
</InputSelectPrimitive.Portal>
);
});
InputSelectContent.displayName = InputSelectPrimitive.Content.displayName;
/** Individual InputSelect option rendered in the menu. */
const InputSelectItem = React.forwardRef<
React.ElementRef<typeof InputSelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof InputSelectPrimitive.Item>
>((props, ref) => {
const { children, ...rest } = props;
return (
<InputSelectPrimitive.Item ref={ref} {...rest} asChild>
<SelectItemWrapper>
<InputSelectPrimitive.ItemText>
{children}
</InputSelectPrimitive.ItemText>
<InputSelectPrimitive.ItemIndicator asChild>
<SelectItemIndicator />
</InputSelectPrimitive.ItemIndicator>
</SelectItemWrapper>
</InputSelectPrimitive.Item>
);
});
InputSelectItem.displayName = InputSelectPrimitive.Item.displayName;
/** Horizontal separator rendered between the options. */
const InputSelectSeparator = React.forwardRef<
React.ElementRef<typeof InputSelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof InputSelectPrimitive.Separator>
>((props, ref) => (
<InputSelectPrimitive.Separator ref={ref} asChild>
<Separator {...props} />
</InputSelectPrimitive.Separator>
));
InputSelectSeparator.displayName = InputSelectPrimitive.Separator.displayName;
/** Styled components. */
const StyledContent = styled(InputSelectPrimitive.Content)`
z-index: ${depths.menu};
min-width: var(--radix-select-trigger-width);
max-width: 400px;
min-height: 44px;
max-height: 350px;
padding: 4px;
border-radius: 6px;
background: ${s("menuBackground")};
box-shadow: ${s("menuShadow")};
transform-origin: 50% 0;
&[data-side="bottom"] {
animation: ${fadeAndSlideDown} 200ms ease;
}
&[data-side="top"] {
animation: ${fadeAndSlideUp} 200ms ease;
}
@media print {
display: none;
}
`;
export {
InputSelectRoot,
InputSelectTrigger,
InputSelectContent,
InputSelectItem,
InputSelectSeparator,
};
@@ -1,136 +0,0 @@
/**
* Reusable components for InputSelect abstraction.
*/
import { CheckmarkIcon } from "outline-icons";
import React from "react";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import Button, { Inner } from "~/components/Button";
import Flex from "~/components/Flex";
export const SelectItem = React.forwardRef<
HTMLDivElement,
React.ComponentPropsWithoutRef<"div">
>((props, ref) => {
const { children, ...rest } = props;
return (
<ItemContainer
ref={ref}
justify="space-between"
align="center"
gap={8}
{...rest}
>
{children}
<IconSpacer />
</ItemContainer>
);
});
SelectItem.displayName = "SelectItem";
export const SelectItemIndicator = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span">
>((props, ref) => (
<IndicatorContainer ref={ref} {...props}>
<CheckmarkIcon />
</IndicatorContainer>
));
SelectItemIndicator.displayName = "SelectItemIndicator";
const IconSpacer = styled.div`
width: 24px;
height: 24px;
flex-shrink: 0;
`;
export const SelectButton = styled(Button)<{ $nude?: boolean }>`
display: block;
font-weight: normal;
text-transform: none;
width: 100%;
cursor: var(--pointer);
&:hover:not(:disabled) {
background: ${s("buttonNeutralBackground")};
}
&:focus-visible {
outline: none;
}
${(props) =>
props.$nude &&
css`
border-color: transparent;
box-shadow: none;
`}
${Inner} {
line-height: 28px;
padding-left: 12px;
padding-right: 4px;
}
svg {
justify-self: flex-end;
margin-left: auto;
}
&[data-placeholder=""] {
color: ${s("placeholder")};
}
`;
const ItemContainer = styled(Flex)`
position: relative;
width: 100%;
font-size: 16px;
cursor: var(--pointer);
color: ${s("textSecondary")};
background: none;
margin: 0;
padding: 12px;
border: 0;
border-radius: 4px;
outline: 0;
user-select: none;
white-space: nowrap;
svg {
flex-shrink: 0;
}
@media (hover: hover) {
&:hover,
&:focus {
color: ${s("accentText")};
background: ${s("accent")};
svg {
color: ${s("accentText")};
fill: ${s("accentText")};
}
}
}
&[data-state="checked"] {
${IconSpacer} {
display: none;
}
}
${breakpoint("tablet")`
font-size: 14px;
padding: 4px;
padding-left: 8px;
`}
`;
const IndicatorContainer = styled.span`
width: 24px;
height: 24px;
`;
@@ -1,15 +0,0 @@
import styled from "styled-components";
import { depths, s } from "@shared/styles";
export const Overlay = styled.div`
position: fixed;
inset: 0;
background: ${s("backdrop")};
z-index: ${depths.overlay};
transition: opacity 50ms ease-in-out;
opacity: 0;
&[data-state="open"] {
opacity: 1;
}
`;
+3 -2
View File
@@ -645,11 +645,12 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
"section" in item ? item.section?.({ t }) : undefined;
const response = (
<React.Fragment key={`${index}-${item.name}`}>
<>
{currentHeading !== previousHeading && (
<Header key={currentHeading}>{currentHeading}</Header>
)}
<ListItem
key={index}
onPointerMove={handlePointerMove}
onPointerDown={handlePointerDown}
>
@@ -658,7 +659,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
onClick: () => handleClickItem(item),
})}
</ListItem>
</React.Fragment>
</>
);
previousHeading = currentHeading;
@@ -1,4 +1,3 @@
import { transparentize } from "polished";
import * as React from "react";
import scrollIntoView from "scroll-into-view-if-needed";
import styled from "styled-components";
@@ -69,16 +68,12 @@ function SuggestionsMenuItem({
const Subtitle = styled.span<{ $active?: boolean }>`
color: ${(props) =>
props.$active
? transparentize(0.35, props.theme.accentText)
: props.theme.textTertiary};
props.$active ? props.theme.white50 : props.theme.textTertiary};
`;
const Shortcut = styled.span<{ $active?: boolean }>`
color: ${(props) =>
props.$active
? transparentize(0.35, props.theme.accentText)
: props.theme.textTertiary};
props.$active ? props.theme.white50 : props.theme.textTertiary};
flex-grow: 1;
text-align: right;
`;
+4 -6
View File
@@ -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";
@@ -122,8 +122,6 @@ export default class PasteHandler extends Extension {
}
// Is the link a link to a document? If so, we can grab the title and insert it.
const containsHash = text.includes("#");
if (isDocumentUrl(text)) {
const slug = parseDocumentSlug(text);
@@ -135,7 +133,7 @@ export default class PasteHandler extends Extension {
return;
}
if (document) {
if (state.schema.nodes.mention && !containsHash) {
if (state.schema.nodes.mention) {
view.dispatch(
view.state.tr.replaceWith(
state.selection.from,
@@ -180,7 +178,7 @@ export default class PasteHandler extends Extension {
return;
}
if (collection) {
if (state.schema.nodes.mention && !containsHash) {
if (state.schema.nodes.mention) {
view.dispatch(
view.state.tr.replaceWith(
state.selection.from,
@@ -228,7 +226,7 @@ export default class PasteHandler extends Extension {
state.tr
.replaceSelectionWith(
state.schema.nodes.code_block.create({
language: Object.keys(codeLanguages).includes(
language: Object.keys(LANGUAGES).includes(
vscodeMeta.mode
)
? vscodeMeta.mode
+1 -1
View File
@@ -23,7 +23,7 @@ export default class Suggestion extends Extension {
this.options.trigger
)}(${`[\\p{L}\/\\p{M}\\d${
this.options.allowSpaces ? "\\s{1}" : ""
}\\.\\-_]+`})${this.options.requireSearchTerm ? "" : "?"}$`,
}\\.]+`})${this.options.requireSearchTerm ? "" : "?"}$`,
"u"
);
}
+1 -1
View File
@@ -843,7 +843,7 @@ const EditorContainer = styled(Styles)<{
${(props) =>
props.userId &&
css`
.mention[data-id="${props.userId}"] {
.mention[data-id=${props.userId}] {
color: ${props.theme.textHighlightForeground};
background: ${props.theme.textHighlight};
+12 -13
View File
@@ -2,11 +2,8 @@ import { CopyIcon, ExpandedIcon } from "outline-icons";
import { Node as ProseMirrorNode } from "prosemirror-model";
import { EditorState } from "prosemirror-state";
import * as React from "react";
import {
getFrequentCodeLanguages,
codeLanguages,
getLabelForLanguage,
} from "@shared/editor/lib/code";
import { LANGUAGES } from "@shared/editor/extensions/Prism";
import { getFrequentCodeLanguages } from "@shared/editor/lib/code";
import { MenuItem } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary";
@@ -17,19 +14,20 @@ 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 = codeLanguages[value]?.label;
const label = LANGUAGES[value];
return 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 remainingLangMenuItems = allLanguages
.filter(([value]) => !frequentLanguages.includes(value))
.map(([value, label]) => langToMenuItem({ node, value, label }));
const languageMenuItems = frequentLangMenuItems.length
? [
@@ -54,7 +52,8 @@ export default function codeMenuItems(
visible: !readOnly,
name: "code_block",
icon: <ExpandedIcon />,
label: getLabelForLanguage(node.attrs.language ?? "none"),
// @ts-expect-error We have a fallback for incorrect mapping
label: LANGUAGES[node.attrs.language ?? "none"],
children: languageMenuItems,
},
];
-5
View File
@@ -1,11 +1,6 @@
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();
-9
View File
@@ -1,15 +1,6 @@
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,
-6
View File
@@ -1,11 +1,5 @@
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);
-5
View File
@@ -1,11 +1,6 @@
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)`);
}
+2 -3
View File
@@ -18,7 +18,6 @@ export default function usePolicy(entity?: string | Model | null) {
? entity
: entity.id
: "";
const policy = policies.get(entityId);
React.useEffect(() => {
if (
@@ -29,11 +28,11 @@ export default function usePolicy(entity?: string | Model | null) {
) {
// The policy for this model is missing and we have an authenticated session, attempt to
// reload relationships for this model.
if (!policy && user) {
if (!policies.get(entity.id) && user) {
void entity.loadRelations();
}
}
}, [policies, policy, user, entity]);
}, [policies, user, entity]);
return policies.abilities(entityId);
}
-5
View File
@@ -1,11 +1,6 @@
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();
-8
View File
@@ -24,14 +24,6 @@ export default function useQueryNotices() {
);
break;
}
case QueryNotices.UnsubscribeCollection: {
toast.success(
t("Unsubscribed from collection", {
type: "success",
})
);
break;
}
default:
}
}, [t, notice]);
-5
View File
@@ -2,11 +2,6 @@ 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;
}
-5
View File
@@ -1,10 +1,5 @@
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;
-6
View File
@@ -1,11 +1,5 @@
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
-7
View File
@@ -13,13 +13,6 @@ 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;
}): {
-62
View File
@@ -1,62 +0,0 @@
import { observer } from "mobx-react";
import { CrossIcon, TrashIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useMenuState } from "reakit/Menu";
import Import from "~/models/Import";
import ContextMenu from "~/components/ContextMenu";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Template from "~/components/ContextMenu/Template";
import usePolicy from "~/hooks/usePolicy";
import { MenuItem } from "~/types";
type Props = {
/** Import to which actions will be applied. */
importModel: Import;
/** Callback to handle import cancellation. */
onCancel: () => Promise<void>;
/** Callback to handle import deletion. */
onDelete: () => Promise<void>;
};
export const ImportMenu = observer(
({ importModel, onCancel, onDelete }: Props) => {
const { t } = useTranslation();
const can = usePolicy(importModel);
const menu = useMenuState({
modal: true,
});
const items = React.useMemo(
() =>
[
{
type: "button",
title: t("Cancel"),
visible: can.cancel,
icon: <CrossIcon />,
dangerous: true,
onClick: onCancel,
},
{
type: "button",
title: t("Delete"),
visible: can.delete,
icon: <TrashIcon />,
dangerous: true,
onClick: onDelete,
},
] satisfies MenuItem[],
[t, can.delete, can.cancel, onCancel, onDelete]
);
return (
<>
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
<ContextMenu {...menu} aria-label={t("Import menu options")}>
<Template {...menu} items={items} />
</ContextMenu>
</>
);
}
);
+1 -1
View File
@@ -600,7 +600,7 @@ export default class Document extends ArchivableModel implements Searchable {
*/
getSummary = (blocks = 4) => ({
...this.data,
content: this.data.content?.slice(0, blocks),
content: this.data.content.slice(0, blocks),
});
@computed
-57
View File
@@ -1,57 +0,0 @@
import { observable } from "mobx";
import { ImportableIntegrationService, ImportState } from "@shared/types";
import ImportsStore from "~/stores/ImportsStore";
import User from "./User";
import Model from "./base/Model";
import Field from "./decorators/Field";
import { AfterChange } from "./decorators/Lifecycle";
import Relation from "./decorators/Relation";
class Import extends Model {
static modelName = "Import";
store: ImportsStore;
/** The name of the import. */
name: string;
/** Descriptive error message when the import errors out. */
error: string | null;
/** The current state of the import. */
@Field
@observable
state: ImportState;
/** The external service from which the import is created. */
service: ImportableIntegrationService;
/** The count of documents created in the import. */
@observable
documentCount: number;
/** The user who created the import. */
@Relation(() => User, {})
createdBy: User;
/** The ID of the user who created the import. */
createdById: string;
/**
* Cancel the import this will stop the import process and mark it as
* cancelled at the first opportunity.
*/
cancel = async () => this.store.cancel(this);
// hooks
@AfterChange
static removePolicies(model: Import, previousAttributes: Partial<Import>) {
if (previousAttributes.state && previousAttributes.state !== model.state) {
const { policies } = model.store.rootStore;
policies.remove(model.id);
}
}
}
export default Import;
+14 -14
View File
@@ -43,6 +43,12 @@ export default abstract class Model {
this: Model,
options: { withoutPolicies?: boolean } = {}
): Promise<any> {
const relations = getRelationsForModelClass(
this.constructor as typeof Model
);
if (!relations) {
return;
}
// this is to ensure that multiple loads dont happen in parallel
if (this.loadingRelations) {
return this.loadingRelations;
@@ -50,20 +56,14 @@ export default abstract class Model {
const promises = [];
const relations = getRelationsForModelClass(
this.constructor as typeof Model
);
if (relations) {
for (const properties of relations.values()) {
const store = this.store.rootStore.getStoreForModelName(
properties.relationClassResolver().modelName
);
if ("fetch" in store) {
const id = this[properties.idKey];
if (id) {
promises.push(store.fetch(id as string));
}
for (const properties of relations.values()) {
const store = this.store.rootStore.getStoreForModelName(
properties.relationClassResolver().modelName
);
if ("fetch" in store) {
const id = this[properties.idKey];
if (id) {
promises.push(store.fetch(id as string));
}
}
}
+1 -6
View File
@@ -1,12 +1,7 @@
import { computed, observable } from "mobx";
import { observable } from "mobx";
import Model from "./Model";
export default abstract class ParanoidModel extends Model {
@observable
deletedAt: string | undefined;
@computed
get isDeleted(): boolean {
return !!this.deletedAt;
}
}
+2 -2
View File
@@ -2,7 +2,7 @@ import { observer } from "mobx-react";
import * as React from "react";
import { Switch, Redirect, RouteComponentProps } from "react-router-dom";
import DocumentNew from "~/scenes/DocumentNew";
import Error404 from "~/scenes/Errors/Error404";
import Error404 from "~/scenes/Error404";
import AuthenticatedLayout from "~/components/AuthenticatedLayout";
import CenteredContent from "~/components/CenteredContent";
import PlaceholderDocument from "~/components/PlaceholderDocument";
@@ -83,7 +83,7 @@ function AuthenticatedRoutes() {
<Route exact path={`/doc/${slug}/insights`} component={Document} />
<Route exact path={`/doc/${slug}/edit`} component={Document} />
<Route path={`/doc/${slug}`} component={Document} />
<Route exact path={`${searchPath()}/:query?`} component={Search} />
<Route exact path={`${searchPath()}/:term?`} component={Search} />
<Route path="/404" component={Error404} />
<SettingsRoutes />
<Route component={Error404} />
+1 -1
View File
@@ -1,7 +1,7 @@
import * as React from "react";
import { RouteComponentProps, Switch } from "react-router-dom";
import DocumentNew from "~/scenes/DocumentNew";
import Error404 from "~/scenes/Errors/Error404";
import Error404 from "~/scenes/Error404";
import Route from "~/components/ProfiledRoute";
import useSettingsConfig from "~/hooks/useSettingsConfig";
import lazy from "~/utils/lazyWithRetry";
+2 -2
View File
@@ -17,6 +17,7 @@ import { s } from "@shared/styles";
import { StatusFilter } from "@shared/types";
import { colorPalette } from "@shared/utils/collections";
import Collection from "~/models/Collection";
import Search from "~/scenes/Search";
import { Action } from "~/components/Actions";
import CenteredContent from "~/components/CenteredContent";
import { CollectionBreadcrumb } from "~/components/CollectionBreadcrumb";
@@ -40,7 +41,6 @@ import { usePinnedDocuments } from "~/hooks/usePinnedDocuments";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { collectionPath, updateCollectionPath } from "~/utils/routeHelpers";
import Error404 from "../Errors/Error404";
import Actions from "./components/Actions";
import DropToImport from "./components/DropToImport";
import Empty from "./components/Empty";
@@ -139,7 +139,7 @@ const CollectionScene = observer(function _CollectionScene() {
useCommandBarActions([editCollection], [ui.activeCollectionId ?? "none"]);
if (!collection && error) {
return <Error404 />;
return <Search notFound />;
}
const hasOverview = can.update || collection?.hasDescription;
+2 -2
View File
@@ -9,8 +9,8 @@ import { s } from "@shared/styles";
import { NavigationNode, PublicTeam, TOCPosition } from "@shared/types";
import type { Theme } from "~/stores/UiStore";
import DocumentModel from "~/models/Document";
import Error404 from "~/scenes/Errors/Error404";
import ErrorOffline from "~/scenes/Errors/ErrorOffline";
import Error404 from "~/scenes/Error404";
import ErrorOffline from "~/scenes/ErrorOffline";
import ClickablePadding from "~/components/ClickablePadding";
import {
DocumentContextProvider,
@@ -5,7 +5,7 @@ import { useHistory, useLocation } from "react-router-dom";
import styled from "styled-components";
import { s } from "@shared/styles";
import { UserPreference } from "@shared/types";
import { InputSelectNew, Option } from "~/components/InputSelectNew";
import InputSelect from "~/components/InputSelect";
import useCurrentUser from "~/hooks/useCurrentUser";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useQuery from "~/hooks/useQuery";
@@ -28,80 +28,66 @@ const CommentSortMenu = () => {
const viewingResolved = params.get("resolved") === "";
const value = viewingResolved ? "resolved" : preferredSortType;
const handleChange = React.useCallback(
(val: string) => {
if (val === "resolved") {
history.push({
search: queryString.stringify({
...queryString.parse(location.search),
resolved: "",
}),
pathname: location.pathname,
state: { sidebarContext },
});
return;
}
const handleSortTypeChange = (type: CommentSortType) => {
if (type !== preferredSortType) {
user.setPreference(
UserPreference.SortCommentsByOrderInDocument,
type === CommentSortType.OrderInDocument
);
void user.save();
}
};
const sortType = val as CommentSortType;
if (sortType !== preferredSortType) {
user.setPreference(
UserPreference.SortCommentsByOrderInDocument,
sortType === CommentSortType.OrderInDocument
);
void user.save();
}
const showResolved = () => {
history.push({
search: queryString.stringify({
...queryString.parse(location.search),
resolved: "",
}),
pathname: location.pathname,
state: { sidebarContext },
});
};
history.push({
search: queryString.stringify({
...queryString.parse(location.search),
resolved: undefined,
}),
pathname: location.pathname,
state: { sidebarContext },
});
},
[history, location, sidebarContext, user, preferredSortType]
);
const options: Option[] = React.useMemo(
() =>
[
{
type: "item",
label: t("Most recent"),
value: CommentSortType.MostRecent,
},
{
type: "item",
label: t("Order in doc"),
value: CommentSortType.OrderInDocument,
},
{
type: "separator",
},
{
type: "item",
label: t("Resolved"),
value: "resolved",
},
] satisfies Option[],
[t]
);
const showUnresolved = () => {
history.push({
search: queryString.stringify({
...queryString.parse(location.search),
resolved: undefined,
}),
pathname: location.pathname,
state: { sidebarContext },
});
};
return (
<Select
options={options}
value={value}
onChange={handleChange}
style={{ margin: 0 }}
ariaLabel={t("Sort comments")}
label={t("Sort comments")}
hideLabel
value={value}
onChange={(ev) => {
if (ev === "resolved") {
showResolved();
} else {
handleSortTypeChange(ev as CommentSortType);
showUnresolved();
}
}}
borderOnHover
options={[
{ value: CommentSortType.MostRecent, label: t("Most recent") },
{ value: CommentSortType.OrderInDocument, label: t("Order in doc") },
{
divider: true,
value: "resolved",
label: t("Resolved"),
},
]}
/>
);
};
const Select = styled(InputSelectNew)`
const Select = styled(InputSelect)`
color: ${s("textSecondary")};
`;
@@ -6,10 +6,9 @@ import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { RevisionHelper } from "@shared/utils/RevisionHelper";
import Document from "~/models/Document";
import Revision from "~/models/Revision";
import Error402 from "~/scenes/Errors/Error402";
import Error403 from "~/scenes/Errors/Error403";
import Error404 from "~/scenes/Errors/Error404";
import ErrorOffline from "~/scenes/Errors/ErrorOffline";
import Error402 from "~/scenes/Error402";
import Error404 from "~/scenes/Error404";
import ErrorOffline from "~/scenes/ErrorOffline";
import { useDocumentContext } from "~/components/DocumentContext";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
@@ -18,7 +17,6 @@ import useStores from "~/hooks/useStores";
import { Properties } from "~/types";
import Logger from "~/utils/Logger";
import {
AuthorizationError,
NotFoundError,
OfflineError,
PaymentRequiredError,
@@ -218,8 +216,6 @@ function DataLoader({ match, children }: Props) {
<ErrorOffline />
) : error instanceof PaymentRequiredError ? (
<Error402 />
) : error instanceof AuthorizationError ? (
<Error403 />
) : (
<Error404 />
);
@@ -6,12 +6,6 @@ import { useHistory } from "react-router-dom";
import { toast } from "sonner";
import { IndexeddbPersistence } from "y-indexeddb";
import * as Y from "yjs";
import {
AuthenticationFailed,
DocumentTooLarge,
EditorUpdateError,
} from "@shared/collaboration/CloseEvents";
import EDITOR_VERSION from "@shared/editor/version";
import { supportsPassiveListener } from "@shared/utils/browser";
import Editor, { Props as EditorProps } from "~/components/Editor";
import MultiplayerExtension from "~/editor/extensions/Multiplayer";
@@ -71,9 +65,6 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
const name = `document.${documentId}`;
const localProvider = new IndexeddbPersistence(name, ydoc);
const provider = new HocuspocusProvider({
parameters: {
editorVersion: EDITOR_VERSION,
},
url: `${env.COLLABORATION_URL}/collaboration`,
name,
document: ydoc,
@@ -149,14 +140,8 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
provider.on("close", (ev: MessageEvent) => {
if ("code" in ev.event) {
provider.shouldConnect =
ev.event.code !== DocumentTooLarge.code &&
ev.event.code !== AuthenticationFailed.code &&
ev.event.code !== EditorUpdateError.code;
ev.event.code !== 1009 && ev.event.code !== 4401;
ui.setMultiplayerStatus("disconnected", ev.event.code);
if (ev.event.code === EditorUpdateError.code) {
window.location.reload();
}
}
});
@@ -1,9 +1,7 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import Flex from "@shared/components/Flex";
import Empty from "~/components/Empty";
import Heading from "~/components/Heading";
import Notice from "~/components/Notice";
import Scene from "~/components/Scene";
@@ -14,15 +12,13 @@ const Error402 = () => {
return (
<Scene title={title}>
<Heading>{title}</Heading>
<Flex style={{ maxWidth: 500 }} column>
<Empty size="large">
<Notice>
This document cannot be viewed with the current edition. Please
upgrade to a paid license to restore access.
</Notice>
</Empty>
</Flex>
<h1>{title}</h1>
<Empty>
<Notice>
This document cannot be viewed with the current edition. Please
upgrade to a paid license to restore access.
</Notice>
</Empty>
</Scene>
);
};
+23
View File
@@ -0,0 +1,23 @@
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { Link } from "react-router-dom";
import Empty from "~/components/Empty";
import Scene from "~/components/Scene";
import { homePath } from "~/utils/routeHelpers";
const Error404 = () => {
const { t } = useTranslation();
return (
<Scene title={t("Not Found")}>
<h1>{t("Not Found")}</h1>
<Empty>
<Trans>
We were unable to find the page youre looking for. Go to the{" "}
<Link to={homePath()}>homepage</Link>?
</Trans>
</Empty>
</Scene>
);
};
export default Error404;
@@ -2,7 +2,6 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import CenteredContent from "~/components/CenteredContent";
import Empty from "~/components/Empty";
import Heading from "~/components/Heading";
import PageTitle from "~/components/PageTitle";
const ErrorOffline = () => {
@@ -10,7 +9,7 @@ const ErrorOffline = () => {
return (
<CenteredContent>
<PageTitle title={t("Offline")} />
<Heading>{t("Offline")}</Heading>
<h1>{t("Offline")}</h1>
<Empty>{t("We were unable to load the document while offline.")}</Empty>
</CenteredContent>
);
@@ -2,7 +2,6 @@ import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import CenteredContent from "~/components/CenteredContent";
import Heading from "~/components/Heading";
import PageTitle from "~/components/PageTitle";
import useStores from "~/hooks/useStores";
@@ -13,12 +12,12 @@ const ErrorSuspended = () => {
return (
<CenteredContent>
<PageTitle title={t("Your account has been suspended")} />
<Heading>
<h1>
<span role="img" aria-label={t("Warning Sign")}>
</span>{" "}
{t("Your account has been suspended")}
</Heading>
</h1>
<p>
<Trans
-40
View File
@@ -1,40 +0,0 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import Flex from "@shared/components/Flex";
import Button from "~/components/Button";
import Empty from "~/components/Empty";
import Heading from "~/components/Heading";
import Scene from "~/components/Scene";
import { navigateToHome } from "~/actions/definitions/navigation";
import useActionContext from "~/hooks/useActionContext";
const Error403 = () => {
const { t } = useTranslation();
const history = useHistory();
const context = useActionContext();
return (
<Scene title={t("No access to this doc")}>
<Heading>{t("No access to this doc")}</Heading>
<Flex gap={20} style={{ maxWidth: 500 }} column>
<Empty size="large">
{t(
"It doesnt look like you have permission to access this document."
)}{" "}
{t("Please request access from the document owner.")}
</Empty>
<Flex gap={8}>
<Button action={navigateToHome} context={context} hideIcon>
{t("Home")}
</Button>
<Button onClick={history.goBack} neutral>
{t("Go back")}
</Button>
</Flex>
</Flex>
</Scene>
);
};
export default Error403;
-41
View File
@@ -1,41 +0,0 @@
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import Flex from "@shared/components/Flex";
import Button from "~/components/Button";
import Empty from "~/components/Empty";
import Heading from "~/components/Heading";
import Scene from "~/components/Scene";
import {
navigateToHome,
navigateToSearch,
} from "~/actions/definitions/navigation";
import useActionContext from "~/hooks/useActionContext";
const Error404 = () => {
const { t } = useTranslation();
const context = useActionContext();
return (
<Scene title={t("Not found")}>
<Heading>{t("Not found")}</Heading>
<Flex gap={20} style={{ maxWidth: 500 }} column>
<Empty size="large">
<Trans>
The page youre looking for cannot be found. It might have been
deleted or the link is incorrect.
</Trans>
</Empty>
<Flex gap={8}>
<Button action={navigateToHome} context={context} hideIcon>
{t("Home")}
</Button>
<Button action={navigateToSearch} context={context} neutral>
{t("Search")}
</Button>
</Flex>
</Flex>
</Scene>
);
};
export default Error404;
+17 -6
View File
@@ -15,6 +15,7 @@ import {
} from "@shared/types";
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
import DocumentListItem from "~/components/DocumentListItem";
import Empty from "~/components/Empty";
import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
import LoadingIndicator from "~/components/LoadingIndicator";
@@ -37,7 +38,9 @@ import RecentSearches from "./components/RecentSearches";
import SearchInput from "./components/SearchInput";
import UserFilter from "./components/UserFilter";
function Search() {
type Props = { notFound?: boolean };
function Search(props: Props) {
const { t } = useTranslation();
const { documents, searches } = useStores();
@@ -45,7 +48,7 @@ function Search() {
const params = useQuery();
const location = useLocation();
const history = useHistory();
const routeMatch = useRouteMatch<{ query: string }>();
const routeMatch = useRouteMatch<{ term: string }>();
// refs
const searchInputRef = React.useRef<HTMLInputElement | null>(null);
@@ -54,7 +57,7 @@ function Search() {
// filters
const decodedQuery = decodeURIComponentSafe(
routeMatch.params.query ?? params.get("q") ?? params.get("query") ?? ""
routeMatch.params.term ?? params.get("query") ?? ""
).trim();
const query = decodedQuery !== "" ? decodedQuery : undefined;
const collectionId = params.get("collectionId") ?? "";
@@ -127,9 +130,9 @@ function Search() {
const updateLocation = (query: string) => {
history.replace({
pathname: location.pathname,
pathname: searchPath(query),
search: queryString.stringify(
{ ...queryString.parse(location.search), q: query },
{ ...queryString.parse(location.search), query: undefined },
{
skipEmptyString: true,
}
@@ -150,7 +153,7 @@ function Search() {
history.replace({
pathname: location.pathname,
search: queryString.stringify(
{ ...queryString.parse(location.search), ...search },
{ ...queryString.parse(location.search), query: undefined, ...search },
{
skipEmptyString: true,
}
@@ -208,6 +211,14 @@ function Search() {
<Scene textTitle={query ? `${query} ${t("Search")}` : t("Search")}>
<RegisterKeyDown trigger="Escape" handler={history.goBack} />
{loading && <LoadingIndicator />}
{props.notFound && (
<div>
<h1>{t("Not Found")}</h1>
<Empty>
{t("We were unable to find the page youre looking for.")}
</Empty>
</div>
)}
<ResultsWrapper column auto>
<form
method="GET"
@@ -27,7 +27,7 @@ function RecentSearchListItem({ searchQuery }: Props) {
return (
<RecentSearch
to={searchPath({ query: searchQuery.query })}
to={searchPath(searchQuery.query)}
ref={ref}
{...rovingTabIndex}
>
+18 -31
View File
@@ -16,7 +16,7 @@ import DefaultCollectionInputSelect from "~/components/DefaultCollectionInputSel
import Heading from "~/components/Heading";
import Input from "~/components/Input";
import InputColor from "~/components/InputColor";
import { InputSelectNew, Option } from "~/components/InputSelectNew";
import InputSelect from "~/components/InputSelect";
import Scene from "~/components/Scene";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
@@ -64,27 +64,6 @@ function Details() {
team.getPreference(TeamPreference.TocPosition) as TOCPosition
);
const tocPositionOptions: Option[] = React.useMemo(
() =>
[
{
type: "item",
label: t("Left"),
value: TOCPosition.Left,
},
{
type: "item",
label: t("Right"),
value: TOCPosition.Right,
},
] satisfies Option[],
[t]
);
const handleTocPositionChange = React.useCallback((position: string) => {
setTocPosition(position as TOCPosition);
}, []);
const handleSubmit = React.useCallback(
async (event?: React.SyntheticEvent) => {
if (event) {
@@ -144,9 +123,9 @@ function Details() {
});
};
const onSelectCollection = React.useCallback((value: string) => {
const selectedValue = value === "home" ? null : value;
setDefaultCollectionId(selectedValue);
const onSelectCollection = React.useCallback(async (value: string) => {
const defaultCollectionId = value === "home" ? null : value;
setDefaultCollectionId(defaultCollectionId);
}, []);
const isValid = form.current?.checkValidity();
@@ -263,13 +242,20 @@ function Details() {
"The side to display the table of contents in relation to the main content."
)}
>
<InputSelectNew
options={tocPositionOptions}
value={tocPosition}
onChange={handleTocPositionChange}
<InputSelect
ariaLabel={t("Table of contents position")}
label={t("Table of contents position")}
hideLabel
options={[
{
label: t("Left"),
value: TOCPosition.Left,
},
{
label: t("Right"),
value: TOCPosition.Right,
},
]}
value={tocPosition}
onChange={(p: TOCPosition) => setTocPosition(p)}
/>
</SettingRow>
@@ -312,6 +298,7 @@ function Details() {
)}
>
<DefaultCollectionInputSelect
id="defaultCollectionId"
onSelectCollection={onSelectCollection}
defaultCollectionId={defaultCollectionId}
/>
+88 -154
View File
@@ -1,13 +1,10 @@
import orderBy from "lodash/orderBy";
import { observer } from "mobx-react";
import { NewDocumentIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { Pagination } from "@shared/constants";
import { FileOperationType } from "@shared/types";
import { cdnPath } from "@shared/utils/urls";
import FileOperation from "~/models/FileOperation";
import ImportModel from "~/models/Import";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
@@ -18,146 +15,16 @@ import Scene from "~/components/Scene";
import Text from "~/components/Text";
import env from "~/env";
import useStores from "~/hooks/useStores";
import { Hook, PluginManager } from "~/utils/PluginManager";
import FileOperationListItem from "./components/FileOperationListItem";
import ImportJSONDialog from "./components/ImportJSONDialog";
import { ImportListItem } from "./components/ImportListItem";
import ImportMarkdownDialog from "./components/ImportMarkdownDialog";
type Config = {
/** The title of the import. */
title: string;
/** The auxiliary descriptive text of the import. */
subtitle: string;
/** An icon to denote the kind of import. */
icon: React.ReactElement;
/** Trigger for the import. */
action: React.ReactElement;
};
function useImportsConfig() {
const { t } = useTranslation();
const { dialogs } = useStores();
const appName = env.APP_NAME;
return React.useMemo(() => {
const items: Config[] = [
{
title: t("Markdown"),
subtitle: t(
"Import a zip file of Markdown documents (exported from version 0.67.0 or earlier)"
),
icon: <MarkdownIcon size={28} />,
action: (
<Button
type="submit"
onClick={() => {
dialogs.openModal({
title: t("Import data"),
content: <ImportMarkdownDialog />,
});
}}
neutral
>
{t("Import")}
</Button>
),
},
{
title: "JSON",
subtitle: t(
"Import a JSON data file exported from another {{ appName }} instance",
{
appName,
}
),
icon: <OutlineIcon size={28} cover />,
action: (
<Button
type="submit"
onClick={() => {
dialogs.openModal({
title: t("Import data"),
content: <ImportJSONDialog />,
});
}}
neutral
>
{t("Import")}
</Button>
),
},
];
PluginManager.getHooks(Hook.Imports).forEach((plugin) => {
items.push({ ...plugin.value });
});
items.push({
title: "Confluence",
subtitle: t("Import pages from a Confluence instance"),
icon: <img src={cdnPath("/images/confluence.png")} width={28} />,
action: (
<Button type="submit" disabled neutral>
{t("Enterprise")}
</Button>
),
});
return items;
}, [t, dialogs, appName]);
}
import ImportNotionDialog from "./components/ImportNotionDialog";
function Import() {
const { t } = useTranslation();
const { fileOperations, imports } = useStores();
const configs = useImportsConfig();
const { dialogs, fileOperations } = useStores();
const appName = env.APP_NAME;
const [, setForceRender] = React.useState(0);
const offset = React.useMemo(() => ({ imports: 0, fileOperations: 0 }), []);
const fetchImports = React.useCallback(async () => {
const [importsArr, fileOpsArr] = await Promise.all([
imports.fetchPage({
offset: offset.imports,
limit: Pagination.defaultLimit,
}),
fileOperations.fetchPage({
type: FileOperationType.Import,
offset: offset.fileOperations,
limit: Pagination.defaultLimit,
}),
]);
const pageImports = orderBy(
[...importsArr, ...fileOpsArr],
"createdAt",
"desc"
).slice(0, Pagination.defaultLimit);
const apiImportsCount = pageImports.filter(
(item) => item instanceof ImportModel
).length;
offset.imports += apiImportsCount;
offset.fileOperations += pageImports.length - apiImportsCount;
// needed to re-render after mobx store and offset is updated
setForceRender((s) => ++s);
return pageImports;
}, [imports, fileOperations, offset]);
const allImports = orderBy(
[
...imports.orderedData,
...fileOperations.filter({ type: FileOperationType.Import }),
],
"createdAt",
"desc"
).slice(0, offset.imports + offset.fileOperations);
return (
<Scene title={t("Import")} icon={<NewDocumentIcon />}>
<Heading>{t("Import")}</Heading>
@@ -171,33 +38,100 @@ function Import() {
</Text>
<div>
{configs.map((config) => (
<Item
key={config.title}
title={config.title}
subtitle={config.subtitle}
image={config.icon}
actions={config.action}
border={false}
/>
))}
<Item
border={false}
image={<MarkdownIcon size={28} />}
title={t("Markdown")}
subtitle={t(
"Import a zip file of Markdown documents (exported from version 0.67.0 or earlier)"
)}
actions={
<Button
type="submit"
onClick={() => {
dialogs.openModal({
title: t("Import data"),
content: <ImportMarkdownDialog />,
});
}}
neutral
>
{t("Import")}
</Button>
}
/>
<Item
border={false}
image={<OutlineIcon size={28} cover />}
title="JSON"
subtitle={t(
"Import a JSON data file exported from another {{ appName }} instance",
{
appName,
}
)}
actions={
<Button
type="submit"
onClick={() => {
dialogs.openModal({
title: t("Import data"),
content: <ImportJSONDialog />,
});
}}
neutral
>
{t("Import")}
</Button>
}
/>
<Item
border={false}
image={<img src={cdnPath("/images/notion.png")} width={28} />}
title="Notion"
subtitle={t("Import pages exported from Notion")}
actions={
<Button
type="submit"
onClick={() => {
dialogs.openModal({
title: t("Import data"),
content: <ImportNotionDialog />,
});
}}
neutral
>
{t("Import")}
</Button>
}
/>
<Item
border={false}
image={<img src={cdnPath("/images/confluence.png")} width={28} />}
title="Confluence"
subtitle={t("Import pages from a Confluence instance")}
actions={
<Button type="submit" disabled neutral>
{t("Enterprise")}
</Button>
}
/>
</div>
<br />
<PaginatedList
items={allImports}
fetch={fetchImports}
items={fileOperations.imports}
fetch={fileOperations.fetchPage}
options={{
type: FileOperationType.Import,
}}
heading={
<h2>
<Trans>Recent imports</Trans>
</h2>
}
renderItem={(item: ImportModel | FileOperation) =>
item instanceof ImportModel ? (
<ImportListItem key={item.id} importModel={item} />
) : (
<FileOperationListItem key={item.id} fileOperation={item} />
)
}
renderItem={(item: FileOperation) => (
<FileOperationListItem key={item.id} fileOperation={item} />
)}
/>
</Scene>
);
+1 -1
View File
@@ -194,7 +194,7 @@ function getFilteredUsers({
switch (filter) {
case "all":
filteredUsers = users.all;
filteredUsers = users.orderedData;
break;
case "suspended":
filteredUsers = users.suspended;
+19 -49
View File
@@ -3,12 +3,12 @@ import { SettingsIcon } from "outline-icons";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import { languageOptions as availableLanguages } from "@shared/i18n";
import { languageOptions } from "@shared/i18n";
import { TeamPreference, UserPreference } from "@shared/types";
import { Theme } from "~/stores/UiStore";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import { InputSelectNew, Option } from "~/components/InputSelectNew";
import InputSelect from "~/components/InputSelect";
import Scene from "~/components/Scene";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
@@ -26,29 +26,6 @@ function Preferences() {
const team = useCurrentTeam();
const can = usePolicy(user.id);
const languageOptions: Option[] = React.useMemo(
() =>
availableLanguages.map(
(lang) =>
({
type: "item",
label: lang.label,
value: lang.value,
} satisfies Option)
),
[]
);
const themeOptions: Option[] = React.useMemo(
() =>
[
{ type: "item", label: t("Light"), value: Theme.Light },
{ type: "item", label: t("Dark"), value: Theme.Dark },
{ type: "item", label: t("System"), value: Theme.System },
] satisfies Option[],
[t]
);
const handlePreferenceChange =
(inverted = false) =>
async (ev: React.ChangeEvent<HTMLInputElement>) => {
@@ -60,21 +37,10 @@ function Preferences() {
toast.success(t("Preferences saved"));
};
const handleLanguageChange = React.useCallback(
async (language: string) => {
await user.save({ language });
toast.success(t("Preferences saved"));
},
[t, user]
);
const handleThemeChange = React.useCallback(
(theme) => {
ui.setTheme(theme as Theme);
toast.success(t("Preferences saved"));
},
[t, ui]
);
const handleLanguageChange = async (language: string) => {
await user.save({ language });
toast.success(t("Preferences saved"));
};
const showDeleteAccount = () => {
dialogs.openModal({
@@ -111,13 +77,12 @@ function Preferences() {
</>
}
>
<InputSelectNew
<InputSelect
id="language"
options={languageOptions}
value={user.language}
onChange={handleLanguageChange}
ariaLabel={t("Language")}
label={t("Language")}
hideLabel
/>
</SettingRow>
<SettingRow
@@ -125,13 +90,18 @@ function Preferences() {
label={t("Appearance")}
description={t("Choose your preferred interface color scheme.")}
>
<InputSelectNew
options={themeOptions}
value={ui.theme}
onChange={handleThemeChange}
<InputSelect
ariaLabel={t("Appearance")}
label={t("Appearance")}
hideLabel
options={[
{ label: t("Light"), value: Theme.Light },
{ label: t("Dark"), value: Theme.Dark },
{ label: t("System"), value: Theme.System },
]}
value={ui.resolvedTheme}
onChange={(theme) => {
ui.setTheme(theme as Theme);
toast.success(t("Preferences saved"));
}}
/>
</SettingRow>
<SettingRow
+13 -22
View File
@@ -10,7 +10,7 @@ import { TeamPreference } from "@shared/types";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Flex from "~/components/Flex";
import Heading from "~/components/Heading";
import { InputSelectNew, Option } from "~/components/InputSelectNew";
import InputSelect from "~/components/InputSelect";
import PluginIcon from "~/components/PluginIcon";
import Scene from "~/components/Scene";
import Switch from "~/components/Switch";
@@ -44,23 +44,6 @@ function Security() {
request,
} = useRequest(authenticationProviders.fetchPage);
const userRoleOptions: Option[] = React.useMemo(
() =>
[
{
type: "item",
label: t("Editor"),
value: "member",
},
{
type: "item",
label: t("Viewer"),
value: "viewer",
},
] satisfies Option[],
[t]
);
React.useEffect(() => {
if (!providers && !loading) {
void request();
@@ -246,13 +229,21 @@ function Security() {
)}
border={false}
>
<InputSelectNew
<InputSelect
id="defaultUserRole"
value={data.defaultUserRole}
options={userRoleOptions}
options={[
{
label: t("Editor"),
value: "member",
},
{
label: t("Viewer"),
value: "viewer",
},
]}
onChange={handleDefaultRoleChange}
ariaLabel={t("Default role")}
label={t("Default role")}
hideLabel
short
/>
</SettingRow>
@@ -1,159 +0,0 @@
import capitalize from "lodash/capitalize";
import { observer } from "mobx-react";
import { CrossIcon, DoneIcon, WarningIcon } from "outline-icons";
import React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useTheme } from "styled-components";
import { ImportState } from "@shared/types";
import Import from "~/models/Import";
import { Action } from "~/components/Actions";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import ListItem from "~/components/List/Item";
import Spinner from "~/components/Spinner";
import Time from "~/components/Time";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { ImportMenu } from "~/menus/ImportMenu";
import isCloudHosted from "~/utils/isCloudHosted";
type Props = {
/** Import that's displayed as list item. */
importModel: Import;
};
export const ImportListItem = observer(({ importModel }: Props) => {
const { t } = useTranslation();
const { dialogs } = useStores();
const user = useCurrentUser();
const theme = useTheme();
const showProgress =
importModel.state !== ImportState.Canceled &&
importModel.state !== ImportState.Errored;
const showErrorInfo =
!isCloudHosted &&
importModel.state === ImportState.Errored &&
!!importModel.error;
const stateMap = React.useMemo(
() => ({
[ImportState.Created]: t("Processing"),
[ImportState.InProgress]: t("Processing"),
[ImportState.Processed]: t("Processing"),
[ImportState.Completed]: t("Completed"),
[ImportState.Errored]: t("Failed"),
[ImportState.Canceled]: t("Canceled"),
}),
[t]
);
const iconMap = React.useMemo(
() => ({
[ImportState.Created]: <Spinner />,
[ImportState.InProgress]: <Spinner />,
[ImportState.Processed]: <Spinner />,
[ImportState.Completed]: <DoneIcon color={theme.accent} />,
[ImportState.Errored]: <WarningIcon color={theme.danger} />,
[ImportState.Canceled]: <CrossIcon color={theme.textTertiary} />,
}),
[theme]
);
const handleCancel = React.useCallback(async () => {
const onCancel = async () => {
try {
await importModel.cancel();
toast.success(t("Import canceled"));
} catch (err) {
toast.error(err.message);
}
};
dialogs.openModal({
title: t("Are you sure you want to cancel this import?"),
content: (
<ConfirmationDialog
onSubmit={onCancel}
submitText={t("Cancel")}
savingText={`${t("Canceling")}`}
danger
>
{t(
"Canceling this import will discard any progress made. This cannot be undone."
)}
</ConfirmationDialog>
),
});
}, [t, dialogs, importModel]);
const handleDelete = React.useCallback(async () => {
const onDelete = async () => {
try {
await importModel.delete();
toast.success(t("Import deleted"));
} catch (err) {
toast.error(err.message);
}
};
dialogs.openModal({
title: t("Are you sure you want to delete this import?"),
content: (
<ConfirmationDialog
onSubmit={onDelete}
savingText={`${t("Deleting")}`}
danger
>
{t(
"Deleting this import will also delete all collections and documents that were created from it. This cannot be undone."
)}
</ConfirmationDialog>
),
});
}, [t, dialogs, importModel]);
return (
<ListItem
title={importModel.name}
image={iconMap[importModel.state]}
subtitle={
<>
{stateMap[importModel.state]}&nbsp;&nbsp;
{showErrorInfo && (
<>
{importModel.error}
{`. ${t("Check server logs for more details.")}`}&nbsp;&nbsp;
</>
)}
{t(`{{userName}} requested`, {
userName:
user.id === importModel.createdBy.id
? t("You")
: importModel.createdBy.name,
})}
&nbsp;
<Time dateTime={importModel.createdAt} addSuffix shorten />
&nbsp;&nbsp;
{capitalize(importModel.service)}
{showProgress && (
<>
&nbsp;&nbsp;
{t("{{ count }} document imported", {
count: importModel.documentCount,
})}
</>
)}
</>
}
actions={
<Action>
<ImportMenu
importModel={importModel}
onCancel={handleCancel}
onDelete={handleDelete}
/>
</Action>
}
/>
);
});
-4
View File
@@ -8,7 +8,6 @@ type DialogDefinition = {
isOpen: boolean;
fullscreen?: boolean;
style?: React.CSSProperties;
onClose?: () => void;
};
export default class DialogsStore {
@@ -51,7 +50,6 @@ export default class DialogsStore {
fullscreen,
replace,
style,
onClose,
}: {
id?: string;
title: string;
@@ -59,7 +57,6 @@ export default class DialogsStore {
content: React.ReactNode;
style?: React.CSSProperties;
replace?: boolean;
onClose?: () => void;
}) => {
setTimeout(
action(() => {
@@ -73,7 +70,6 @@ export default class DialogsStore {
fullscreen,
style,
isOpen: true,
onClose,
});
}),
0
-25
View File
@@ -1,25 +0,0 @@
import invariant from "invariant";
import { action, runInAction } from "mobx";
import Import from "~/models/Import";
import { client } from "~/utils/ApiClient";
import RootStore from "./RootStore";
import Store from "./base/Store";
export default class ImportsStore extends Store<Import> {
constructor(rootStore: RootStore) {
super(rootStore, Import);
}
@action
cancel = async (importModel: Import) => {
const res = await client.post("/imports.cancel", {
id: importModel.id,
});
runInAction("Import#cancel", () => {
invariant(res?.data, "Data should be available");
importModel.updateData(res.data);
this.addPolicies(res.policies);
});
};
}
-3
View File
@@ -14,7 +14,6 @@ import FileOperationsStore from "./FileOperationsStore";
import GroupMembershipsStore from "./GroupMembershipsStore";
import GroupUsersStore from "./GroupUsersStore";
import GroupsStore from "./GroupsStore";
import ImportsStore from "./ImportsStore";
import IntegrationsStore from "./IntegrationsStore";
import MembershipsStore from "./MembershipsStore";
import NotificationsStore from "./NotificationsStore";
@@ -44,7 +43,6 @@ export default class RootStore {
events: EventsStore;
groups: GroupsStore;
groupUsers: GroupUsersStore;
imports: ImportsStore;
integrations: IntegrationsStore;
memberships: MembershipsStore;
notifications: NotificationsStore;
@@ -74,7 +72,6 @@ export default class RootStore {
this.registerStore(EventsStore);
this.registerStore(GroupsStore);
this.registerStore(GroupUsersStore);
this.registerStore(ImportsStore);
this.registerStore(IntegrationsStore);
this.registerStore(MembershipsStore);
this.registerStore(NotificationsStore);
+10 -8
View File
@@ -27,44 +27,46 @@ export default class UsersStore extends Store<User> {
@computed
get active(): User[] {
return this.all.filter((user) => !user.isSuspended && !user.isInvited);
return this.orderedData.filter(
(user) => !user.isSuspended && user.lastActiveAt
);
}
@computed
get suspended(): User[] {
return this.all.filter((user) => user.isSuspended);
return this.orderedData.filter((user) => user.isSuspended);
}
@computed
get activeOrInvited(): User[] {
return this.all.filter((user) => !user.isSuspended);
return this.orderedData.filter((user) => !user.isSuspended);
}
@computed
get invited(): User[] {
return this.all.filter((user) => user.isInvited);
return this.orderedData.filter((user) => user.isInvited);
}
@computed
get admins(): User[] {
return this.all.filter((user) => user.isAdmin && !user.isInvited);
return this.orderedData.filter((user) => user.isAdmin);
}
@computed
get members(): User[] {
return this.all.filter(
return this.orderedData.filter(
(user) => !user.isViewer && !user.isAdmin && !user.isInvited
);
}
@computed
get viewers(): User[] {
return this.all.filter((user) => user.isViewer && !user.isInvited);
return this.orderedData.filter((user) => user.isViewer);
}
@computed
get all(): User[] {
return this.orderedData.filter((user) => !user.isDeleted);
return this.orderedData.filter((user) => user.lastActiveAt);
}
@computed
+1 -5
View File
@@ -1,7 +1,6 @@
import commandScore from "command-score";
import invariant from "invariant";
import type { ObjectIterateeCustom } from "lodash";
import deburr from "lodash/deburr";
import { deburr, type ObjectIterateeCustom } from "lodash";
import filter from "lodash/filter";
import find from "lodash/find";
import flatten from "lodash/flatten";
@@ -103,9 +102,6 @@ export default abstract class Store<T extends Model> {
return this.orderedData
.filter((item: T & Searchable) => {
if ("deletedAt" in item && item.deletedAt) {
return false;
}
if ("searchContent" in item) {
const seachables =
typeof item.searchContent === "string"
-5
View File
@@ -17,7 +17,6 @@ import {
RateLimitExceededError,
RequestError,
ServiceUnavailableError,
UnprocessableEntityError,
UpdateRequiredError,
} from "./errors";
@@ -215,10 +214,6 @@ class ApiClient {
throw new ServiceUnavailableError(error.message);
}
if (response.status === 422) {
throw new UnprocessableEntityError(error.message);
}
if (response.status === 429) {
throw new RateLimitExceededError(
`Too many requests, try again in a minute.`
-11
View File
@@ -12,7 +12,6 @@ import isCloudHosted from "./isCloudHosted";
*/
export enum Hook {
Settings = "settings",
Imports = "imports",
Icon = "icon",
}
@@ -32,16 +31,6 @@ type PluginValueMap = {
/** Whether the plugin is enabled in the current context. */
enabled?: (team: Team, user: User) => boolean;
};
[Hook.Imports]: {
/** The title of the import. */
title: string;
/** The auxiliary descriptive text of the import. */
subtitle: string;
/** An icon to denote the kind of import. */
icon: React.ReactElement;
/** Trigger for the import. */
action: React.ReactElement;
};
[Hook.Icon]: React.ElementType;
};
-2
View File
@@ -16,8 +16,6 @@ export class ServiceUnavailableError extends ExtendableError {}
export class BadGatewayError extends ExtendableError {}
export class UnprocessableEntityError extends ExtendableError {}
export class RateLimitExceededError extends ExtendableError {}
export class RequestError extends ExtendableError {}
+15 -18
View File
@@ -111,26 +111,23 @@ export function newNestedDocumentPath(parentDocumentId?: string): string {
return `/doc/new?${queryString.stringify({ parentDocumentId })}`;
}
export function searchPath({
query,
collectionId,
documentId,
ref,
}: {
query?: string;
collectionId?: string;
documentId?: string;
ref?: string;
} = {}): string {
let search = queryString.stringify({
q: query,
collectionId,
documentId,
ref,
});
export function searchPath(
query?: string,
params: {
collectionId?: string;
documentId?: string;
ref?: string;
} = {}
): string {
let search = queryString.stringify(params);
let route = "/search";
if (query) {
route += `/${encodeURIComponent(query.replace(/%/g, "%25"))}`;
}
search = search ? `?${search}` : "";
return `/search${search}`;
return `${route}${search}`;
}
export function sharedDocumentPath(shareId: string, docPath?: string) {
+26 -33
View File
@@ -48,21 +48,21 @@
"> 0.25%, not dead"
],
"dependencies": {
"@aws-sdk/client-s3": "3.774.0",
"@aws-sdk/lib-storage": "3.774.0",
"@aws-sdk/s3-presigned-post": "3.774.0",
"@aws-sdk/s3-request-presigner": "3.774.0",
"@aws-sdk/signature-v4-crt": "^3.774.0",
"@babel/core": "^7.26.10",
"@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",
"@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.27.0",
"@babel/plugin-transform-regenerator": "^7.25.9",
"@babel/preset-env": "^7.26.9",
"@babel/preset-react": "^7.26.3",
"@benrbray/prosemirror-math": "^0.2.2",
"@bull-board/api": "^6.7.10",
"@bull-board/koa": "^6.7.10",
"@bull-board/api": "^4.2.2",
"@bull-board/koa": "^4.12.2",
"@css-inline/css-inline-wasm": "^0.14.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^6.0.1",
@@ -79,24 +79,20 @@
"@hocuspocus/server": "1.1.2",
"@joplin/turndown-plugin-gfm": "^1.0.49",
"@juggle/resize-observer": "^3.4.0",
"@notionhq/client": "^2.2.16",
"@octokit/auth-app": "^6.1.3",
"@outlinewiki/koa-passport": "^4.2.1",
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-visually-hidden": "^1.1.2",
"@renderlesskit/react": "^0.11.0",
"@sentry/node": "^7.120.3",
"@sentry/react": "^7.120.3",
"@tanstack/react-table": "^8.20.6",
"@tanstack/react-virtual": "^3.11.3",
"@tippyjs/react": "^4.2.6",
"@types/form-data": "^2.5.2",
"@types/form-data": "^2.5.0",
"@types/mailparser": "^3.4.5",
"@types/sanitize-filename": "^1.6.3",
"@vitejs/plugin-react": "^3.1.0",
"addressparser": "^1.0.1",
"async-sema": "^3.1.1",
"autotrack": "^2.4.1",
"babel-plugin-styled-components": "^2.1.4",
"babel-plugin-transform-class-properties": "^6.24.1",
@@ -110,9 +106,9 @@
"copy-to-clipboard": "^3.3.3",
"core-js": "^3.37.0",
"crypto-js": "^4.2.0",
"datadog-metrics": "^0.12.1",
"datadog-metrics": "^0.11.2",
"date-fns": "^3.6.0",
"dd-trace": "^5.40.0",
"dd-trace": "^5.41.0",
"diff": "^5.2.0",
"dotenv": "^16.4.7",
"email-providers": "^1.14.0",
@@ -129,12 +125,11 @@
"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.6.0",
"ioredis": "^5.4.1",
"is-printable-key-event": "^1.0.0",
"jsdom": "^22.1.0",
"jsonwebtoken": "^9.0.0",
@@ -173,13 +168,13 @@
"passport-oauth2": "^1.8.0",
"passport-slack-oauth2": "^1.2.0",
"patch-package": "^7.0.2",
"pg": "^8.14.1",
"pg": "^8.12.0",
"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.7.0",
"prosemirror-commands": "^1.6.2",
"prosemirror-dropcursor": "^1.8.1",
"prosemirror-gapcursor": "^1.3.2",
"prosemirror-history": "^1.4.1",
@@ -189,9 +184,9 @@
"prosemirror-model": "^1.24.0",
"prosemirror-schema-list": "^1.4.1",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.6.4",
"prosemirror-tables": "^1.4.0",
"prosemirror-transform": "1.10.0",
"prosemirror-view": "^1.38.1",
"prosemirror-view": "^1.37.1",
"query-string": "^7.1.3",
"randomstring": "1.3.1",
"rate-limiter-flexible": "^2.4.2",
@@ -247,8 +242,7 @@
"utility-types": "^3.11.0",
"uuid": "^8.3.2",
"validator": "13.12.0",
"vaul": "^1.1.2",
"vite": "^5.4.15",
"vite": "^5.4.14",
"vite-plugin-pwa": "^0.20.3",
"winston": "^3.17.0",
"ws": "^7.5.10",
@@ -257,11 +251,11 @@
"y-protocols": "^1.0.6",
"yauzl": "^2.10.0",
"yjs": "^13.6.1",
"zod": "^3.24.2"
"zod": "^3.23.8"
},
"devDependencies": {
"@babel/cli": "^7.27.0",
"@babel/preset-typescript": "^7.27.0",
"@babel/cli": "^7.26.4",
"@babel/preset-typescript": "^7.26.0",
"@faker-js/faker": "^8.4.1",
"@relative-ci/agent": "^4.2.14",
"@testing-library/react": "^12.0.0",
@@ -296,7 +290,7 @@
"@types/markdown-it-emoji": "^2.0.4",
"@types/mime-types": "^2.1.4",
"@types/natural-sort": "^0.0.24",
"@types/node": "20.17.27",
"@types/node": "20.17.16",
"@types/node-fetch": "^2.6.9",
"@types/nodemailer": "^6.4.17",
"@types/passport-oauth2": "^1.4.17",
@@ -337,7 +331,7 @@
"babel-plugin-tsconfig-paths-module-resolver": "^1.0.4",
"browserslist-to-esbuild": "^1.2.0",
"concurrently": "^8.2.2",
"discord-api-types": "^0.37.119",
"discord-api-types": "^0.37.102",
"eslint": "^8.57.0",
"eslint-config-prettier": "^8.10.0",
"eslint-import-resolver-typescript": "^3.8.0",
@@ -360,8 +354,8 @@
"prettier": "^2.8.8",
"react-refresh": "^0.14.2",
"rimraf": "^2.5.4",
"rollup-plugin-webpack-stats": "^2.0.3",
"terser": "^5.39.0",
"rollup-plugin-webpack-stats": "^2.0.1",
"terser": "^5.37.0",
"typescript": "^5.7.3",
"vite-plugin-static-copy": "^0.17.0",
"yarn-deduplicate": "^6.0.2"
@@ -374,8 +368,7 @@
"node-fetch": "^2.7.0",
"js-yaml": "^3.14.1",
"qs": "6.9.7",
"rollup": "^4.5.1",
"prismjs": "1.30.0"
"rollup": "^4.5.1"
},
"version": "0.82.0"
}
+1 -1
View File
@@ -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.SMTP_SERVICE) || env.isDevelopment;
const enabled = !!env.SMTP_HOST || env.isDevelopment;
if (enabled) {
PluginManager.add({
-93
View File
@@ -1,93 +0,0 @@
import { observer } from "mobx-react";
import React from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useLocation } from "react-router-dom";
import { toast } from "sonner";
import env from "@shared/env";
import { IntegrationService } from "@shared/types";
import Button from "~/components/Button";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import { redirectTo } from "~/utils/urls";
import { NotionUtils } from "../shared/NotionUtils";
import { ImportDialog } from "./components/ImportDialog";
export const Notion = observer(() => {
const { t } = useTranslation();
const { dialogs } = useStores();
const team = useCurrentTeam();
const history = useHistory();
const location = useLocation();
const queryParams = useQuery();
const appName = env.APP_NAME;
const authUrl = NotionUtils.authUrl({ state: { teamId: team.id } });
const service = queryParams.get("service");
const oauthSuccess = queryParams.get("success") === "";
const oauthError = queryParams.get("error");
const integrationId = queryParams.get("integrationId");
const clearQueryParams = React.useCallback(() => {
history.replace({
pathname: location.pathname,
search: "",
});
}, [history, location]);
const handleSubmit = React.useCallback(() => {
dialogs.closeAllModals();
clearQueryParams();
}, [dialogs, clearQueryParams]);
React.useEffect(() => {
if (
integrationId &&
oauthSuccess &&
service === IntegrationService.Notion
) {
dialogs.openModal({
title: t("Import data"),
content: (
<ImportDialog integrationId={integrationId} onSubmit={handleSubmit} />
),
onClose: clearQueryParams,
});
}
}, [t, dialogs, oauthSuccess, service, clearQueryParams]);
React.useEffect(() => {
if (!oauthError) {
return;
}
if (oauthError === "access_denied") {
toast.error(
t(
"Whoops, you need to accept the permissions in Notion to connect {{ appName }} to your workspace. Try again?",
{
appName,
}
)
);
} else {
toast.error(
t(
"Something went wrong while authenticating your request. Please try logging in again."
)
);
}
}, [t, appName, oauthError]);
return (
<Button
type="submit"
onClick={() => redirectTo(authUrl)}
disabled={!env.NOTION_CLIENT_ID}
neutral
>
{t("Import")}
</Button>
);
});
@@ -1,78 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { ImportInput } from "@shared/schema";
import { CollectionPermission, IntegrationService } from "@shared/types";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import InputSelectPermission from "~/components/InputSelectPermission";
import Text from "~/components/Text";
import useBoolean from "~/hooks/useBoolean";
import useStores from "~/hooks/useStores";
import { EmptySelectValue } from "~/types";
type Props = {
/** The integrationId associated with this import flow. */
integrationId: string;
/** Callback to handle import creation. */
onSubmit: () => void;
};
export function ImportDialog({ integrationId, onSubmit }: Props) {
const { t } = useTranslation();
const { imports } = useStores();
const [submitting, setSubmitting, resetSubmitting] = useBoolean();
const [permission, setPermission] = React.useState<CollectionPermission>();
const handlePermissionChange = React.useCallback(
(value: CollectionPermission | typeof EmptySelectValue) => {
setPermission(value === EmptySelectValue ? undefined : value);
},
[]
);
const handleStartImport = React.useCallback(async () => {
setSubmitting();
// TODO: This can send the page info + permission once we overcome the search timeout issues.
const input: ImportInput<IntegrationService.Notion> = [{ permission }];
try {
await imports.create(
{ service: IntegrationService.Notion },
{ integrationId, input }
);
toast.success(
t("Your import is being processed, you can safely leave this page")
);
onSubmit();
} catch (err) {
toast.error(err.message);
resetSubmitting();
}
}, [permission, onSubmit]);
return (
<Flex column gap={12}>
<div>
<InputSelectPermission
value={permission}
onChange={handlePermissionChange}
/>
<Text as="span" type="secondary">
{t(
"Set the default permission level for collections created from the import"
)}
.
</Text>
</div>
<Flex justify="flex-end">
<Button onClick={handleStartImport} disabled={submitting}>
{t("Start import")}
</Button>
</Flex>
</Flex>
);
}
-19
View File
@@ -1,19 +0,0 @@
import { t } from "i18next";
import * as React from "react";
import { cdnPath } from "@shared/utils/urls";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import { Notion } from "./Imports";
PluginManager.add([
{
...config,
type: Hook.Imports,
value: {
title: "Notion",
subtitle: t("Import pages from Notion"),
icon: <img src={cdnPath("/images/notion.png")} width={28} />,
action: <Notion />,
},
},
]);
-5
View File
@@ -1,5 +0,0 @@
{
"id": "notion",
"name": "Notion",
"description": "Adds a Notion integration for importing data."
}
-105
View File
@@ -1,105 +0,0 @@
import Router from "koa-router";
import { IntegrationService, IntegrationType } from "@shared/types";
import { parseDomain } from "@shared/utils/domains";
import Logger from "@server/logging/Logger";
import auth from "@server/middlewares/authentication";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate";
import { Integration, IntegrationAuthentication, Team } from "@server/models";
import { APIContext } from "@server/types";
import { NotionClient } from "../notion";
import * as T from "./schema";
import { NotionUtils } from "plugins/notion/shared/NotionUtils";
const router = new Router();
router.get(
"notion.callback",
auth({ optional: true }),
validate(T.NotionCallbackSchema),
transaction(),
async (ctx: APIContext<T.NotionCallbackReq>) => {
const { code, state, error } = ctx.input.query;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
let parsedState;
try {
parsedState = NotionUtils.parseState(state);
} catch {
ctx.redirect(NotionUtils.errorUrl("invalid_state"));
return;
}
const { teamId } = parsedState;
// This code block accounts for the root domain being unable to access authentication for subdomains.
// We must forward to the appropriate subdomain to complete the oauth flow.
if (!user) {
if (teamId) {
try {
const team = await Team.findByPk(teamId, {
rejectOnEmpty: true,
transaction,
});
return parseDomain(ctx.host).teamSubdomain === team.subdomain
? ctx.redirect("/")
: ctx.redirectOnClient(
NotionUtils.callbackUrl({
baseUrl: team.url,
params: ctx.request.querystring,
})
);
} catch (err) {
Logger.error(`Error fetching team for teamId: ${teamId}!`, err);
return ctx.redirect(NotionUtils.errorUrl("unauthenticated"));
}
} else {
return ctx.redirect(NotionUtils.errorUrl("unauthenticated"));
}
}
// Check error after any sub-domain redirection. Otherwise, the user will be redirected to the root domain.
if (error) {
ctx.redirect(NotionUtils.errorUrl(error));
return;
}
// validation middleware ensures that code is non-null at this point.
const data = await NotionClient.oauthAccess(code!);
const authentication = await IntegrationAuthentication.create(
{
service: IntegrationService.Notion,
userId: user.id,
teamId: user.teamId,
token: data.access_token,
},
{ transaction }
);
const integration = await Integration.create<
Integration<IntegrationType.Import>
>(
{
service: IntegrationService.Notion,
type: IntegrationType.Import,
userId: user.id,
teamId: user.teamId,
authenticationId: authentication.id,
settings: {
externalWorkspace: {
id: data.workspace_id,
name: data.workspace_name ?? "Notion import",
iconUrl: data.workspace_icon ?? undefined,
},
},
},
{ transaction }
);
ctx.redirect(NotionUtils.successUrl(integration.id));
}
);
export default router;
-25
View File
@@ -1,25 +0,0 @@
import isEmpty from "lodash/isEmpty";
import { z } from "zod";
import { BaseSchema } from "@server/routes/api/schema";
export const NotionCallbackSchema = BaseSchema.extend({
query: z
.object({
code: z.string().nullish(),
state: z.string(),
error: z.string().nullish(),
})
.refine((req) => !(isEmpty(req.code) && isEmpty(req.error)), {
message: "one of code or error is required",
}),
});
export type NotionCallbackReq = z.infer<typeof NotionCallbackSchema>;
export const NotionSearchSchema = BaseSchema.extend({
body: z.object({
integrationId: z.string().uuid(),
}),
});
export type NotionSearchReq = z.infer<typeof NotionSearchSchema>;
-19
View File
@@ -1,19 +0,0 @@
import { IsOptional } from "class-validator";
import { Environment } from "@server/env";
import { Public } from "@server/utils/decorators/Public";
import environment from "@server/utils/environment";
import { CannotUseWithout } from "@server/utils/validators";
class NotionPluginEnvironment extends Environment {
@Public
@IsOptional()
public NOTION_CLIENT_ID = this.toOptionalString(environment.NOTION_CLIENT_ID);
@IsOptional()
@CannotUseWithout("NOTION_CLIENT_ID")
public NOTION_CLIENT_SECRET = this.toOptionalString(
environment.NOTION_CLIENT_SECRET
);
}
export default new NotionPluginEnvironment();
-26
View File
@@ -1,26 +0,0 @@
import { Hook, PluginManager } from "@server/utils/PluginManager";
import config from "../plugin.json";
import router from "./api/notion";
import env from "./env";
import { NotionImportsProcessor } from "./processors/NotionImportsProcessor";
import NotionAPIImportTask from "./tasks/NotionAPIImportTask";
const enabled = !!env.NOTION_CLIENT_ID && !!env.NOTION_CLIENT_SECRET;
if (enabled) {
PluginManager.add([
{
...config,
type: Hook.API,
value: router,
},
{
type: Hook.Processor,
value: NotionImportsProcessor,
},
{
type: Hook.Task,
value: NotionAPIImportTask,
},
]);
}
-297
View File
@@ -1,297 +0,0 @@
import {
APIErrorCode,
APIResponseError,
Client,
isFullPage,
isFullPageOrDatabase,
isFullUser,
} from "@notionhq/client";
import {
BlockObjectResponse,
DatabaseObjectResponse,
PageObjectResponse,
RichTextItemResponse,
} from "@notionhq/client/build/src/api-endpoints";
import { RateLimit } from "async-sema";
import compact from "lodash/compact";
import { z } from "zod";
import { Second } from "@shared/utils/time";
import { NotionUtils } from "../shared/NotionUtils";
import { Block, Page, PageType } from "../shared/types";
import env from "./env";
type PageInfo = {
title: string;
emoji?: string;
author?: string;
createdAt?: Date;
updatedAt?: Date;
};
const Credentials = Buffer.from(
`${env.NOTION_CLIENT_ID}:${env.NOTION_CLIENT_SECRET}`
).toString("base64");
const AccessTokenResponseSchema = z.object({
access_token: z.string(),
bot_id: z.string(),
workspace_id: z.string(),
workspace_name: z.string().nullish(),
workspace_icon: z.string().url().nullish(),
});
export class NotionClient {
private client: Client;
private limiter: ReturnType<typeof RateLimit>;
private pageSize = 25;
constructor(
accessToken: string,
rateLimit: { window: number; limit: number } = {
window: Second.ms,
limit: 3,
}
) {
this.client = new Client({
auth: accessToken,
});
this.limiter = RateLimit(rateLimit.limit, {
timeUnit: rateLimit.window,
uniformDistribution: true,
});
}
static async oauthAccess(code: string) {
const headers = {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: `Basic ${Credentials}`,
};
const body = {
grant_type: "authorization_code",
code,
redirect_uri: NotionUtils.callbackUrl(),
};
const res = await fetch(NotionUtils.tokenUrl, {
method: "POST",
headers,
body: JSON.stringify(body),
});
return AccessTokenResponseSchema.parse(await res.json());
}
async fetchRootPages() {
const pages: Page[] = [];
let cursor: string | undefined;
let hasMore = true;
while (hasMore) {
await this.limiter();
const response = await this.client.search({
start_cursor: cursor,
page_size: this.pageSize,
});
response.results.forEach((item) => {
if (!isFullPageOrDatabase(item)) {
return;
}
if (item.parent.type === "workspace") {
pages.push({
type: item.object === "page" ? PageType.Page : PageType.Database,
id: item.id,
name: this.parseTitle(item),
emoji: this.parseEmoji(item),
});
}
});
hasMore = response.has_more;
cursor = response.next_cursor ?? undefined;
}
return pages;
}
async fetchPage(pageId: string) {
const pageInfo = await this.fetchPageInfo(pageId);
const blocks = await this.fetchBlockChildren(pageId);
return { ...pageInfo, blocks };
}
async fetchDatabase(databaseId: string) {
const databaseInfo = await this.fetchDatabaseInfo(databaseId);
const pages = await this.queryDatabase(databaseId);
return { ...databaseInfo, pages };
}
private async fetchBlockChildren(blockId: string) {
const blocks: Block[] = [];
let cursor: string | undefined;
let hasMore = true;
while (hasMore) {
await this.limiter();
const response = await this.client.blocks.children.list({
block_id: blockId,
start_cursor: cursor,
page_size: this.pageSize,
});
blocks.push(...(response.results as BlockObjectResponse[]));
hasMore = response.has_more;
cursor = response.next_cursor ?? undefined;
}
// Recursive fetch when direct children have their own children.
await Promise.all(
blocks.map(async (block) => {
if (
block.has_children &&
block.type !== "child_page" &&
block.type !== "child_database"
) {
block.children = await this.fetchBlockChildren(block.id);
}
})
);
return blocks;
}
private async queryDatabase(databaseId: string) {
const pages: Page[] = [];
let cursor: string | undefined;
let hasMore = true;
while (hasMore) {
await this.limiter();
const response = await this.client.databases.query({
database_id: databaseId,
filter_properties: ["title"],
start_cursor: cursor,
page_size: this.pageSize,
});
const pagesFromRes = compact(
response.results.map<Page | undefined>((item) => {
if (!isFullPage(item)) {
return;
}
return {
type: PageType.Page,
id: item.id,
name: this.parseTitle(item),
emoji: this.parseEmoji(item),
};
})
);
pages.push(...pagesFromRes);
hasMore = response.has_more;
cursor = response.next_cursor ?? undefined;
}
return pages;
}
private async fetchPageInfo(pageId: string): Promise<PageInfo> {
await this.limiter();
const page = (await this.client.pages.retrieve({
page_id: pageId,
})) as PageObjectResponse;
const author = await this.fetchUsername(page.created_by.id);
return {
title: this.parseTitle(page),
emoji: this.parseEmoji(page),
author: author ?? undefined,
createdAt: !page.created_time ? undefined : new Date(page.created_time),
updatedAt: !page.last_edited_time
? undefined
: new Date(page.last_edited_time),
};
}
private async fetchDatabaseInfo(databaseId: string): Promise<PageInfo> {
await this.limiter();
const database = (await this.client.databases.retrieve({
database_id: databaseId,
})) as DatabaseObjectResponse;
const author = await this.fetchUsername(database.created_by.id);
return {
title: this.parseTitle(database),
emoji: this.parseEmoji(database),
author: author ?? undefined,
createdAt: !database.created_time
? undefined
: new Date(database.created_time),
updatedAt: !database.last_edited_time
? undefined
: new Date(database.last_edited_time),
};
}
private async fetchUsername(userId: string) {
await this.limiter();
try {
const user = await this.client.users.retrieve({ user_id: userId });
if (user.type === "person" || !user.bot.owner) {
return user.name;
}
// bot belongs to a user, get the user's name.
if (user.bot.owner.type === "user" && isFullUser(user.bot.owner.user)) {
return user.bot.owner.user.name;
}
// bot belongs to a workspace, fallback to bot's name.
return user.name;
} catch (error) {
// Handle the case where a user can't be found
if (
error instanceof APIResponseError &&
error.code === APIErrorCode.ObjectNotFound
) {
return "Unknown";
}
throw error;
}
}
private parseTitle(item: PageObjectResponse | DatabaseObjectResponse) {
let richTexts: RichTextItemResponse[];
if (item.object === "page") {
const titleProp = Object.values(item.properties).find(
(property) => property.type === "title"
);
richTexts = titleProp?.title ?? [];
} else {
richTexts = item.title;
}
return richTexts.map((richText) => richText.plain_text).join("");
}
private parseEmoji(item: PageObjectResponse | DatabaseObjectResponse) {
// Other icon types return a url to download from, which we don't support.
return item.icon?.type === "emoji" ? item.icon.emoji : undefined;
}
}
@@ -1,71 +0,0 @@
import { Transaction } from "sequelize";
import { NotionImportInput, NotionImportTaskInput } from "@shared/schema";
import { IntegrationService } from "@shared/types";
import { Import, ImportTask, Integration } from "@server/models";
import ImportsProcessor from "@server/queues/processors/ImportsProcessor";
import { NotionClient } from "../notion";
import NotionAPIImportTask from "../tasks/NotionAPIImportTask";
export class NotionImportsProcessor extends ImportsProcessor<IntegrationService.Notion> {
/**
* Determine whether this is a "Notion" import.
*
* @param importModel Import model associated with the import.
* @returns boolean.
*/
protected canProcess(
importModel: Import<IntegrationService.Notion>
): boolean {
return importModel.service === IntegrationService.Notion;
}
/**
* Build task inputs which will be used for `NotionAPIImportTask`s.
*
* @param importInput Array of root externalId and associated info which were used to create the import.
* @returns `NotionImportTaskInput`.
*/
protected async buildTasksInput(
importModel: Import<IntegrationService.Notion>,
transaction: Transaction
): Promise<NotionImportTaskInput> {
const integration = await Integration.scope("withAuthentication").findByPk(
importModel.integrationId,
{ rejectOnEmpty: true }
);
const notion = new NotionClient(integration.authentication.token);
const rootPages = await notion.fetchRootPages();
// App will send the default permission in an array with single item.
const defaultPermission = importModel.input[0].permission;
// TODO: This update can be deleted when we receive the page info + permission from app.
const importInput: NotionImportInput = rootPages.map((page) => ({
type: page.type,
externalId: page.id,
permission: defaultPermission,
}));
importModel.input = importInput;
await importModel.save({ transaction });
return rootPages.map((page) => ({
type: page.type,
externalId: page.id,
}));
}
/**
* Schedule the first `NotionAPIImportTask` for the import.
*
* @param importTask ImportTask model associated with the `NotionAPIImportTask`.
* @returns Promise that resolves when the task is scheduled.
*/
protected async scheduleTask(
importTask: ImportTask<IntegrationService.Notion>
): Promise<void> {
await NotionAPIImportTask.schedule({ importTaskId: importTask.id });
}
}
@@ -1,144 +0,0 @@
import { ImportTaskInput, ImportTaskOutput } from "@shared/schema";
import { IntegrationService, ProsemirrorDoc } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { Integration } from "@server/models";
import ImportTask from "@server/models/ImportTask";
import APIImportTask, {
ProcessOutput,
} from "@server/queues/tasks/APIImportTask";
import { Block, PageType } from "../../shared/types";
import { NotionClient } from "../notion";
import { NotionConverter, NotionPage } from "../utils/NotionConverter";
type ChildPage = { type: PageType; externalId: string };
type ParsePageOutput = ImportTaskOutput[number] & {
collectionExternalId?: string;
children: ChildPage[];
};
export default class NotionAPIImportTask extends APIImportTask<IntegrationService.Notion> {
/**
* Process the Notion import task.
* This fetches data from Notion and converts it to task output.
*
* @param importTask ImportTask model to process.
* @returns Promise with output that resolves once processing has completed.
*/
protected async process(
importTask: ImportTask<IntegrationService.Notion>
): Promise<ProcessOutput<IntegrationService.Notion>> {
const integration = await Integration.scope("withAuthentication").findByPk(
importTask.import.integrationId,
{ rejectOnEmpty: true }
);
const client = new NotionClient(integration.authentication.token);
const parsedPages = await Promise.all(
importTask.input.map(async (item) => this.processPage({ item, client }))
);
const taskOutput: ImportTaskOutput = parsedPages.map((parsedPage) => ({
externalId: parsedPage.externalId,
title: parsedPage.title,
emoji: parsedPage.emoji,
content: parsedPage.content,
author: parsedPage.author,
createdAt: parsedPage.createdAt,
updatedAt: parsedPage.updatedAt,
}));
const childTasksInput: ImportTaskInput<IntegrationService.Notion> =
parsedPages.flatMap((parsedPage) =>
parsedPage.children.map((childPage) => ({
type: childPage.type,
externalId: childPage.externalId,
parentExternalId: parsedPage.externalId,
collectionExternalId: parsedPage.collectionExternalId,
}))
);
return { taskOutput, childTasksInput };
}
/**
* Schedule the next `NotionAPIImportTask`.
*
* @param importTask ImportTask model associated with the `NotionAPIImportTask`.
* @returns Promise that resolves when the task is scheduled.
*/
protected async scheduleNextTask(
importTask: ImportTask<IntegrationService.Notion>
) {
await NotionAPIImportTask.schedule({ importTaskId: importTask.id });
return;
}
/**
* Fetch page data from Notion and convert it to expected output.
*
* @param item Object containing data about a notion page (or) database.
* @param client Notion client.
* @returns Promise of parsed page output that resolves when the task is scheduled.
*/
private async processPage({
item,
client,
}: {
item: ImportTaskInput<IntegrationService.Notion>[number];
client: NotionClient;
}): Promise<ParsePageOutput> {
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
);
return {
...databaseInfo,
externalId: item.externalId,
content: ProsemirrorHelper.getEmptyDocument() as ProsemirrorDoc,
collectionExternalId,
children: pages.map((page) => ({
type: page.type,
externalId: page.id,
})),
};
}
const { blocks, ...pageInfo } = await client.fetchPage(item.externalId);
return {
...pageInfo,
externalId: item.externalId,
content: NotionConverter.page({ children: blocks } as NotionPage),
collectionExternalId,
children: this.parseChildPages(blocks),
};
}
/**
* Parse Notion page blocks to get its child pages and databases.
*
* @param pageBlocks Array of blocks representing the page's content.
* @returns Array containing child page and child database info.
*/
private parseChildPages(pageBlocks: Block[]): ChildPage[] {
const childPages: ChildPage[] = [];
pageBlocks.forEach((block) => {
if (block.type === "child_page") {
childPages.push({ type: PageType.Page, externalId: block.id });
} else if (block.type === "child_database") {
childPages.push({ type: PageType.Database, externalId: block.id });
} else if (block.children?.length) {
childPages.push(...this.parseChildPages(block.children));
}
});
return childPages;
}
}
@@ -1,25 +0,0 @@
import { Node } from "prosemirror-model";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import nodesWithEmptyTextNode from "@server/test/fixtures/notion-page-with-empty-text-nodes.json";
import allNodes from "@server/test/fixtures/notion-page.json";
import { NotionConverter, NotionPage } from "./NotionConverter";
describe("NotionConverter", () => {
it("converts a page", () => {
const response = NotionConverter.page({
children: allNodes,
} as NotionPage);
expect(response).toMatchSnapshot();
expect(ProsemirrorHelper.toProsemirror(response)).toBeInstanceOf(Node);
});
it("converts a page with empty text nodes", () => {
const response = NotionConverter.page({
children: nodesWithEmptyTextNode,
} as NotionPage);
expect(response).toMatchSnapshot();
expect(ProsemirrorHelper.toProsemirror(response)).toBeInstanceOf(Node);
});
});
@@ -1,587 +0,0 @@
import type {
BookmarkBlockObjectResponse,
BreadcrumbBlockObjectResponse,
BulletedListItemBlockObjectResponse,
DividerBlockObjectResponse,
Heading1BlockObjectResponse,
Heading2BlockObjectResponse,
Heading3BlockObjectResponse,
NumberedListItemBlockObjectResponse,
ParagraphBlockObjectResponse,
QuoteBlockObjectResponse,
RichTextItemResponse,
FileBlockObjectResponse,
PdfBlockObjectResponse,
ImageBlockObjectResponse,
EmbedBlockObjectResponse,
TableBlockObjectResponse,
ToDoBlockObjectResponse,
EquationBlockObjectResponse,
CodeBlockObjectResponse,
ToggleBlockObjectResponse,
PageObjectResponse,
VideoBlockObjectResponse,
CalloutBlockObjectResponse,
ColumnListBlockObjectResponse,
ColumnBlockObjectResponse,
LinkPreviewBlockObjectResponse,
SyncedBlockBlockObjectResponse,
LinkToPageBlockObjectResponse,
} from "@notionhq/client/build/src/api-endpoints";
import isArray from "lodash/isArray";
import { NoticeTypes } from "@shared/editor/nodes/Notice";
import { MentionType, ProsemirrorData, ProsemirrorDoc } from "@shared/types";
import Logger from "@server/logging/Logger";
import { Block } from "../../shared/types";
export type NotionPage = PageObjectResponse & {
children: Block[];
};
/** Convert Notion blocks to Outline data. */
export class NotionConverter {
/**
* Nodes which cannot contain block children in Outline, their children
* will be flattened into the parent.
*/
private static nodesWithoutBlockChildren = ["paragraph", "toggle"];
public static page(item: NotionPage): ProsemirrorDoc {
return {
type: "doc",
content: this.mapChildren(item),
};
}
private static mapChildren(item: Block | NotionPage) {
const mapChild = (
child: Block
): ProsemirrorData | ProsemirrorData[] | undefined => {
if (child.type === "child_page" || child.type === "child_database") {
return; // this will be created as a nested page, no need to handle/convert.
}
// @ts-expect-error Not all blocks have an interface
if (this[child.type]) {
// @ts-expect-error Not all blocks have an interface
const response = this[child.type](child);
if (
response &&
this.nodesWithoutBlockChildren.includes(response.type) &&
"children" in child
) {
return [response, ...this.mapChildren(child)];
}
return response;
}
Logger.warn("Encountered unknown Notion block", child);
return undefined;
};
let wrappingList;
const children = [] as ProsemirrorData[];
if (!item.children) {
return [];
}
for (const child of item.children) {
const mapped = mapChild(child);
if (!mapped) {
continue;
}
// Ensure lists are wrapped correctly we require a wrapping element
// whereas Notion does not
// TODO: Handle mixed list
if (child.type === "numbered_list_item") {
if (!wrappingList) {
wrappingList = {
type: "ordered_list",
content: [] as ProsemirrorData[],
};
}
wrappingList.content.push(...(isArray(mapped) ? mapped : [mapped]));
continue;
}
if (child.type === "bulleted_list_item") {
if (!wrappingList) {
wrappingList = {
type: "bullet_list",
content: [] as ProsemirrorData[],
};
}
wrappingList.content.push(...(isArray(mapped) ? mapped : [mapped]));
continue;
}
if (child.type === "to_do") {
if (!wrappingList) {
wrappingList = {
type: "checkbox_list",
content: [] as ProsemirrorData[],
};
}
wrappingList.content.push(...(isArray(mapped) ? mapped : [mapped]));
continue;
}
if (wrappingList) {
children.push(wrappingList);
wrappingList = undefined;
}
children.push(...(isArray(mapped) ? mapped : [mapped]));
}
if (wrappingList) {
children.push(wrappingList);
}
return children;
}
private static callout(item: Block<CalloutBlockObjectResponse>) {
const colorToNoticeType: Record<string, NoticeTypes> = {
default_background: NoticeTypes.Info,
blue_background: NoticeTypes.Info,
purple_background: NoticeTypes.Info,
green_background: NoticeTypes.Success,
orange_background: NoticeTypes.Tip,
yellow_background: NoticeTypes.Tip,
pink_background: NoticeTypes.Warning,
red_background: NoticeTypes.Warning,
};
return {
type: "container_notice",
attrs: {
style:
colorToNoticeType[item.callout.color as string] ?? NoticeTypes.Info,
},
content: [
{
type: "paragraph",
content: item.callout.rich_text.map(this.rich_text).filter(Boolean),
},
...this.mapChildren(item),
],
};
}
private static column_list(item: Block<ColumnListBlockObjectResponse>) {
return this.mapChildren(item);
}
private static column(item: Block<ColumnBlockObjectResponse>) {
return this.mapChildren(item);
}
private static bookmark(item: BookmarkBlockObjectResponse) {
const caption = item.bookmark.caption
.map(this.rich_text_to_plaintext)
.join("");
return {
type: "paragraph",
content: [
{
text: caption || item.bookmark.url,
type: "text",
marks: [
{
type: "link",
attrs: {
href: item.bookmark.url,
title: null,
},
},
],
},
],
};
}
private static breadcrumb(_: BreadcrumbBlockObjectResponse) {
return undefined;
}
private static bulleted_list_item(
item: Block<BulletedListItemBlockObjectResponse>
) {
return {
type: "list_item",
content: [
{
type: "paragraph",
content: item.bulleted_list_item.rich_text
.map(this.rich_text)
.filter(Boolean),
},
...this.mapChildren(item),
],
};
}
private static code(item: CodeBlockObjectResponse) {
const text = item.code.rich_text.map(this.rich_text_to_plaintext).join("");
return {
type: "code_fence",
attrs: {
language: item.code.language,
},
content: text ? [{ type: "text", text }] : undefined,
};
}
private static numbered_list_item(
item: Block<NumberedListItemBlockObjectResponse>
) {
return {
type: "list_item",
content: [
{
type: "paragraph",
content: item.numbered_list_item.rich_text
.map(this.rich_text)
.filter(Boolean),
},
...this.mapChildren(item),
],
};
}
private static rich_text(item: RichTextItemResponse) {
const annotationToMark: Record<
keyof RichTextItemResponse["annotations"],
string
> = {
bold: "strong",
code: "code_inline",
italic: "em",
underline: "underline",
strikethrough: "strikethrough",
color: "highlight",
};
const mapAttrs = () =>
Object.entries(item.annotations)
.filter(([key]) => key !== "color")
.filter(([, enabled]) => enabled)
.map(([key]) => ({
type: annotationToMark[key as keyof typeof annotationToMark],
}));
if (item.type === "mention") {
if (item.mention.type === "page") {
return {
type: "mention",
attrs: {
type: MentionType.Document,
label: item.plain_text,
modelId: item.mention.page.id,
},
};
}
if (item.mention.type === "link_mention") {
return {
type: "text",
text: item.plain_text,
marks: [
{
type: "link",
attrs: {
href: item.mention.link_mention.href,
},
},
],
};
}
if (item.mention.type === "link_preview") {
return {
type: "text",
text: item.plain_text,
marks: [
{
type: "link",
attrs: {
href: item.mention.link_preview.url,
},
},
],
};
}
if (!item.plain_text) {
return undefined;
}
return {
type: "text",
text: item.plain_text,
};
}
if (item.type === "equation") {
return {
type: "math_inline",
content: [
{
type: "text",
text: item.equation.expression,
},
],
};
}
if (!item.text.content) {
return undefined;
}
return {
type: "text",
text: item.text.content,
marks: [
...mapAttrs(),
...(item.text.link
? [{ type: "link", attrs: { href: item.text.link.url } }]
: []),
].filter(Boolean),
};
}
private static rich_text_to_plaintext(item: RichTextItemResponse) {
return item.plain_text;
}
private static divider(_: DividerBlockObjectResponse) {
return {
type: "hr",
};
}
private static equation(item: EquationBlockObjectResponse) {
return {
type: "math_block",
content: item.equation.expression
? [
{
type: "text",
text: item.equation.expression,
},
]
: undefined,
};
}
private static embed(item: EmbedBlockObjectResponse) {
return {
type: "embed",
attrs: {
href: item.embed.url,
},
};
}
private static file(item: FileBlockObjectResponse) {
return {
type: "attachment",
attrs: {
href: "file" in item.file ? item.file.file.url : item.file.external.url,
title: item.file.name,
},
};
}
private static pdf(item: PdfBlockObjectResponse) {
return {
type: "attachment",
attrs: {
href: "file" in item.pdf ? item.pdf.file.url : item.pdf.external.url,
title: item.pdf.caption.map(this.rich_text_to_plaintext).join(""),
},
};
}
private static heading_1(item: Heading1BlockObjectResponse) {
return {
type: "heading",
attrs: {
level: 1,
},
content: item.heading_1.rich_text.map(this.rich_text).filter(Boolean),
};
}
private static heading_2(item: Heading2BlockObjectResponse) {
return {
type: "heading",
attrs: {
level: 2,
},
content: item.heading_2.rich_text.map(this.rich_text).filter(Boolean),
};
}
private static heading_3(item: Heading3BlockObjectResponse) {
return {
type: "heading",
attrs: {
level: 3,
},
content: item.heading_3.rich_text.map(this.rich_text).filter(Boolean),
};
}
private static image(item: ImageBlockObjectResponse) {
return {
type: "paragraph",
content: [
{
type: "image",
attrs: {
src:
"file" in item.image
? item.image.file.url
: item.image.external.url,
alt: item.image.caption.map(this.rich_text_to_plaintext).join(""),
},
},
],
};
}
private static link_preview(item: LinkPreviewBlockObjectResponse) {
return {
type: "paragraph",
content: [
{
type: "text",
text: item.link_preview.url,
marks: [
{
type: "link",
attrs: {
href: item.link_preview.url,
},
},
],
},
],
};
}
private static link_to_page(item: LinkToPageBlockObjectResponse) {
if (item.link_to_page.type !== "page_id") {
return undefined;
}
return {
type: "mention",
attrs: {
modelId: item.link_to_page.page_id,
type: MentionType.Document,
label: "Page",
},
};
}
private static paragraph(item: ParagraphBlockObjectResponse) {
return {
type: "paragraph",
content: item.paragraph.rich_text.map(this.rich_text).filter(Boolean),
};
}
private static quote(item: Block<QuoteBlockObjectResponse>) {
return {
type: "blockquote",
content: [
{
type: "paragraph",
content: item.quote.rich_text.map(this.rich_text).filter(Boolean),
},
...this.mapChildren(item),
],
};
}
private static synced_block(item: Block<SyncedBlockBlockObjectResponse>) {
return this.mapChildren(item);
}
private static table(
item: TableBlockObjectResponse & {
children: Array<{
table_row: {
cells: Array<Array<RichTextItemResponse>>;
};
type?: "table_row";
object?: "block";
}>;
}
) {
return {
type: "table",
content: item.children.map((tr, y) => ({
type: "tr",
content: tr.table_row.cells.map((td, x) => ({
type:
(item.table.has_row_header && y === 0) ||
(item.table.has_column_header && x === 0)
? "th"
: "td",
content: [
{
type: "paragraph",
content: td.map(this.rich_text).filter(Boolean),
},
],
})),
})),
};
}
private static toggle(item: ToggleBlockObjectResponse) {
return {
type: "paragraph",
content: item.toggle.rich_text.map(this.rich_text).filter(Boolean),
};
}
private static to_do(item: Block<ToDoBlockObjectResponse>) {
return {
type: "checkbox_item",
attrs: {
checked: item.to_do.checked,
},
content: [
{
type: "paragraph",
content: item.to_do.rich_text.map(this.rich_text).filter(Boolean),
},
...this.mapChildren(item),
],
};
}
private static video(item: VideoBlockObjectResponse) {
if (item.video.type === "file") {
return {
type: "video",
attrs: {
src: item.video.file.url,
title: item.video.caption.map(this.rich_text_to_plaintext).join(""),
},
};
}
return {
type: "embed",
attrs: {
href: item.video.external.url,
},
};
}
}
File diff suppressed because it is too large Load Diff
-58
View File
@@ -1,58 +0,0 @@
import queryString from "query-string";
import env from "@shared/env";
import { IntegrationService } from "@shared/types";
import { settingsPath } from "@shared/utils/routeHelpers";
export type OAuthState = {
teamId: string;
};
export class NotionUtils {
public static tokenUrl = "https://api.notion.com/v1/oauth/token";
private static authBaseUrl = "https://api.notion.com/v1/oauth/authorize";
private static settingsUrl = settingsPath("import");
static parseState(state: string): OAuthState {
return JSON.parse(state);
}
static successUrl(integrationId: string) {
const params = {
success: "",
service: IntegrationService.Notion,
integrationId,
};
return `${this.settingsUrl}?${queryString.stringify(params)}`;
}
static errorUrl(error: string) {
const params = {
error,
service: IntegrationService.Notion,
};
return `${this.settingsUrl}?${queryString.stringify(params)}`;
}
static callbackUrl(
{ baseUrl, params }: { baseUrl: string; params?: string } = {
baseUrl: `${env.URL}`,
params: undefined,
}
) {
return params
? `${baseUrl}/api/notion.callback?${params}`
: `${baseUrl}/api/notion.callback`;
}
static authUrl({ state }: { state: OAuthState }) {
const params = {
client_id: env.NOTION_CLIENT_ID,
redirect_uri: this.callbackUrl(),
state: JSON.stringify(state),
response_type: "code",
owner: "user",
};
return `${this.authBaseUrl}?${queryString.stringify(params)}`;
}
}
-18
View File
@@ -1,18 +0,0 @@
import { BlockObjectResponse } from "@notionhq/client/build/src/api-endpoints";
export enum PageType {
Page = "page",
Database = "database",
}
export type Page = {
type: PageType;
id: string;
name: string;
emoji?: string;
};
// Transformed block structure with "children".
export type Block<T = BlockObjectResponse> = T & {
children?: Block[];
};

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