Compare commits

...

53 Commits

Author SHA1 Message Date
Tom Moor e01423a2ac perf refactor 2024-11-18 21:26:11 -05:00
Tom Moor 265151cff0 Add inline resolve action on comment threads 2024-11-18 18:42:55 -05:00
Tom Moor 326f733d4c fix: Further improvements to diacritics matching in CMD+F 2024-11-18 18:04:10 -05:00
Tom Moor d4d683c046 fix: Missing space character in invite modal, related #7968 2024-11-18 17:51:49 -05:00
Tom Moor 8204ac343f chore: Upgrade Sentry/AWS 2024-11-18 17:48:36 -05:00
dependabot[bot] cae8de7c7a chore(deps): bump @octokit/auth-app from 6.1.2 to 6.1.3 (#7974)
Bumps [@octokit/auth-app](https://github.com/octokit/auth-app.js) from 6.1.2 to 6.1.3.
- [Release notes](https://github.com/octokit/auth-app.js/releases)
- [Commits](https://github.com/octokit/auth-app.js/compare/v6.1.2...v6.1.3)

---
updated-dependencies:
- dependency-name: "@octokit/auth-app"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-18 14:41:05 -08:00
dependabot[bot] 8efa601967 chore(deps-dev): bump @relative-ci/agent from 4.2.12 to 4.2.13 (#7975)
Bumps [@relative-ci/agent](https://github.com/relative-ci/agent) from 4.2.12 to 4.2.13.
- [Release notes](https://github.com/relative-ci/agent/releases)
- [Commits](https://github.com/relative-ci/agent/compare/v4.2.12...v4.2.13)

---
updated-dependencies:
- dependency-name: "@relative-ci/agent"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-18 14:40:53 -08:00
Tom Moor 86c3ea8e9d fix: Copy toolbar positioning 2024-11-17 10:00:52 -05:00
Tom Moor c222782534 Upgrade Mermaid script in exported HTML, related #7964 2024-11-17 09:51:46 -05:00
Tom Moor 19ea7ee52b Remove sourcemap generation in bundle size calc (#7966) 2024-11-16 16:57:19 -08:00
Tom Moor d1de84a07e Reduce build time (#7965)
* Test using xlarge

* wip

* wip
2024-11-16 10:45:14 -08:00
Tom Moor d73b4c55bf Mermaid v11 (#7964)
* mermaid-v11

* fix: Various rendering incompatibilities
2024-11-16 08:10:55 -08:00
Hemachandar 9843c4c995 Improvements around templates (#7952)
* hide new-doc-from-template menu item

* change trash path for deleted templates

* conditional show templates in command bar
2024-11-16 07:48:58 -08:00
dependabot[bot] 685397b057 chore(deps): bump cross-spawn from 7.0.3 to 7.0.5 (#7963)
Bumps [cross-spawn](https://github.com/moxystudio/node-cross-spawn) from 7.0.3 to 7.0.5.
- [Changelog](https://github.com/moxystudio/node-cross-spawn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/moxystudio/node-cross-spawn/compare/v7.0.3...v7.0.5)

---
updated-dependencies:
- dependency-name: cross-spawn
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-16 06:43:06 -08:00
Tom Moor 13d37d4207 perf: Fix increase in initial bundle size, prosemirror-transform must be fixed at 1.10.0 until paragraph join is fixed 2024-11-16 09:14:27 -05:00
Tom Moor 7bedfab301 fix: Mermaid diagrams in collapsed headings do not render, closes #7960 2024-11-15 23:43:05 -05:00
Translate-O-Tron db5850ac0d New Crowdin updates (#7898) 2024-11-15 18:15:32 -08:00
Hemachandar a4c40ce25e show resolved view when a resolved comment is opened from notif email (#7959)
* show resolved view when a resolved comment is opened from notif email

* check and replace state
2024-11-15 18:14:30 -08:00
Tom Moor f5457e79cd fix: Mermaid diagram moves up and down when focused in read-only editor 2024-11-14 20:00:55 -05:00
Hemachandar 73eeeefb25 fix: restore workspace templates (#7951) 2024-11-14 16:32:02 -08:00
Hemachandar 54f82cac96 fix: show all templates in the menu (#7950)
* fetch all templates

* websocket events for workspace templates
2024-11-14 16:31:48 -08:00
Tom Moor bb43c24efe fix: Improve handling of unknown errors – closes #7933 2024-11-13 21:22:06 -05:00
Tom Moor acf3d7cd08 fix: Add latex fences as markdown signal 2024-11-13 21:03:44 -05:00
github-actions[bot] 5245f93642 chore: Compressed inefficient images automatically (#7946)
Co-authored-by: tommoor <tommoor@users.noreply.github.com>
2024-11-13 20:47:49 -05:00
Benjamin Kramser cfce55250e Add Pinterest embed (#7930)
* add pinterest embed

* improved profile detection
2024-11-13 17:46:07 -08:00
Tom Moor 6421995b29 fix: Do not override from address in self-hosted env, closes #7929 2024-11-13 20:19:17 -05:00
Tom Moor 8cfd8e25db fix: Event should not be written when API key is used 2024-11-13 09:10:30 -05:00
Tom Moor 1282e9653e fix: Excess padding on internal read-only docs, should only have applied to shares 2024-11-13 08:16:06 -05:00
Tom Moor f1edaecf49 perf: Fix observable changing on every keydown 2024-11-13 08:16:06 -05:00
Tom Moor f7d737ca45 fix: Missing 'Untitled' in reference list 2024-11-13 08:16:06 -05:00
Tom Moor 41c2c760d4 v0.81.0 2024-11-13 08:16:06 -05:00
dependabot[bot] f692d1bc3a chore(deps-dev): bump terser from 5.32.0 to 5.36.0 (#7928)
Bumps [terser](https://github.com/terser/terser) from 5.32.0 to 5.36.0.
- [Changelog](https://github.com/terser/terser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/terser/terser/compare/v5.32.0...v5.36.0)

---
updated-dependencies:
- dependency-name: terser
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-13 05:06:33 -08:00
dependabot[bot] 5197d6e18c chore(deps): bump prosemirror-view from 1.34.3 to 1.36.0 (#7925)
Bumps [prosemirror-view](https://github.com/prosemirror/prosemirror-view) from 1.34.3 to 1.36.0.
- [Changelog](https://github.com/ProseMirror/prosemirror-view/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prosemirror/prosemirror-view/compare/1.34.3...1.36.0)

---
updated-dependencies:
- dependency-name: prosemirror-view
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-13 05:06:16 -08:00
dependabot[bot] b901ea7b30 chore(deps-dev): bump eslint-plugin-react-hooks from 4.6.0 to 4.6.2 (#7924)
Bumps [eslint-plugin-react-hooks](https://github.com/facebook/react/tree/HEAD/packages/eslint-plugin-react-hooks) from 4.6.0 to 4.6.2.
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/packages/eslint-plugin-react-hooks/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/HEAD/packages/eslint-plugin-react-hooks)

---
updated-dependencies:
- dependency-name: eslint-plugin-react-hooks
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-13 05:05:21 -08:00
dependabot[bot] 3820499856 chore(deps-dev): bump @types/jest from 29.5.13 to 29.5.14 (#7927)
Bumps [@types/jest](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/jest) from 29.5.13 to 29.5.14.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/jest)

---
updated-dependencies:
- dependency-name: "@types/jest"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-13 05:05:11 -08:00
dependabot[bot] 0cffde63ab chore(deps): bump zod from 3.22.4 to 3.23.8 (#7926)
Bumps [zod](https://github.com/colinhacks/zod) from 3.22.4 to 3.23.8.
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Changelog](https://github.com/colinhacks/zod/blob/main/CHANGELOG.md)
- [Commits](https://github.com/colinhacks/zod/compare/v3.22.4...v3.23.8)

---
updated-dependencies:
- dependency-name: zod
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-13 05:05:01 -08:00
Hemachandar 449ba6488e hide share option for templates (#7937) 2024-11-13 05:03:36 -08:00
Tom Moor 62f3e6921f Add changes property to client event model 2024-11-10 22:58:51 -05:00
Tom Moor bc259316f7 Include changes in event presenter 2024-11-10 22:35:11 -05:00
Tom Moor 98e03cc227 Convert stars, towards #7920 (#7921) 2024-11-10 19:26:27 -08:00
Tom Moor 633e547d3e Refactor of event insertion (#5909) 2024-11-10 16:26:20 -08:00
Tom Moor d5de69fd4b fix: Exception for Notion import of a single document 2024-11-09 19:26:44 -05:00
Hemachandar feec01f160 fix: don't show comment marks for other users' drafts (#7838)
* fix: don't show comment marks for other users' drafts

* remove unnecessary draft check
2024-11-09 10:43:59 -08:00
Tom Moor aa5813032e fix: Click image to focus 2024-11-09 13:02:55 -05:00
Tom Moor a6ba189180 Add menu item to leave document that has been shared with current user (#7918)
* Add menu item to leave document that has been shared with current user

* Only redirect if viewing doc
2024-11-09 06:45:59 -08:00
Tom Moor 4c65bbc57c fix: Improved toolbar behavior with partial link selection, closes #7890 2024-11-08 22:41:58 -05:00
Tom Moor c76b4f46aa Tweak sharing UI 2024-11-08 21:35:55 -05:00
infinite-persistence ca17b41c53 share: add allowIndexing (#7896)
* share: add `allowIndexing`

## Ticket
Closes 7486

* i18n: follow existing no-punctuation style
2024-11-08 17:28:30 -08:00
Tom Moor 9747c6ba5d fix: Document mentions can be incorrectly attributed during collab session (#7913) 2024-11-08 05:35:49 -08:00
Tom Moor 55ffd6d098 feat: Adds support for importing CSV files (#7912)
* feat: Adds support for importing CSV files

* test

* tsc
2024-11-07 19:09:02 -08:00
Tom Moor 9b26ccda19 fix: Switching edit mode scrolls to page top, closes #7910 2024-11-07 22:04:15 -05:00
Hemachandar 56b38b9dbd fix: restore documents from a deleted collection (#7909) 2024-11-07 18:03:30 -08:00
Hemachandar 0a3a684493 fix: collection archival post-process parity with deletion (#7906) 2024-11-07 18:02:51 -08:00
129 changed files with 3357 additions and 1930 deletions
+8 -6
View File
@@ -4,12 +4,6 @@ defaults: &defaults
working_directory: ~/outline
docker:
- image: cimg/node:20.10
- image: cimg/redis:5.0
- image: cimg/postgres:14.2
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: circle_test
resource_class: large
environment:
NODE_ENV: test
@@ -78,6 +72,14 @@ jobs:
test-server:
<<: *defaults
parallelism: 3
docker:
- image: cimg/node:20.10
- image: cimg/redis:5.0
- image: cimg/postgres:14.2
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: circle_test
steps:
- checkout
- restore_cache:
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.80.2
Licensed Work: Outline 0.81.0
The Licensed Work is (c) 2024 General Outline, Inc.
Additional Use Grant: You may make use of the Licensed Work, provided that
you may not use the Licensed Work for a Document
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
Licensed Work by creating teams and documents
controlled by such third parties.
Change Date: 2028-09-26
Change Date: 2028-11-11
Change License: Apache License, Version 2.0
+63 -5
View File
@@ -28,6 +28,7 @@ import {
EyeIcon,
PadlockIcon,
GlobeIcon,
LogoutIcon,
} from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
@@ -37,6 +38,7 @@ import {
NavigationNode,
} from "@shared/types";
import { getEventFiles } from "@shared/utils/files";
import UserMembership from "~/models/UserMembership";
import DocumentDelete from "~/scenes/DocumentDelete";
import DocumentMove from "~/scenes/DocumentMove";
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
@@ -129,11 +131,30 @@ export const createDocumentFromTemplate = createAction({
section: DocumentSection,
icon: <NewDocumentIcon />,
keywords: "create",
visible: ({ currentTeamId, activeDocumentId, stores }) =>
!!currentTeamId &&
!!activeDocumentId &&
!!stores.documents.get(activeDocumentId)?.template &&
stores.policies.abilities(currentTeamId).createDocument,
visible: ({
currentTeamId,
activeCollectionId,
activeDocumentId,
stores,
}) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (
!currentTeamId ||
!document?.isTemplate ||
!!document?.isDraft ||
!!document?.isDeleted
) {
return false;
}
if (activeCollectionId) {
return stores.policies.abilities(activeCollectionId).createDocument;
}
return stores.policies.abilities(currentTeamId).createDocument;
},
perform: ({ activeCollectionId, activeDocumentId, sidebarContext }) =>
history.push(
newDocumentPath(activeCollectionId, { templateId: activeDocumentId }),
@@ -1119,6 +1140,42 @@ export const toggleViewerInsights = createAction({
},
});
export const leaveDocument = createAction({
name: ({ t }) => t("Leave document"),
analyticsName: "Leave document",
section: ActiveDocumentSection,
icon: <LogoutIcon />,
visible: ({ currentUserId, activeDocumentId, stores }) => {
const membership = stores.userMemberships.orderedData.find(
(m) => m.documentId === activeDocumentId && m.userId === currentUserId
);
return !!membership;
},
perform: async ({ t, location, currentUserId, activeDocumentId, stores }) => {
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
try {
if (document && location.pathname.startsWith(document.path)) {
history.push(homePath());
}
await stores.userMemberships.delete({
documentId: activeDocumentId,
userId: currentUserId,
} as UserMembership);
toast.success(t("You have left the shared document"));
} catch (err) {
toast.error(t("Could not leave document"));
}
},
});
export const rootDocumentActions = [
openDocument,
archiveDocument,
@@ -1137,6 +1194,7 @@ export const rootDocumentActions = [
subscribeDocument,
unsubscribeDocument,
duplicateDocument,
leaveDocument,
moveTemplateToWorkspace,
moveDocumentToCollection,
openRandomDocument,
@@ -2,7 +2,11 @@ import { NewDocumentIcon, ShapesIcon } from "outline-icons";
import * as React from "react";
import Icon from "~/components/Icon";
import { createAction } from "~/actions";
import { DocumentSection } from "~/actions/sections";
import {
ActiveCollectionSection,
DocumentSection,
TeamSection,
} from "~/actions/sections";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
import { newDocumentPath } from "~/utils/routeHelpers";
@@ -11,26 +15,42 @@ const useTemplatesAction = () => {
const { documents } = useStores();
React.useEffect(() => {
void documents.fetchTemplates();
void documents.fetchAllTemplates();
}, [documents]);
const actions = React.useMemo(
() =>
documents.templatesAlphabetical.map((item) =>
documents.templatesAlphabetical.map((template) =>
createAction({
name: item.titleWithDefault,
name: template.titleWithDefault,
analyticsName: "New document",
section: DocumentSection,
icon: item.icon ? (
<Icon value={item.icon} color={item.color ?? undefined} />
section: template.isWorkspaceTemplate
? TeamSection
: ActiveCollectionSection,
icon: template.icon ? (
<Icon value={template.icon} color={template.color ?? undefined} />
) : (
<NewDocumentIcon />
),
keywords: "create",
visible: ({ currentTeamId, activeCollectionId, stores }) => {
if (activeCollectionId) {
return (
stores.policies.abilities(activeCollectionId).createDocument &&
(template.collectionId === activeCollectionId ||
template.isWorkspaceTemplate)
);
}
return (
!!currentTeamId &&
stores.policies.abilities(currentTeamId).createDocument &&
template.isWorkspaceTemplate
);
},
perform: ({ activeCollectionId, sidebarContext }) =>
history.push(
newDocumentPath(item.collectionId ?? activeCollectionId, {
templateId: item.id,
newDocumentPath(template.collectionId ?? activeCollectionId, {
templateId: template.id,
}),
{
sidebarContext,
@@ -49,9 +69,15 @@ const useTemplatesAction = () => {
placeholder: ({ t }) => t("Choose a template"),
section: DocumentSection,
icon: <ShapesIcon />,
visible: ({ currentTeamId, stores }) =>
!!currentTeamId &&
stores.policies.abilities(currentTeamId).createDocument,
visible: ({ currentTeamId, activeCollectionId, stores }) => {
if (activeCollectionId) {
return stores.policies.abilities(activeCollectionId).createDocument;
}
return (
!!currentTeamId &&
stores.policies.abilities(currentTeamId).createDocument
);
},
children: () => actions,
}),
[actions]
+9 -8
View File
@@ -268,14 +268,15 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
placeholder={props.placeholder || ""}
defaultValue={props.defaultValue || ""}
/>
{props.editorStyle?.paddingBottom && (
<ClickablePadding
onClick={props.readOnly ? undefined : focusAtEnd}
onDrop={props.readOnly ? undefined : handleDrop}
onDragOver={props.readOnly ? undefined : handleDragOver}
minHeight={props.editorStyle.paddingBottom}
/>
)}
{props.editorStyle?.paddingBottom &&
(!props.readOnly || props.shareId) && (
<ClickablePadding
onClick={props.readOnly ? undefined : focusAtEnd}
onDrop={props.readOnly ? undefined : handleDrop}
onDragOver={props.readOnly ? undefined : handleDragOver}
minHeight={props.editorStyle.paddingBottom}
/>
)}
</>
</ErrorBoundary>
);
+1 -1
View File
@@ -27,7 +27,7 @@ import { documentHistoryPath } from "~/utils/routeHelpers";
type Props = {
document: Document;
event: Event;
event: Event<Document>;
latest?: boolean;
};
+5 -3
View File
@@ -6,9 +6,11 @@ import PaginatedList from "~/components/PaginatedList";
import EventListItem from "./EventListItem";
type Props = {
events: Event[];
events: Event<Document>[];
document: Document;
fetch: (options: Record<string, any> | undefined) => Promise<Event[]>;
fetch: (
options: Record<string, any> | undefined
) => Promise<Event<Document>[]>;
options?: Record<string, any>;
heading?: React.ReactNode;
empty?: React.ReactNode;
@@ -30,7 +32,7 @@ const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
heading={heading}
fetch={fetch}
options={options}
renderItem={(item: Event, index) => (
renderItem={(item: Event<Document>, index) => (
<EventListItem
key={item.id}
event={item}
+16 -13
View File
@@ -2,7 +2,6 @@ import { ReactionIcon } from "outline-icons";
import React from "react";
import { useTranslation } from "react-i18next";
import { PopoverDisclosure, usePopoverState } from "reakit";
import styled from "styled-components";
import EventBoundary from "@shared/components/EventBoundary";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
@@ -11,6 +10,7 @@ import Popover from "~/components/Popover";
import useMobile from "~/hooks/useMobile";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import useWindowSize from "~/hooks/useWindowSize";
import Tooltip from "../Tooltip";
const EmojiPanel = React.lazy(
() => import("~/components/IconPicker/components/EmojiPanel")
@@ -98,15 +98,22 @@ const ReactionPicker: React.FC<Props> = ({
<>
<PopoverDisclosure {...popover}>
{(props) => (
<PopoverButton
{...props}
aria-label={t("Reaction picker")}
className={className}
onClick={handlePopoverButtonClick}
size={size}
<Tooltip
content={t("Add reaction")}
placement="top"
delay={500}
hideOnClick
>
<ReactionIcon size={22} />
</PopoverButton>
<NudeButton
{...props}
aria-label={t("Reaction picker")}
className={className}
onClick={handlePopoverButtonClick}
size={size}
>
<ReactionIcon size={22} />
</NudeButton>
</Tooltip>
)}
</PopoverDisclosure>
<Popover
@@ -151,8 +158,4 @@ const Placeholder = React.memo(
);
Placeholder.displayName = "ReactionPickerPlaceholder";
const PopoverButton = styled(NudeButton)`
border-radius: 50%;
`;
export default ReactionPicker;
@@ -1,12 +1,13 @@
import debounce from "lodash/debounce";
import isEmpty from "lodash/isEmpty";
import { observer } from "mobx-react";
import { CopyIcon, GlobeIcon, InfoIcon } from "outline-icons";
import { CopyIcon, GlobeIcon, InfoIcon, QuestionMarkIcon } from "outline-icons";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { toast } from "sonner";
import styled, { useTheme } from "styled-components";
import Flex from "@shared/components/Flex";
import Squircle from "@shared/components/Squircle";
import { s } from "@shared/styles";
import { UrlHelper } from "@shared/utils/UrlHelper";
@@ -50,6 +51,19 @@ function PublicAccess({ document, share, sharedParent }: Props) {
setUrlId(share?.urlId);
}, [share?.urlId]);
const handleIndexingChanged = React.useCallback(
async (event) => {
try {
await share?.save({
allowIndexing: event.currentTarget.checked,
});
} catch (err) {
toast.error(err.message);
}
},
[share]
);
const handlePublishedChange = React.useCallback(
async (event) => {
try {
@@ -153,6 +167,32 @@ function PublicAccess({ document, share, sharedParent }: Props) {
/>
<ResizingHeightContainer>
{share?.published && (
<ListItem
title={
<Text type="tertiary" as={Flex}>
{t("Search engine indexing")}&nbsp;
<Tooltip
content={t(
"Disable this setting to discourage search engines from indexing the page"
)}
>
<QuestionMarkIcon size={18} />
</Tooltip>
</Text>
}
actions={
<Switch
aria-label={t("Search engine indexing")}
checked={share?.allowIndexing ?? false}
onChange={handleIndexingChanged}
width={26}
height={14}
/>
}
/>
)}
{sharedParent?.published ? (
<ShareLinkInput type="text" disabled defaultValue={shareUrl}>
{copyButton}
+7 -7
View File
@@ -216,8 +216,7 @@ export default function SelectionToolbar(props: Props) {
const colIndex = getColumnIndex(state);
const rowIndex = getRowIndex(state);
const isTableSelection = colIndex !== undefined && rowIndex !== undefined;
const link = isMarkActive(state.schema.marks.link)(state);
const range = getMarkRange(selection.$from, state.schema.marks.link);
const link = getMarkRange(selection.$from, state.schema.marks.link);
const isImageSelection =
selection instanceof NodeSelection && selection.node.type.name === "image";
const isAttachmentSelection =
@@ -266,7 +265,8 @@ export default function SelectionToolbar(props: Props) {
return null;
}
const showLinkToolbar = link && range;
const showLinkToolbar =
link && link.from === selection.from && link.to === selection.to;
return (
<FloatingToolbar
@@ -276,12 +276,12 @@ export default function SelectionToolbar(props: Props) {
>
{showLinkToolbar ? (
<LinkEditor
key={`${range.from}-${range.to}`}
key={`${link.from}-${link.to}`}
dictionary={dictionary}
view={view}
mark={range.mark}
from={range.from}
to={range.to}
mark={link.mark}
from={link.from}
to={link.to}
onClickLink={props.onClickLink}
onSearchLink={props.onSearchLink}
onCreateLink={onCreateLink ? handleOnCreateLink : undefined}
+8 -3
View File
@@ -248,14 +248,19 @@ export default class FindAndReplaceExtension extends Extension {
let m;
const search = this.findRegExp;
while ((m = search.exec(deburr(text)))) {
// We construct a string with the text stripped of diacritics plus the original text for
// search allowing to search for diacritics-insensitive matches easily.
while ((m = search.exec(deburr(text) + text))) {
if (m[0] === "") {
break;
}
// Reconstruct the correct match position
const i = m.index > text.length ? m.index - text.length : m.index;
this.results.push({
from: pos + m.index,
to: pos + m.index + m[0].length,
from: pos + i,
to: pos + i + m[0].length,
});
}
} catch (e) {
+4 -1
View File
@@ -690,7 +690,10 @@ export class Editor extends React.PureComponent<
* @param commentId The id of the comment to remove
* @param attrs The attributes to update
*/
public updateComment = (commentId: string, attrs: { resolved: boolean }) => {
public updateComment = (
commentId: string,
attrs: { resolved?: boolean; draft?: boolean }
) => {
const { state, dispatch } = this.view;
const tr = state.tr;
-1
View File
@@ -214,7 +214,6 @@ export default function formattingMenuItems(
name: "link",
tooltip: dictionary.createLink,
icon: <LinkIcon />,
active: isMarkActive(schema.marks.link),
attrs: { href: "" },
visible: !isCodeBlock && (!isMobile || !isEmpty),
},
+2
View File
@@ -47,6 +47,7 @@ import {
shareDocument,
copyDocument,
searchInDocument,
leaveDocument,
moveTemplate,
} from "~/actions/definitions/documents";
import useActionContext from "~/hooks/useActionContext";
@@ -298,6 +299,7 @@ const MenuContent: React.FC<MenuContentProps> = ({
},
actionToMenuItem(deleteDocument, context),
actionToMenuItem(permanentlyDeleteDocument, context),
actionToMenuItem(leaveDocument, context),
]}
/>
{(showDisplayOptions || showToggleEmbeds) && can.update && (
+2 -1
View File
@@ -254,7 +254,8 @@ export default class Document extends ArchivableModel {
@computed
get path(): string {
const prefix = this.template ? settingsPath("templates") : "/doc";
const prefix =
this.template && !this.isDeleted ? settingsPath("templates") : "/doc";
if (!this.title) {
return `${prefix}/untitled-${this.urlId}`;
+18 -11
View File
@@ -1,21 +1,29 @@
import Collection from "./Collection";
import Document from "./Document";
import User from "./User";
import Model from "./base/Model";
import Relation from "./decorators/Relation";
class Event extends Model {
class Event<T extends Model> extends Model {
static modelName = "Event";
id: string;
name: string;
modelId: string | null | undefined;
modelId: string | undefined;
actorIpAddress: string | null | undefined;
documentId: string;
@Relation(() => Document)
document: Document;
collectionId: string | null | undefined;
documentId: string | undefined;
@Relation(() => Collection)
collection: Collection;
collectionId: string | undefined;
@Relation(() => User)
user: User;
@@ -27,13 +35,12 @@ class Event extends Model {
actorId: string;
data: {
name: string;
email: string;
title: string;
published: boolean;
templateId: string;
};
data: Partial<T> | null;
changes: {
attributes: Partial<T>;
previous: Partial<T>;
} | null;
}
export default Event;
+4
View File
@@ -55,6 +55,10 @@ class Share extends Model {
@observable
url: string;
@Field
@observable
allowIndexing: boolean;
/** The user that shared the document. */
@Relation(() => User, { onDelete: "null" })
createdBy: User;
+12 -5
View File
@@ -27,6 +27,8 @@ import { Bubble } from "./CommentThreadItem";
import { HighlightedText } from "./HighlightText";
type Props = {
/** Callback when the form is submitted. */
onSubmit?: () => void;
/** Callback when the draft should be saved. */
onSaveDraft: (data: ProsemirrorData | undefined) => void;
/** A draft comment for this thread. */
@@ -59,6 +61,7 @@ function CommentForm({
documentId,
thread,
draft,
onSubmit,
onSaveDraft,
onTyping,
onFocus,
@@ -119,6 +122,7 @@ function CommentForm({
documentId,
data: draft,
})
.then(() => onSubmit?.())
.catch(() => {
comment.isNew = true;
toast.error(t("Error creating comment"));
@@ -153,11 +157,14 @@ function CommentForm({
comment.id = uuidv4();
comments.add(comment);
comment.save().catch(() => {
comments.remove(comment.id);
comment.isNew = true;
toast.error(t("Error creating comment"));
});
comment
.save()
.then(() => onSubmit?.())
.catch(() => {
comments.remove(comment.id);
comment.isNew = true;
toast.error(t("Error creating comment"));
});
// optimistically update the comment model
comment.isNew = false;
@@ -86,6 +86,11 @@ function CommentThread({
});
const can = usePolicy(document);
const [draft, onSaveDraft] = usePersistedState<ProsemirrorData | undefined>(
`draft-${document.id}-${thread.id}`,
undefined
);
const canReply = can.comment && !thread.isResolved;
const highlightedCommentMarks = editor
@@ -111,6 +116,10 @@ function CommentThread({
}
});
const handleSubmit = React.useCallback(() => {
editor?.updateComment(thread.id, { draft: false });
}, [editor, thread.id]);
const handleClickThread = () => {
history.replace({
// Clear any commentId from the URL when explicitly focusing a thread
@@ -174,11 +183,6 @@ function CommentThread({
}
}, [focused, focusedOnMount, thread.id]);
const [draft, onSaveDraft] = usePersistedState<ProsemirrorData | undefined>(
`draft-${document.id}-${thread.id}`,
undefined
);
return (
<Thread
ref={topRef}
@@ -228,6 +232,7 @@ function CommentThread({
{(focused || draft || commentsInThread.length === 0) && canReply && (
<Fade timing={100}>
<CommentForm
onSubmit={handleSubmit}
onSaveDraft={onSaveDraft}
draft={draft}
documentId={document.id}
@@ -1,6 +1,7 @@
import { differenceInMilliseconds } from "date-fns";
import { action } from "mobx";
import { observer } from "mobx-react";
import { DoneIcon } from "outline-icons";
import { darken } from "polished";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -16,10 +17,14 @@ import Comment from "~/models/Comment";
import { Avatar } from "~/components/Avatar";
import ButtonSmall from "~/components/ButtonSmall";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import ReactionList from "~/components/Reactions/ReactionList";
import ReactionPicker from "~/components/Reactions/ReactionPicker";
import Text from "~/components/Text";
import Time from "~/components/Time";
import Tooltip from "~/components/Tooltip";
import { resolveCommentFactory } from "~/actions/definitions/comments";
import useActionContext from "~/hooks/useActionContext";
import useBoolean from "~/hooks/useBoolean";
import useCurrentUser from "~/hooks/useCurrentUser";
import CommentMenu from "~/menus/CommentMenu";
@@ -242,11 +247,13 @@ function CommentThreadItem({
onRemoveReaction={handleRemoveReaction}
picker={
!comment.isResolved ? (
<StyledReactionPicker
<Action
as={ReactionPicker}
onSelect={handleAddReaction}
onOpen={disableScroll}
onClose={enableScroll}
size={28}
rounded
/>
) : undefined
}
@@ -257,14 +264,20 @@ function CommentThreadItem({
<EventBoundary>
{!isEditing && (
<Actions gap={4} dir={dir}>
{firstOfThread && (
<ResolveButton onUpdate={handleUpdate} comment={comment} />
)}
{!comment.isResolved && (
<StyledReactionPicker
<Action
as={ReactionPicker}
onSelect={handleAddReaction}
onOpen={disableScroll}
onClose={enableScroll}
rounded
/>
)}
<StyledMenu
<Action
as={CommentMenu}
comment={comment}
onEdit={setEditing}
onDelete={handleDelete}
@@ -278,6 +291,38 @@ function CommentThreadItem({
);
}
const ResolveButton = ({
comment,
onUpdate,
}: {
comment: Comment;
onUpdate: (attrs: { resolved: boolean }) => void;
}) => {
const context = useActionContext();
const { t } = useTranslation();
return (
<Tooltip
content={t("Mark as resolved")}
placement="top"
delay={500}
hideOnClick
>
<Action
as={NudeButton}
context={context}
action={resolveCommentFactory({
comment,
onResolve: () => onUpdate({ resolved: true }),
})}
rounded
>
<DoneIcon size={22} outline />
</Action>
</Tooltip>
);
};
const StyledCommentEditor = styled(CommentEditor)`
${(props) =>
!props.readOnly &&
@@ -308,25 +353,13 @@ const Body = styled.form`
border-radius: 2px;
`;
const StyledMenu = styled(CommentMenu)`
color: ${s("textSecondary")};
svg {
fill: currentColor;
opacity: 0.5;
}
&: ${hover}, &[aria-expanded= "true"] {
background: ${s("backgroundQuaternary")};
svg {
opacity: 0.75;
}
}
`;
const StyledReactionPicker = styled(ReactionPicker)`
const Action = styled.span<{ rounded?: boolean }>`
color: ${s("textSecondary")};
${(props) =>
props.rounded &&
css`
border-radius: 50%;
`}
svg {
fill: currentColor;
@@ -352,7 +385,7 @@ const Actions = styled(Flex)<{ dir?: "rtl" | "ltr" }>`
background: ${s("backgroundSecondary")};
padding-left: 4px;
&:has(${StyledReactionPicker}[aria-expanded="true"], ${StyledMenu}[aria-expanded="true"]) {
&:has(${Action}[aria-expanded="true"]) {
opacity: 1;
}
`;
@@ -218,11 +218,11 @@ function DataLoader({ match, children }: Props) {
);
}
const readOnly =
!isEditing || !can.update || document.isArchived || !!revisionId;
const canEdit = can.update && !document.isArchived && !revisionId;
const readOnly = !isEditing || !canEdit;
return (
<React.Fragment key={readOnly ? "readOnly" : ""}>
<React.Fragment key={canEdit ? "edit" : "read"}>
{children({
document,
revision,
+14 -1
View File
@@ -27,6 +27,7 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import useFocusedComment from "~/hooks/useFocusedComment";
import usePolicy from "~/hooks/usePolicy";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import {
documentHistoryPath,
@@ -81,6 +82,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
const user = useCurrentUser({ rejectOnEmpty: false });
const team = useCurrentTeam({ rejectOnEmpty: false });
const history = useHistory();
const params = useQuery();
const {
document,
onChangeTitle,
@@ -103,9 +105,20 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
React.useEffect(() => {
if (focusedComment) {
const viewingResolved = params.get("resolved") === "";
if (
(focusedComment.isResolved && !viewingResolved) ||
(!focusedComment.isResolved && viewingResolved)
) {
history.replace({
search: focusedComment.isResolved ? "resolved=" : "",
pathname: location.pathname,
state: { commentId: focusedComment.id },
});
}
ui.expandComments(document.id);
}
}, [focusedComment, ui, document.id]);
}, [focusedComment, ui, document.id, history, params]);
// Save document when blurring title, but delay so that if clicking on a
// button this is allowed to execute first.
+2 -1
View File
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { useHistory, useRouteMatch } from "react-router-dom";
import styled from "styled-components";
import { RevisionHelper } from "@shared/utils/RevisionHelper";
import Document from "~/models/Document";
import Event from "~/models/Event";
import Empty from "~/components/Empty";
import PaginatedEventList from "~/components/PaginatedEventList";
@@ -12,7 +13,7 @@ import useStores from "~/hooks/useStores";
import { documentPath } from "~/utils/routeHelpers";
import Sidebar from "./SidebarLayout";
const EMPTY_ARRAY: Event[] = [];
const EMPTY_ARRAY: Event<Document>[] = [];
function History() {
const { events, documents } = useStores();
@@ -61,6 +61,8 @@ function ReferenceListItem({
}: Props) {
const { icon, color } = document;
const isEmoji = determineIconType(icon) === IconType.Emoji;
const title =
document instanceof Document ? document.titleWithDefault : document.title;
return (
<DocumentLink
@@ -81,9 +83,7 @@ function ReferenceListItem({
) : (
<DocumentIcon />
)}
<Title>
{isEmoji ? document.title.replace(icon!, "") : document.title}
</Title>
<Title>{isEmoji ? title.replace(icon!, "") : title}</Title>
</Content>
</DocumentLink>
);
+1 -1
View File
@@ -127,7 +127,7 @@ function Invite({ onSubmit }: Props) {
<Trans>{{ collectionCount }} collections</Trans>
</strong>
</Tooltip>
.
.{" "}
</span>
) : undefined;
+119 -101
View File
@@ -5,6 +5,124 @@ import { Trans } from "react-i18next";
import Notice from "~/components/Notice";
import useQuery from "~/hooks/useQuery";
function Message({ notice }: { notice: string }) {
switch (notice) {
case "domain-not-allowed":
return (
<Trans>
The domain associated with your email address has not been allowed for
this workspace.
</Trans>
);
case "domain-required":
return (
<Trans>
Unable to sign-in. Please navigate to your workspace's custom URL,
then try to sign-in again.
<hr />
If you were invited to a workspace, you will find a link to it in the
invite email.
</Trans>
);
case "gmail-account-creation":
return (
<Trans>
Sorry, a new account cannot be created with a personal Gmail address.
<hr />
Please use a Google Workspaces account instead.
</Trans>
);
case "pending-deletion":
return (
<Trans>
The workspace associated with your user is scheduled for deletion and
cannot be accessed at this time.
</Trans>
);
case "maximum-reached":
return (
<Trans>
The workspace you authenticated with is not authorized on this
installation. Try another?
</Trans>
);
case "malformed-user-info":
return (
<Trans>
We could not read the user info supplied by your identity provider.
</Trans>
);
case "email-auth-required":
return (
<Trans>
Your account uses email sign-in, please sign-in with email to
continue.
</Trans>
);
case "email-auth-ratelimit":
return (
<Trans>
An email sign-in link was recently sent, please check your inbox or
try again in a few minutes.
</Trans>
);
case "auth-error":
case "state-mismatch":
return (
<Trans>
Authentication failed we were unable to sign you in at this time.
Please try again.
</Trans>
);
case "invalid-authentication":
return (
<Trans>
Authentication failed you do not have permission to access this
workspace.
</Trans>
);
case "expired-token":
return (
<Trans>
Sorry, it looks like that sign-in link is no longer valid, please try
requesting another.
</Trans>
);
case "user-suspended":
return (
<Trans>
Your account has been suspended. To re-activate your account, please
contact a workspace admin.
</Trans>
);
case "team-suspended":
return (
<Trans>
This workspace has been suspended. Please contact support to restore
access.
</Trans>
);
case "authentication-provider-disabled":
return (
<Trans>
Authentication failed this login method was disabled by a team
admin.
</Trans>
);
case "invite-required":
return (
<Trans>
The workspace you are trying to join requires an invite before you can
create an account.
<hr />
Please request an invite from your workspace admin and try again.
</Trans>
);
default:
return <Trans>Sorry, an unknown error occurred.</Trans>;
}
}
export default function Notices() {
const query = useQuery();
const notice = query.get("notice");
@@ -15,107 +133,7 @@ export default function Notices() {
return (
<Notice icon={<WarningIcon color="currentcolor" />}>
{notice === "domain-not-allowed" && (
<Trans>
The domain associated with your email address has not been allowed for
this workspace.
</Trans>
)}
{notice === "domain-required" && (
<Trans>
Unable to sign-in. Please navigate to your workspace's custom URL,
then try to sign-in again.
<hr />
If you were invited to a workspace, you will find a link to it in the
invite email.
</Trans>
)}
{notice === "gmail-account-creation" && (
<Trans>
Sorry, a new account cannot be created with a personal Gmail address.
<hr />
Please use a Google Workspaces account instead.
</Trans>
)}
{notice === "pending-deletion" && (
<Trans>
The workspace associated with your user is scheduled for deletion and
cannot be accessed at this time.
</Trans>
)}
{notice === "maximum-reached" && (
<Trans>
The workspace you authenticated with is not authorized on this
installation. Try another?
</Trans>
)}
{notice === "malformed-user-info" && (
<Trans>
We could not read the user info supplied by your identity provider.
</Trans>
)}
{notice === "email-auth-required" && (
<Trans>
Your account uses email sign-in, please sign-in with email to
continue.
</Trans>
)}
{notice === "email-auth-ratelimit" && (
<Trans>
An email sign-in link was recently sent, please check your inbox or
try again in a few minutes.
</Trans>
)}
{(notice === "auth-error" || notice === "state-mismatch") && (
<Trans>
Authentication failed we were unable to sign you in at this time.
Please try again.
</Trans>
)}
{notice === "invalid-authentication" && (
<Trans>
Authentication failed you do not have permission to access this
workspace.
</Trans>
)}
{notice === "expired-token" && (
<Trans>
Sorry, it looks like that sign-in link is no longer valid, please try
requesting another.
</Trans>
)}
{notice === "user-suspended" && (
<Trans>
Your account has been suspended. To re-activate your account, please
contact a workspace admin.
</Trans>
)}
{notice === "team-suspended" && (
<Trans>
This workspace has been suspended. Please contact support to restore
access.
</Trans>
)}
{notice === "authentication-provider-disabled" && (
<Trans>
Authentication failed this login method was disabled by a team
admin.
</Trans>
)}
{notice === "invite-required" && (
<Trans>
The workspace you are trying to join requires an invite before you can
create an account.
<hr />
Please request an invite from your workspace admin and try again.
</Trans>
)}
{notice === "domain-not-allowed" && (
<Trans>
Sorry, your domain is not allowed. Please try again with an allowed
workspace domain.
</Trans>
)}
<Message notice={notice} />
</Notice>
);
}
+10 -6
View File
@@ -68,12 +68,16 @@ export default class PresenceStore {
@action
private update(documentId: string, userId: string, isEditing: boolean) {
const existing = this.data.get(documentId) || new Map();
existing.set(userId, {
isEditing,
userId,
});
this.data.set(documentId, existing);
const presence = this.data.get(documentId) || new Map();
const existing = presence.get(userId);
if (!existing || existing.isEditing !== isEditing) {
presence.set(userId, {
isEditing,
userId,
});
this.data.set(documentId, presence);
}
}
public get(documentId: string): DocumentPresence | null | undefined {
+5
View File
@@ -63,6 +63,7 @@ export default class DocumentsStore extends Store<Document> {
".md",
".doc",
".docx",
"text/csv",
"text/markdown",
"text/plain",
"text/html",
@@ -367,6 +368,10 @@ export default class DocumentsStore extends Store<Document> {
fetchTemplates = async (options?: PaginationParams): Promise<Document[]> =>
this.fetchNamedPage("list", { ...options, template: true });
@action
fetchAllTemplates = async (options?: PaginationParams): Promise<Document[]> =>
this.fetchAll({ ...options, template: true });
@action
fetchAlphabetical = async (options?: PaginationParams): Promise<Document[]> =>
this.fetchNamedPage("list", {
+2 -2
View File
@@ -4,7 +4,7 @@ import Event from "~/models/Event";
import RootStore from "./RootStore";
import Store, { RPCAction } from "./base/Store";
export default class EventsStore extends Store<Event> {
export default class EventsStore extends Store<Event<any>> {
actions = [RPCAction.List];
constructor(rootStore: RootStore) {
@@ -12,7 +12,7 @@ export default class EventsStore extends Store<Event> {
}
@computed
get orderedData(): Event[] {
get orderedData(): Event<any>[] {
return orderBy(Array.from(this.data.values()), "createdAt", "desc");
}
}
+17 -16
View File
@@ -48,11 +48,11 @@
"> 0.25%, not dead"
],
"dependencies": {
"@aws-sdk/client-s3": "3.616.0",
"@aws-sdk/lib-storage": "3.616.0",
"@aws-sdk/s3-presigned-post": "3.616.0",
"@aws-sdk/s3-request-presigner": "3.616.0",
"@aws-sdk/signature-v4-crt": "^3.616.0",
"@aws-sdk/client-s3": "3.693.0",
"@aws-sdk/lib-storage": "3.693.0",
"@aws-sdk/s3-presigned-post": "3.693.0",
"@aws-sdk/s3-request-presigner": "3.693.0",
"@aws-sdk/signature-v4-crt": "^3.693.0",
"@babel/core": "^7.24.7",
"@babel/plugin-proposal-decorators": "^7.24.7",
"@babel/plugin-transform-class-properties": "^7.24.7",
@@ -68,6 +68,7 @@
"@dnd-kit/modifiers": "^6.0.1",
"@dnd-kit/sortable": "^7.0.2",
"@emoji-mart/data": "^1.2.1",
"@fast-csv/parse": "^5.0.2",
"@fortawesome/fontawesome-svg-core": "^6.5.2",
"@fortawesome/free-brands-svg-icons": "^6.5.2",
"@fortawesome/free-solid-svg-icons": "^6.5.2",
@@ -78,11 +79,11 @@
"@hocuspocus/server": "1.1.2",
"@joplin/turndown-plugin-gfm": "^1.0.49",
"@juggle/resize-observer": "^3.4.0",
"@octokit/auth-app": "^6.1.2",
"@octokit/auth-app": "^6.1.3",
"@outlinewiki/koa-passport": "^4.2.1",
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
"@renderlesskit/react": "^0.11.0",
"@sentry/node": "^7.117.0",
"@sentry/node": "^7.119.0",
"@sentry/react": "^7.119.0",
"@tippyjs/react": "^4.2.6",
"@types/form-data": "^2.5.0",
@@ -149,7 +150,7 @@
"markdown-it": "^13.0.2",
"markdown-it-container": "^3.0.0",
"markdown-it-emoji": "^2.0.0",
"mermaid": "9.3.0",
"mermaid": "11.4.0",
"mime-types": "^2.1.35",
"mobx": "^4.15.4",
"mobx-react": "^6.3.1",
@@ -183,7 +184,7 @@
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.4.0",
"prosemirror-transform": "1.10.0",
"prosemirror-view": "^1.34.3",
"prosemirror-view": "^1.36.0",
"query-string": "^7.1.3",
"randomstring": "1.3.0",
"rate-limiter-flexible": "^2.4.2",
@@ -247,13 +248,13 @@
"y-protocols": "^1.0.6",
"yauzl": "^2.10.0",
"yjs": "^13.6.1",
"zod": "^3.22.4"
"zod": "^3.23.8"
},
"devDependencies": {
"@babel/cli": "^7.25.9",
"@babel/preset-typescript": "^7.24.1",
"@faker-js/faker": "^8.4.1",
"@relative-ci/agent": "^4.2.12",
"@relative-ci/agent": "^4.2.13",
"@testing-library/react": "^12.0.0",
"@types/addressparser": "^1.0.3",
"@types/body-scroll-lock": "^3.1.2",
@@ -269,7 +270,7 @@
"@types/glob": "^8.0.1",
"@types/google.analytics": "^0.0.46",
"@types/invariant": "^2.2.37",
"@types/jest": "^29.5.13",
"@types/jest": "^29.5.14",
"@types/jsonwebtoken": "^8.5.9",
"@types/katex": "^0.16.7",
"@types/koa": "^2.15.0",
@@ -284,7 +285,6 @@
"@types/markdown-it": "^14.1.2",
"@types/markdown-it-container": "^2.0.9",
"@types/markdown-it-emoji": "^2.0.4",
"@types/mermaid": "^9.2.0",
"@types/mime-types": "^2.1.4",
"@types/natural-sort": "^0.0.24",
"@types/node": "20.14.2",
@@ -340,7 +340,7 @@
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.35.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-hooks": "^4.6.2",
"husky": "^8.0.3",
"i18next-parser": "^7.9.0",
"jest-cli": "^29.7.0",
@@ -353,12 +353,13 @@
"react-refresh": "^0.14.0",
"rimraf": "^2.5.4",
"rollup-plugin-webpack-stats": "^0.4.1",
"terser": "^5.32.0",
"terser": "^5.36.0",
"typescript": "^5.6.3",
"vite-plugin-static-copy": "^0.17.0",
"yarn-deduplicate": "^6.0.2"
},
"resolutions": {
"prosemirror-transform": "1.10.0",
"body-scroll-lock": "^4.0.0-beta.0",
"d3": "^7.0.0",
"debug": "4.3.4",
@@ -367,5 +368,5 @@
"qs": "6.9.7",
"rollup": "^4.5.1"
},
"version": "0.80.2"
"version": "0.81.0"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+23 -53
View File
@@ -1,9 +1,9 @@
import { Transaction } from "sequelize";
import { v4 as uuidv4 } from "uuid";
import { AttachmentPreset } from "@shared/types";
import { Attachment, Event, User } from "@server/models";
import { Attachment, User } from "@server/models";
import AttachmentHelper from "@server/models/helpers/AttachmentHelper";
import FileStorage from "@server/storage/files";
import { APIContext } from "@server/types";
import { RequestInit } from "@server/utils/fetch";
type BaseProps = {
@@ -17,10 +17,8 @@ type BaseProps = {
source?: "import";
/** The preset to use for the attachment */
preset: AttachmentPreset;
/** The IP address of the user creating the attachment, if available. */
ip?: string;
/** The database transaction to use for the creation */
transaction?: Transaction;
/** The request context */
ctx: APIContext;
/** Options to pass to fetch when downloading the attachment */
fetchOptions?: RequestInit;
};
@@ -42,8 +40,7 @@ export default async function attachmentCreator({
user,
source,
preset,
ip,
transaction,
ctx,
fetchOptions,
...rest
}: Props): Promise<Attachment | undefined> {
@@ -64,20 +61,15 @@ export default async function attachmentCreator({
if (!res) {
return;
}
attachment = await Attachment.create(
{
id,
key,
acl,
size: res.contentLength,
contentType: res.contentType,
teamId: user.teamId,
userId: user.id,
},
{
transaction,
}
);
attachment = await Attachment.createWithCtx(ctx, {
id,
key,
acl,
size: res.contentLength,
contentType: res.contentType,
teamId: user.teamId,
userId: user.id,
});
} else {
const { buffer, type } = rest;
await FileStorage.store({
@@ -88,38 +80,16 @@ export default async function attachmentCreator({
acl,
});
attachment = await Attachment.create(
{
id,
key,
acl,
size: buffer.length,
contentType: type,
teamId: user.teamId,
userId: user.id,
},
{
transaction,
}
);
}
await Event.create(
{
name: "attachments.create",
data: {
name,
source,
},
modelId: attachment.id,
attachment = await Attachment.createWithCtx(ctx, {
id,
key,
acl,
size: buffer.length,
contentType: type,
teamId: user.teamId,
actorId: user.id,
ip,
},
{
transaction,
}
);
userId: user.id,
});
}
return attachment;
}
+17 -42
View File
@@ -1,4 +1,3 @@
import { Transaction } from "sequelize";
import { v4 as uuidv4 } from "uuid";
import {
FileOperationFormat,
@@ -6,8 +5,9 @@ import {
FileOperationState,
} from "@shared/types";
import { traceFunction } from "@server/logging/tracing";
import { Collection, Event, Team, User, FileOperation } from "@server/models";
import { Collection, Team, User, FileOperation } from "@server/models";
import { Buckets } from "@server/models/helpers/AttachmentHelper";
import { type APIContext } from "@server/types";
type Props = {
collection?: Collection;
@@ -15,8 +15,7 @@ type Props = {
user: User;
format?: FileOperationFormat;
includeAttachments?: boolean;
ip: string;
transaction: Transaction;
ctx: APIContext;
};
function getKeyForFileOp(
@@ -35,8 +34,7 @@ async function collectionExporter({
user,
format = FileOperationFormat.MarkdownZip,
includeAttachments = true,
ip,
transaction,
ctx,
}: Props) {
const collectionId = collection?.id;
const key = getKeyForFileOp(
@@ -44,43 +42,20 @@ async function collectionExporter({
format,
collection?.name || team.name
);
const fileOperation = await FileOperation.create(
{
type: FileOperationType.Export,
state: FileOperationState.Creating,
format,
key,
url: null,
size: 0,
collectionId,
userId: user.id,
teamId: user.teamId,
options: {
includeAttachments,
},
const fileOperation = await FileOperation.createWithCtx(ctx, {
type: FileOperationType.Export,
state: FileOperationState.Creating,
format,
key,
url: null,
size: 0,
collectionId,
options: {
includeAttachments,
},
{
transaction,
}
);
await Event.create(
{
name: "fileOperations.create",
teamId: user.teamId,
actorId: user.id,
modelId: fileOperation.id,
collectionId,
ip,
data: {
type: FileOperationType.Export,
format,
},
},
{
transaction,
}
);
userId: user.id,
teamId: user.teamId,
});
fileOperation.user = user;
+4 -5
View File
@@ -1,9 +1,9 @@
import { Transaction } from "sequelize";
import { Optional } from "utility-types";
import { Document, Event, User } from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import { TextHelper } from "@server/models/helpers/TextHelper";
import { APIContext } from "@server/types";
type Props = Optional<
Pick<
@@ -31,8 +31,7 @@ type Props = Optional<
publish?: boolean;
templateDocument?: Document | null;
user: User;
ip?: string;
transaction?: Transaction;
ctx: APIContext;
};
export default async function documentCreator({
@@ -58,9 +57,9 @@ export default async function documentCreator({
editorVersion,
publishedAt,
sourceMetadata,
ip,
transaction,
ctx,
}: Props): Promise<Document> {
const { transaction, ip } = ctx.context;
const templateId = templateDocument ? templateDocument.id : undefined;
if (state && templateDocument) {
+5 -10
View File
@@ -1,10 +1,9 @@
import { createContext } from "@server/context";
import { sequelize } from "@server/storage/database";
import { buildDocument, buildUser } from "@server/test/factories";
import documentDuplicator from "./documentDuplicator";
describe("documentDuplicator", () => {
const ip = "127.0.0.1";
it("should duplicate existing document", async () => {
const user = await buildUser();
const original = await buildDocument({
@@ -16,9 +15,8 @@ describe("documentDuplicator", () => {
documentDuplicator({
document: original,
collection: original.collection,
transaction,
user,
ip,
ctx: createContext(user, transaction),
})
);
@@ -43,9 +41,8 @@ describe("documentDuplicator", () => {
document: original,
collection: original.collection,
title: "New title",
transaction,
user,
ip,
ctx: createContext(user, transaction),
})
);
@@ -77,9 +74,8 @@ describe("documentDuplicator", () => {
document: original,
collection: original.collection,
user,
transaction,
recursive: true,
ip,
ctx: createContext(user, transaction),
})
);
@@ -97,10 +93,9 @@ describe("documentDuplicator", () => {
documentDuplicator({
document: original,
collection: original.collection,
transaction,
publish: false,
user,
ip,
ctx: createContext(user, transaction),
})
);
+7 -12
View File
@@ -1,7 +1,8 @@
import { Transaction, Op } from "sequelize";
import { Op } from "sequelize";
import { User, Collection, Document } from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import { APIContext } from "@server/types";
import documentCreator from "./documentCreator";
type Props = {
@@ -19,10 +20,8 @@ type Props = {
publish?: boolean;
/** Whether to duplicate child documents */
recursive?: boolean;
/** The database transaction to use for the creation */
transaction?: Transaction;
/** The IP address of the request */
ip: string;
/** The request context */
ctx: APIContext;
};
export default async function documentDuplicator({
@@ -33,16 +32,14 @@ export default async function documentDuplicator({
title,
publish,
recursive,
transaction,
ip,
ctx,
}: Props): Promise<Document[]> {
const newDocuments: Document[] = [];
const sharedProperties = {
user,
collectionId: collection?.id,
publish: publish ?? !!document.publishedAt,
ip,
transaction,
ctx,
};
const duplicated = await documentCreator({
@@ -76,9 +73,7 @@ export default async function documentDuplicator({
[Op.eq]: null,
},
},
{
transaction,
}
ctx
);
for (const childDocument of childDocuments) {
+123 -94
View File
@@ -1,28 +1,31 @@
import path from "path";
import fs from "fs-extra";
import { createContext } from "@server/context";
import Attachment from "@server/models/Attachment";
import { sequelize } from "@server/storage/database";
import { buildUser } from "@server/test/factories";
import documentImporter from "./documentImporter";
jest.mock("@server/storage/files");
describe("documentImporter", () => {
const ip = "127.0.0.1";
it("should convert Word Document to markdown", async () => {
const user = await buildUser();
const fileName = "images.docx";
const content = await fs.readFile(
path.resolve(__dirname, "..", "test", "fixtures", fileName)
);
const response = await documentImporter({
user,
mimeType:
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
fileName,
content,
ip,
});
const response = await sequelize.transaction((transaction) =>
documentImporter({
user,
mimeType:
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
fileName,
content,
ctx: createContext(user, transaction),
})
);
const attachments = await Attachment.count({
where: {
teamId: user.teamId,
@@ -40,13 +43,15 @@ describe("documentImporter", () => {
const content = await fs.readFile(
path.resolve(__dirname, "..", "test", "fixtures", fileName)
);
const response = await documentImporter({
user,
mimeType: "application/octet-stream",
fileName,
content,
ip,
});
const response = await sequelize.transaction((transaction) =>
documentImporter({
user,
mimeType: "application/octet-stream",
fileName,
content,
ctx: createContext(user, transaction),
})
);
const attachments = await Attachment.count({
where: {
teamId: user.teamId,
@@ -67,13 +72,15 @@ describe("documentImporter", () => {
let error;
try {
await documentImporter({
user,
mimeType: "application/octet-stream",
fileName,
content,
ip,
});
await sequelize.transaction((transaction) =>
documentImporter({
user,
mimeType: "application/octet-stream",
fileName,
content,
ctx: createContext(user, transaction),
})
);
} catch (err) {
error = err.message;
}
@@ -87,13 +94,15 @@ describe("documentImporter", () => {
const content = await fs.readFile(
path.resolve(__dirname, "..", "test", "fixtures", fileName)
);
const response = await documentImporter({
user,
mimeType: "application/octet-stream",
fileName,
content,
ip,
});
const response = await sequelize.transaction((transaction) =>
documentImporter({
user,
mimeType: "application/octet-stream",
fileName,
content,
ctx: createContext(user, transaction),
})
);
const attachments = await Attachment.count({
where: {
teamId: user.teamId,
@@ -112,13 +121,15 @@ describe("documentImporter", () => {
path.resolve(__dirname, "..", "test", "fixtures", fileName),
"utf8"
);
const response = await documentImporter({
user,
mimeType: "text/html",
fileName,
content,
ip,
});
const response = await sequelize.transaction((transaction) =>
documentImporter({
user,
mimeType: "text/html",
fileName,
content,
ctx: createContext(user, transaction),
})
);
expect(response.text).toContain("Text paragraph");
expect(response.title).toEqual("Heading 1");
});
@@ -129,13 +140,16 @@ describe("documentImporter", () => {
const content = await fs.readFile(
path.resolve(__dirname, "..", "test", "fixtures", fileName)
);
const response = await documentImporter({
user,
mimeType: "application/msword",
fileName,
content,
ip,
});
const response = await sequelize.transaction((transaction) =>
documentImporter({
user,
mimeType: "application/msword",
fileName,
content,
ctx: createContext(user, transaction),
})
);
expect(response.text).toContain("this is a test document");
expect(response.title).toEqual("Heading 1");
});
@@ -147,13 +161,15 @@ describe("documentImporter", () => {
path.resolve(__dirname, "..", "test", "fixtures", fileName),
"utf8"
);
const response = await documentImporter({
user,
mimeType: "text/plain",
fileName,
content,
ip,
});
const response = await sequelize.transaction((transaction) =>
documentImporter({
user,
mimeType: "text/plain",
fileName,
content,
ctx: createContext(user, transaction),
})
);
expect(response.text).toContain("This is a test paragraph");
expect(response.title).toEqual("Heading 1");
});
@@ -162,13 +178,16 @@ describe("documentImporter", () => {
const user = await buildUser();
const fileName = "markdown.md";
const content = `# Title`;
const response = await documentImporter({
user,
mimeType: "text/plain",
fileName,
content,
ip,
});
const response = await sequelize.transaction((transaction) =>
documentImporter({
user,
mimeType: "text/plain",
fileName,
content,
ctx: createContext(user, transaction),
})
);
expect(response.text).toEqual("");
expect(response.title).toEqual("Title");
});
@@ -180,13 +199,15 @@ describe("documentImporter", () => {
path.resolve(__dirname, "..", "test", "fixtures", fileName),
"utf8"
);
const response = await documentImporter({
user,
mimeType: "application/lol",
fileName,
content,
ip,
});
const response = await sequelize.transaction((transaction) =>
documentImporter({
user,
mimeType: "application/lol",
fileName,
content,
ctx: createContext(user, transaction),
})
);
expect(response.text).toContain("This is a test paragraph");
expect(response.title).toEqual("Heading 1");
});
@@ -200,13 +221,15 @@ describe("documentImporter", () => {
let error;
try {
await documentImporter({
user,
mimeType: "executable/zip",
fileName,
content,
ip,
});
await sequelize.transaction((transaction) =>
documentImporter({
user,
mimeType: "executable/zip",
fileName,
content,
ctx: createContext(user, transaction),
})
);
} catch (err) {
error = err.message;
}
@@ -228,13 +251,15 @@ describe("documentImporter", () => {
</body>
</html>
`;
const response = await documentImporter({
user,
mimeType: "text/html",
fileName,
content,
ip,
});
const response = await sequelize.transaction((transaction) =>
documentImporter({
user,
mimeType: "text/html",
fileName,
content,
ctx: createContext(user, transaction),
})
);
expect(response.text).toEqual("\\$100");
});
@@ -252,13 +277,15 @@ describe("documentImporter", () => {
</body>
</html>
`;
const response = await documentImporter({
user,
mimeType: "text/html",
fileName,
content,
ip,
});
const response = await sequelize.transaction((transaction) =>
documentImporter({
user,
mimeType: "text/html",
fileName,
content,
ctx: createContext(user, transaction),
})
);
expect(response.text).toEqual("`echo $foo`");
});
@@ -276,13 +303,15 @@ describe("documentImporter", () => {
</body>
</html>
`;
const response = await documentImporter({
user,
mimeType: "text/html",
fileName,
content,
ip,
});
const response = await sequelize.transaction((transaction) =>
documentImporter({
user,
mimeType: "text/html",
fileName,
content,
ctx: createContext(user, transaction),
})
);
expect(response.text).toEqual("```\necho $foo\n```");
});
});
+4 -11
View File
@@ -1,13 +1,13 @@
import emojiRegex from "emoji-regex";
import escapeRegExp from "lodash/escapeRegExp";
import truncate from "lodash/truncate";
import { Transaction } from "sequelize";
import parseTitle from "@shared/utils/parseTitle";
import { DocumentValidation } from "@shared/validations";
import { traceFunction } from "@server/logging/tracing";
import { User } from "@server/models";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import { TextHelper } from "@server/models/helpers/TextHelper";
import { APIContext } from "@server/types";
import { DocumentConverter } from "@server/utils/DocumentConverter";
import { InvalidRequestError } from "../errors";
@@ -16,8 +16,7 @@ type Props = {
mimeType: string;
fileName: string;
content: Buffer | string;
ip?: string;
transaction?: Transaction;
ctx: APIContext;
};
async function documentImporter({
@@ -25,8 +24,7 @@ async function documentImporter({
fileName,
content,
user,
ip,
transaction,
ctx,
}: Props): Promise<{
icon?: string;
text: string;
@@ -66,12 +64,7 @@ async function documentImporter({
// Remove any closed and immediately reopened formatting marks
text = text.replace(/\*\*\*\*/gi, "").replace(/____/gi, "");
text = await TextHelper.replaceImagesWithAttachments(
text,
user,
ip,
transaction
);
text = await TextHelper.replaceImagesWithAttachments(ctx, text, user);
// Sanity check text cannot possibly be longer than state so if it is, we can short-circuit here
if (text.length > DocumentValidation.maxStateLength) {
-30
View File
@@ -1,30 +0,0 @@
import { Transaction } from "sequelize";
import { FileOperation, Event, User } from "@server/models";
type Props = {
fileOperation: FileOperation;
user: User;
ip: string;
transaction: Transaction;
};
export default async function fileOperationDeleter({
fileOperation,
user,
ip,
transaction,
}: Props) {
await fileOperation.destroy({ transaction });
await Event.create(
{
name: "fileOperations.delete",
teamId: user.teamId,
actorId: user.id,
modelId: fileOperation.id,
ip,
},
{
transaction,
}
);
}
+6 -10
View File
@@ -1,24 +1,21 @@
import { Star, Event } from "@server/models";
import { sequelize } from "@server/storage/database";
import { buildDocument, buildUser } from "@server/test/factories";
import { withAPIContext } from "@server/test/support";
import starCreator from "./starCreator";
describe("starCreator", () => {
const ip = "127.0.0.1";
it("should create star", async () => {
it("should create star for document", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const star = await sequelize.transaction(async (transaction) =>
const star = await withAPIContext(user, (ctx) =>
starCreator({
ctx,
documentId: document.id,
user,
ip,
transaction,
})
);
@@ -45,12 +42,11 @@ describe("starCreator", () => {
index: "P",
});
const star = await sequelize.transaction(async (transaction) =>
const star = await withAPIContext(user, (ctx) =>
starCreator({
ctx,
documentId: document.id,
user,
ip,
transaction,
})
);
+8 -26
View File
@@ -1,6 +1,7 @@
import fractionalIndex from "fractional-index";
import { Sequelize, Transaction, WhereOptions } from "sequelize";
import { Star, User, Event } from "@server/models";
import { Sequelize, WhereOptions } from "sequelize";
import { Star, User } from "@server/models";
import { APIContext } from "@server/types";
type Props = {
/** The user creating the star */
@@ -11,9 +12,8 @@ type Props = {
collectionId?: string;
/** The sorted index for the star in the sidebar If no index is provided then it will be at the end */
index?: string;
/** The IP address of the user creating the star */
ip: string;
transaction: Transaction;
/** The request context */
ctx: APIContext;
};
/**
@@ -27,8 +27,7 @@ export default async function starCreator({
user,
documentId,
collectionId,
ip,
transaction,
ctx,
...rest
}: Props): Promise<Star> {
let { index } = rest;
@@ -47,14 +46,14 @@ export default async function starCreator({
Sequelize.literal('"star"."index" collate "C"'),
["updatedAt", "DESC"],
],
transaction,
transaction: ctx.state.transaction,
});
// create a star at the beginning of the list
index = fractionalIndex(null, stars.length ? stars[0].index : null);
}
const [star, isCreated] = await Star.findOrCreate({
const [star] = await Star.findOrCreateWithCtx(ctx, {
where: documentId
? {
userId: user.id,
@@ -67,24 +66,7 @@ export default async function starCreator({
defaults: {
index,
},
transaction,
});
if (isCreated) {
await Event.create(
{
name: "stars.create",
teamId: user.teamId,
modelId: star.id,
userId: user.id,
actorId: user.id,
documentId,
collectionId,
ip,
},
{ transaction }
);
}
return star;
}
-40
View File
@@ -1,40 +0,0 @@
import { Event, Star } from "@server/models";
import { buildDocument, buildUser } from "@server/test/factories";
import starDestroyer from "./starDestroyer";
describe("starDestroyer", () => {
const ip = "127.0.0.1";
it("should destroy existing star", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const star = await Star.create({
documentId: document.id,
userId: user.id,
index: "P",
});
await starDestroyer({
star,
user,
ip,
});
const count = await Star.count({
where: {
userId: user.id,
},
});
expect(count).toEqual(0);
const event = await Event.findLatest({
teamId: user.teamId,
});
expect(event!.name).toEqual("stars.delete");
expect(event!.modelId).toEqual(star.id);
});
});
-43
View File
@@ -1,43 +0,0 @@
import { Transaction } from "sequelize";
import { Event, Star, User } from "@server/models";
type Props = {
/** The user destroying the star */
user: User;
/** The star to destroy */
star: Star;
/** The IP address of the user creating the star */
ip: string;
/** Optional existing transaction */
transaction?: Transaction;
};
/**
* This command destroys a document star. This just removes the star itself and
* does not touch the document
*
* @param Props The properties of the star to destroy
* @returns void
*/
export default async function starDestroyer({
user,
star,
ip,
transaction,
}: Props): Promise<Star> {
await star.destroy({ transaction });
await Event.create(
{
name: "stars.delete",
modelId: star.id,
teamId: user.teamId,
actorId: user.id,
userId: star.userId,
documentId: star.documentId,
ip,
},
{ transaction }
);
return star;
}
-37
View File
@@ -1,37 +0,0 @@
import { Event, Star } from "@server/models";
import { buildDocument, buildUser } from "@server/test/factories";
import starUpdater from "./starUpdater";
describe("starUpdater", () => {
const ip = "127.0.0.1";
it("should update (move) existing star", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
let star = await Star.create({
documentId: document.id,
userId: user.id,
index: "P",
});
star = await starUpdater({
star,
index: "h",
user,
ip,
});
const event = await Event.findLatest({
teamId: user.teamId,
});
expect(star.documentId).toEqual(document.id);
expect(star.userId).toEqual(user.id);
expect(star.index).toEqual("h");
expect(event!.name).toEqual("stars.update");
expect(event!.modelId).toEqual(star.id);
});
});
-47
View File
@@ -1,47 +0,0 @@
import { Transaction } from "sequelize";
import { Event, Star, User } from "@server/models";
type Props = {
/** The user updating the star */
user: User;
/** The existing star */
star: Star;
/** The index to star the document at */
index: string;
/** The IP address of the user creating the star */
ip: string;
/** Optional existing transaction */
transaction?: Transaction;
};
/**
* This command updates a "starred" document. A star can only be moved to a new
* index (reordered) once created.
*
* @param Props The properties of the star to update
* @returns Star The updated star
*/
export default async function starUpdater({
user,
star,
index,
ip,
transaction,
}: Props): Promise<Star> {
star.index = index;
await star.save({ transaction });
await Event.create(
{
name: "stars.update",
modelId: star.id,
userId: star.userId,
teamId: user.teamId,
actorId: user.id,
documentId: star.documentId,
ip,
},
{ transaction }
);
return star;
}
+17
View File
@@ -0,0 +1,17 @@
import { Transaction } from "sequelize";
import { User } from "@server/models";
import { APIContext } from "@server/types";
export function createContext(
user: User,
transaction?: Transaction,
ip?: string
) {
return {
context: {
ip: ip ?? user.lastActiveIp,
transaction,
auth: { user },
},
} as APIContext;
}
+1 -1
View File
@@ -143,7 +143,7 @@ export class Mailer {
Logger.info("email", `Sending email "${data.subject}" to ${data.to}`);
const info = await transporter.sendMail({
from: data.from ?? env.SMTP_FROM_EMAIL,
from: env.isCloudHosted && data.from ? data.from : env.SMTP_FROM_EMAIL,
replyTo: data.replyTo ?? env.SMTP_REPLY_EMAIL ?? env.SMTP_FROM_EMAIL,
to: data.to,
messageId: data.messageId,
+10
View File
@@ -155,6 +155,16 @@ export default function auth(options: AuthenticationOptions = {}) {
}
}
Object.defineProperty(ctx, "context", {
get() {
return {
auth: ctx.state.auth,
transaction: ctx.state.transaction,
ip: ctx.request.ip,
};
},
});
return next();
};
}
@@ -0,0 +1,15 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn("shares", "allowIndexing", {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: true,
});
},
down: async (queryInterface) => {
await queryInterface.removeColumn("shares", "allowIndexing");
},
};
+7 -1
View File
@@ -17,6 +17,7 @@ import {
import { ApiKeyValidation } from "@shared/validations";
import User from "./User";
import ParanoidModel from "./base/ParanoidModel";
import { SkipChangeset } from "./decorators/Changeset";
import Fix from "./decorators/Fix";
import Length from "./validators/Length";
@@ -28,6 +29,8 @@ class ApiKey extends ParanoidModel<
> {
static prefix = "ol_api_";
static eventNamespace = "api_keys";
@Length({
min: ApiKeyValidation.minNameLength,
max: ApiKeyValidation.maxNameLength,
@@ -48,10 +51,12 @@ class ApiKey extends ParanoidModel<
/** The hashed value of the API key */
@Unique
@Column
@SkipChangeset
hash: string;
/** The last 4 characters of the API key */
@Column
@SkipChangeset
last4: string;
@IsDate
@@ -60,6 +65,7 @@ class ApiKey extends ParanoidModel<
@IsDate
@Column
@SkipChangeset
lastActiveAt: Date | null;
// hooks
@@ -148,7 +154,7 @@ class ApiKey extends ParanoidModel<
this.lastActiveAt = new Date();
}
return this.save();
return this.save({ silent: true });
};
}
+4
View File
@@ -26,6 +26,7 @@ import Document from "./Document";
import Team from "./Team";
import User from "./User";
import IdModel from "./base/IdModel";
import { SkipChangeset } from "./decorators/Changeset";
import Fix from "./decorators/Fix";
import Length from "./validators/Length";
@@ -35,6 +36,8 @@ class Attachment extends IdModel<
InferAttributes<Attachment>,
Partial<InferCreationAttributes<Attachment>>
> {
static eventNamespace = "attachments";
@Length({
max: 4096,
msg: "key must be 4096 characters or less",
@@ -59,6 +62,7 @@ class Attachment extends IdModel<
acl: string;
@Column
@SkipChangeset
lastAccessedAt: Date | null;
@Column
+1
View File
@@ -55,6 +55,7 @@ class Event extends IdModel<
/**
* Metadata associated with the event, previously used for storing some changed attributes.
* Note that the `data` column will be visible to the client and API requests.
*/
@Column(DataType.JSONB)
data: Record<string, any> | null;
+2
View File
@@ -51,6 +51,8 @@ class FileOperation extends ParanoidModel<
InferAttributes<FileOperation>,
Partial<InferCreationAttributes<FileOperation>>
> {
static eventNamespace = "fileOperations";
@Column(DataType.ENUM(...Object.values(FileOperationType)))
type: FileOperationType;
+4
View File
@@ -184,6 +184,10 @@ class Share extends IdModel<
@Column(DataType.UUID)
documentId: string;
@Default(true)
@Column
allowIndexing: boolean;
revoke(userId: string) {
this.revokedAt = new Date();
this.revokedById = userId;
+2
View File
@@ -19,6 +19,8 @@ class Star extends IdModel<
InferAttributes<Star>,
Partial<InferCreationAttributes<Star>>
> {
static eventNamespace = "stars";
@Length({
max: 256,
msg: `index must be 256 characters or less`,
+1
View File
@@ -51,6 +51,7 @@ describe("Model", () => {
expect(document.changeset.previous.collaboratorIds).toEqual(prev);
});
});
describe("batch load", () => {
it("should return data in batches", async () => {
const team = await buildTeam();
+241 -4
View File
@@ -3,14 +3,200 @@ import isEqual from "fast-deep-equal";
import isArray from "lodash/isArray";
import isObject from "lodash/isObject";
import pick from "lodash/pick";
import { FindOptions, NonAttribute } from "sequelize";
import { Model as SequelizeModel } from "sequelize-typescript";
import { Replace } from "@server/types";
import {
Attributes,
CreateOptions,
CreationAttributes,
DataTypes,
FindOptions,
FindOrCreateOptions,
InstanceDestroyOptions,
InstanceUpdateOptions,
ModelStatic,
NonAttribute,
SaveOptions,
} from "sequelize";
import {
AfterCreate,
AfterDestroy,
AfterUpdate,
AfterUpsert,
BeforeCreate,
Model as SequelizeModel,
} from "sequelize-typescript";
import Logger from "@server/logging/Logger";
import { Replace, APIContext } from "@server/types";
import { getChangsetSkipped } from "../decorators/Changeset";
class Model<
TModelAttributes extends {} = any,
TCreationAttributes extends {} = TModelAttributes
> extends SequelizeModel<TModelAttributes, TCreationAttributes> {
/**
* The namespace to use for events, if none is provided an event will not be created
* during the migration period. In the future this may default to the table name.
*/
static eventNamespace: string | undefined;
/**
* Validates this instance, and if the validation passes, persists it to the database.
*/
public saveWithCtx(ctx: APIContext) {
this.cacheChangeset();
return this.save(ctx.context as SaveOptions);
}
/**
* This is the same as calling `set` and then calling `save`.
*/
public updateWithCtx(ctx: APIContext, keys: Partial<TModelAttributes>) {
this.set(keys);
this.cacheChangeset();
return this.save(ctx.context as SaveOptions);
}
/**
* Destroy the row corresponding to this instance. Depending on your setting for paranoid, the row will
* either be completely deleted, or have its deletedAt timestamp set to the current time.
*/
public destroyWithCtx(ctx: APIContext) {
return this.destroy(ctx.context as InstanceDestroyOptions);
}
/**
* Find a row that matches the query, or build and save the row if none is found
* The successful result of the promise will be (instance, created) - Make sure to use `.then(([...]))`
*/
public static findOrCreateWithCtx<M extends Model>(
this: ModelStatic<M>,
ctx: APIContext,
options: FindOrCreateOptions<Attributes<M>, CreationAttributes<M>>
) {
return this.findOrCreate({
...options,
...ctx.context,
});
}
/**
* Builds a new model instance and calls save on it.
*/
public static createWithCtx<M extends Model>(
this: ModelStatic<M>,
ctx: APIContext,
values?: CreationAttributes<M>
) {
return this.create(values, ctx.context as CreateOptions);
}
@BeforeCreate
static async beforeCreateEvent<T extends Model>(model: T) {
model.cacheChangeset();
}
@AfterCreate
static async afterCreateEvent<T extends Model>(
model: T,
context: APIContext["context"]
) {
await this.insertEvent("create", model, context);
}
@AfterUpsert
static async afterUpsertEvent<T extends Model>(
model: T,
context: APIContext["context"]
) {
await this.insertEvent("create", model, context);
}
@AfterUpdate
static async afterUpdateEvent<T extends Model>(
model: T,
context: APIContext["context"]
) {
await this.insertEvent("update", model, context);
}
@AfterDestroy
static async afterDestroyEvent<T extends Model>(
model: T,
context: APIContext["context"]
) {
await this.insertEvent("delete", model, context);
}
/**
* Insert an event into the database recording a mutation to this model.
*
* @param name The name of the event.
* @param model The model that was mutated.
* @param context The API context.
*/
protected static async insertEvent<T extends Model>(
name: string,
model: T,
context: APIContext["context"] & InstanceUpdateOptions
) {
const namespace = this.eventNamespace;
const models = this.sequelize!.models;
// If no namespace is defined, don't create an event
if (!namespace || context.silent) {
return;
}
if (!context.transaction) {
Logger.warn("No transaction provided to insertEvent", {
modelId: model.id,
});
}
if (!context.ip) {
Logger.warn("No ip provided to insertEvent", {
modelId: model.id,
});
}
return models.event.create(
{
name: `${namespace}.${name}`,
modelId: model.id,
collectionId:
"collectionId" in model
? model.collectionId
: model instanceof models.collection
? model.id
: undefined,
documentId:
"documentId" in model
? model.documentId
: model instanceof models.document
? model.id
: undefined,
userId:
"userId" in model
? model.userId
: model instanceof models.user
? model.id
: undefined,
teamId:
"teamId" in model
? model.teamId
: model instanceof models.team
? model.id
: context.auth?.user.teamId,
actorId: context.auth?.user?.id,
authType: context.auth?.type,
ip: context.ip,
changes: model.previousChangeset,
},
{
transaction: context.transaction,
}
);
}
/**
* Find all models in batches, calling the callback function for each batch.
*
@@ -38,7 +224,7 @@ class Model<
}
/**
* Returns the attributes that have changed since the last save and their previous values.
* Returns a representation of the attributes that have changed since the last save and their previous values.
*
* @returns An object with `attributes` and `previousAttributes` keys.
*/
@@ -57,10 +243,22 @@ class Model<
};
}
const virtualFields = (this.constructor as typeof Model).virtualFields;
const blobFields = (this.constructor as typeof Model).blobFields;
const skippedFields = getChangsetSkipped(this);
for (const change of changes) {
const previous = this.previous(change);
const current = this.getDataValue(change);
if (
virtualFields.includes(String(change)) ||
blobFields.includes(String(change)) ||
skippedFields.includes(String(change))
) {
continue;
}
if (
isObject(previous) &&
isObject(current) &&
@@ -91,6 +289,45 @@ class Model<
previous: previousAttributes,
};
}
/**
* Cache the current changeset for later use.
*/
protected cacheChangeset() {
const previous = this.changeset;
if (
Object.keys(previous.attributes).length > 0 ||
Object.keys(previous.previous).length > 0
) {
this.previousChangeset = previous;
}
}
/**
* Returns the virtual fields for this model.
*/
protected static get virtualFields() {
const attrs = this.rawAttributes;
return Object.keys(attrs).filter(
(attr) => attrs[attr].type instanceof DataTypes.VIRTUAL
);
}
/**
* Returns the blob fields for this model.
*/
protected static get blobFields() {
const attrs = this.rawAttributes;
return Object.keys(attrs).filter(
(attr) => attrs[attr].type instanceof DataTypes.BLOB
);
}
private previousChangeset: NonAttribute<{
attributes: Partial<TModelAttributes>;
previous: Partial<TModelAttributes>;
}> | null;
}
export default Model;
+23
View File
@@ -0,0 +1,23 @@
import "reflect-metadata";
const key = Symbol("skipChangeset");
/**
* This decorator is used to annotate a property as being skipped from being included in a changeset.
*/
export function SkipChangeset(target: any, propertyKey: string) {
const properties: string[] = Reflect.getMetadata(key, target);
if (!properties) {
return Reflect.defineMetadata(key, [propertyKey], target);
}
properties.push(propertyKey);
}
/**
* This function is used to get the properties that should be skipped from a changeset.
*/
export function getChangsetSkipped(target: any): string[] {
return Reflect.getMetadata(key, target) || [];
}
+1 -1
View File
@@ -558,7 +558,7 @@ export class ProsemirrorHelper {
// Inject Mermaid script
if (mermaidElements.length) {
element.innerHTML = `
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@9/dist/mermaid.esm.min.mjs';
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
mermaid.initialize({
startOnLoad: true,
fontFamily: "inherit",
+5 -8
View File
@@ -1,7 +1,6 @@
import chunk from "lodash/chunk";
import escapeRegExp from "lodash/escapeRegExp";
import startCase from "lodash/startCase";
import { Transaction } from "sequelize";
import { AttachmentPreset } from "@shared/types";
import {
getCurrentDateAsString,
@@ -14,6 +13,7 @@ import env from "@server/env";
import { trace } from "@server/logging/tracing";
import { Attachment, User } from "@server/models";
import FileStorage from "@server/storage/files";
import { APIContext } from "@server/types";
import parseAttachmentIds from "@server/utils/parseAttachmentIds";
import parseImages from "@server/utils/parseImages";
@@ -83,17 +83,15 @@ export class TextHelper {
* Replaces remote and base64 encoded images in the given text with attachment
* urls and uploads the images to the storage provider.
*
* @param ctx The API context
* @param markdown The text to replace the images in
* @param user The user context
* @param ip The IP address of the user
* @param transaction The transaction to use for the database operations
* @returns The text with the images replaced
*/
static async replaceImagesWithAttachments(
ctx: APIContext,
markdown: string,
user: User,
ip?: string,
transaction?: Transaction
user: User
) {
let output = markdown;
const images = parseImages(markdown);
@@ -117,11 +115,10 @@ export class TextHelper {
url: image.src,
preset: AttachmentPreset.DocumentAttachment,
user,
ip,
transaction,
fetchOptions: {
timeout: timeoutPerImage,
},
ctx,
});
if (attachment) {
+13 -1
View File
@@ -119,7 +119,19 @@ allow(User, "publish", Document, (actor, document) =>
)
);
allow(User, ["manageUsers", "duplicate"], Document, (actor, document) =>
allow(User, "manageUsers", Document, (actor, document) =>
and(
!document?.template,
can(actor, "update", document),
or(
includesMembership(document, [DocumentPermission.Admin]),
can(actor, "updateDocument", document?.collection),
!!document?.isDraft && actor.id === document?.createdById
)
)
);
allow(User, "duplicate", Document, (actor, document) =>
and(
can(actor, "update", document),
or(
+2
View File
@@ -13,10 +13,12 @@ export default function presentEvent(event: Event, isAdmin = false) {
documentId: event.documentId,
createdAt: event.createdAt,
data: event.data,
changes: event.changes || undefined,
actor: presentUser(event.actor),
};
if (!isAdmin) {
delete data.changes;
delete data.actorIpAddress;
}
+1
View File
@@ -12,6 +12,7 @@ export default function presentShare(share: Share, isAdmin = false) {
urlId: share.urlId,
createdBy: presentUser(share.user),
includeChildDocuments: share.includeChildDocuments,
allowIndexing: share.allowIndexing,
lastAccessedAt: share.lastAccessedAt || undefined,
views: share.views || 0,
domain: share.domain,
@@ -5,8 +5,11 @@ import { Event as TEvent, CollectionEvent } from "@server/types";
import DetachDraftsFromCollectionTask from "../tasks/DetachDraftsFromCollectionTask";
import BaseProcessor from "./BaseProcessor";
export default class CollectionDeletedProcessor extends BaseProcessor {
static applicableEvents: TEvent["name"][] = ["collections.delete"];
export default class CollectionsProcessor extends BaseProcessor {
static applicableEvents: TEvent["name"][] = [
"collections.delete",
"collections.archive",
];
async perform(event: CollectionEvent) {
await DetachDraftsFromCollectionTask.schedule({
@@ -852,6 +852,8 @@ export default class WebsocketsProcessor {
channels.push(
...this.getCollectionEventChannels(event, document.collection)
);
} else if (document.isWorkspaceTemplate) {
channels.push(`team-${document.teamId}`);
} else {
channels.push(`collection-${document.collectionId}`);
}
@@ -27,4 +27,28 @@ describe("DetachDraftsFromCollectionTask", () => {
expect(draft?.deletedAt).toBe(null);
expect(draft?.collectionId).toBe(null);
});
it("should detach drafts from archived collection", async () => {
const collection = await buildCollection({ archivedAt: new Date() });
const document = await buildDocument({
title: "test",
collectionId: collection.id,
publishedAt: null,
createdById: collection.createdById,
teamId: collection.teamId,
});
const task = new DetachDraftsFromCollectionTask();
await task.perform({
collectionId: collection.id,
ip,
actorId: collection.createdById,
});
const draft = await Document.findByPk(document.id);
expect(draft).not.toBe(null);
expect(draft?.archivedAt).toBe(null);
expect(draft?.deletedAt).toBe(null);
expect(draft?.collectionId).toBe(null);
});
});
@@ -19,7 +19,11 @@ export default class DetachDraftsFromCollectionTask extends BaseTask<Props> {
User.findByPk(props.actorId),
]);
if (!actor || !collection || !collection.deletedAt) {
if (
!actor ||
!collection ||
!(collection.deletedAt || collection.archivedAt)
) {
return;
}
+3 -4
View File
@@ -1,6 +1,7 @@
import { SourceMetadata } from "@shared/types";
import documentCreator from "@server/commands/documentCreator";
import documentImporter from "@server/commands/documentImporter";
import { createContext } from "@server/context";
import { User } from "@server/models";
import { sequelize } from "@server/storage/database";
import FileStorage from "@server/storage/files";
@@ -48,8 +49,7 @@ export default class DocumentImportTask extends BaseTask<Props> {
fileName: sourceMetadata.fileName,
mimeType: sourceMetadata.mimeType,
content,
ip,
transaction,
ctx: createContext(user, transaction, ip),
});
return documentCreator({
@@ -62,8 +62,7 @@ export default class DocumentImportTask extends BaseTask<Props> {
collectionId,
parentDocumentId,
user,
ip,
transaction,
ctx: createContext(user, transaction, ip),
});
});
return { documentId: document.id };
@@ -40,7 +40,7 @@ export default class DocumentPublishedNotificationsTask extends BaseTask<Documen
await Notification.create({
event: NotificationEventType.MentionedInDocument,
userId: recipient.id,
actorId: document.updatedBy.id,
actorId: mention.actorId,
teamId: document.teamId,
documentId: document.id,
});
@@ -34,7 +34,7 @@ export default class ErrorTimedOutFileOperationsTask extends BaseTask<Props> {
fileOperations.map(async (fileOperation) => {
fileOperation.state = FileOperationState.Error;
fileOperation.error = "Timed out";
await fileOperation.save();
await fileOperation.save({ hooks: false });
})
);
Logger.info("task", `Updated ${fileOperations.length} file operations`);
+11 -6
View File
@@ -157,12 +157,17 @@ export default abstract class ExportTask extends BaseTask<Props> {
fileOperation: FileOperation,
options: Partial<FileOperation> & { error?: Error }
) {
await fileOperation.update({
...options,
error: options.error
? truncate(options.error.message, { length: 255 })
: undefined,
});
await fileOperation.update(
{
...options,
error: options.error
? truncate(options.error.message, { length: 255 })
: undefined,
},
{
hooks: false,
}
);
await Event.schedule({
name: "fileOperations.update",
+15 -10
View File
@@ -4,9 +4,11 @@ import escapeRegExp from "lodash/escapeRegExp";
import mime from "mime-types";
import { v4 as uuidv4 } from "uuid";
import documentImporter from "@server/commands/documentImporter";
import { createContext } from "@server/context";
import Logger from "@server/logging/Logger";
import { FileOperation, User } from "@server/models";
import { Buckets } from "@server/models/helpers/AttachmentHelper";
import { sequelize } from "@server/storage/database";
import ImportHelper, { FileTreeNode } from "@server/utils/ImportHelper";
import ImportTask, { StructuredImportData } from "./ImportTask";
@@ -82,16 +84,19 @@ export default class ImportMarkdownZipTask extends ImportTask {
return;
}
const { title, icon, text } = await documentImporter({
mimeType: "text/markdown",
fileName: child.name,
content:
child.children.length > 0
? ""
: await fs.readFile(child.path, "utf8"),
user,
ip: user.lastActiveIp || undefined,
});
const { title, icon, text } = await sequelize.transaction(
async (transaction) =>
documentImporter({
mimeType: "text/markdown",
fileName: child.name,
content:
child.children.length > 0
? ""
: await fs.readFile(child.path, "utf8"),
user,
ctx: createContext(user, transaction),
})
);
const existingDocumentIndex = output.documents.findIndex(
(doc) =>
+54 -19
View File
@@ -5,8 +5,10 @@ import escapeRegExp from "lodash/escapeRegExp";
import mime from "mime-types";
import { v4 as uuidv4 } from "uuid";
import documentImporter from "@server/commands/documentImporter";
import { createContext } from "@server/context";
import Logger from "@server/logging/Logger";
import { FileOperation, User } from "@server/models";
import { sequelize } from "@server/storage/database";
import ImportHelper, { FileTreeNode } from "@server/utils/ImportHelper";
import ImportTask, { StructuredImportData } from "./ImportTask";
@@ -22,17 +24,45 @@ export default class ImportNotionTask extends ImportTask {
// New Notion exports have a single folder with the name of the export, we must skip this
// folder and go directly to the children.
let parsed;
if (
tree.children.length === 1 &&
tree.children[0].children.find((child) => child.title === "index")
) {
return this.parseFileTree(
parsed = await this.parseFileTree(
fileOperation,
tree.children[0].children.filter((child) => child.title !== "index")
);
} else {
parsed = await this.parseFileTree(fileOperation, tree.children);
}
return this.parseFileTree(fileOperation, tree.children);
if (parsed.documents.length === 0 && parsed.collections.length === 1) {
const collection = parsed.collections[0];
const collectionId = uuidv4();
if (collection.description) {
parsed.documents.push({
title: collection.name,
icon: collection.icon,
color: collection.color,
path: "",
text: String(collection.description),
id: collection.id,
externalId: collection.externalId,
mimeType: "text/html",
collectionId,
});
}
collection.name = "Notion";
collection.icon = undefined;
collection.color = undefined;
collection.externalId = undefined;
collection.description = undefined;
collection.id = collectionId;
}
return parsed;
}
/**
@@ -96,16 +126,19 @@ export default class ImportNotionTask extends ImportTask {
Logger.debug("task", `Processing ${name} as ${mimeType}`);
const { title, icon, text } = await documentImporter({
mimeType: mimeType || "text/markdown",
fileName: name,
content:
child.children.length > 0
? ""
: await fs.readFile(child.path, "utf8"),
user,
ip: user.lastActiveIp || undefined,
});
const { title, icon, text } = await sequelize.transaction(
async (transaction) =>
documentImporter({
mimeType: mimeType || "text/markdown",
fileName: name,
content:
child.children.length > 0
? ""
: await fs.readFile(child.path, "utf8"),
user,
ctx: createContext(user, transaction),
})
);
const existingDocumentIndex = output.documents.findIndex(
(doc) => doc.externalId === externalId
@@ -218,13 +251,15 @@ export default class ImportNotionTask extends ImportTask {
mimeType === "text/plain" ||
mimeType === "text/html"
) {
const { text } = await documentImporter({
mimeType,
fileName: name,
content: await fs.readFile(node.path, "utf8"),
user,
ip: user.lastActiveIp || undefined,
});
const { text } = await sequelize.transaction(async (transaction) =>
documentImporter({
mimeType,
fileName: name,
content: await fs.readFile(node.path, "utf8"),
user,
ctx: createContext(user, transaction),
})
);
description = text;
} else if (node.children.length > 0) {
+12 -8
View File
@@ -14,6 +14,7 @@ import {
import { CollectionValidation } from "@shared/validations";
import attachmentCreator from "@server/commands/attachmentCreator";
import documentCreator from "@server/commands/documentCreator";
import { createContext } from "@server/context";
import { serializer } from "@server/editor";
import { InternalError, ValidationError } from "@server/errors";
import Logger from "@server/logging/Logger";
@@ -183,10 +184,15 @@ export default abstract class ImportTask extends BaseTask<Props> {
state: FileOperationState,
error?: Error
) {
await fileOperation.update({
state,
error: error ? truncate(error.message, { length: 255 }) : undefined,
});
await fileOperation.update(
{
state,
error: error ? truncate(error.message, { length: 255 }) : undefined,
},
{
hooks: false,
}
);
await Event.schedule({
name: "fileOperations.update",
modelId: fileOperation.id,
@@ -468,8 +474,7 @@ export default abstract class ImportTask extends BaseTask<Props> {
parentDocumentId: item.parentDocumentId,
importId: fileOperation.id,
user,
ip,
transaction,
ctx: createContext(user, transaction),
});
documents.set(item.id, document);
@@ -503,8 +508,7 @@ export default abstract class ImportTask extends BaseTask<Props> {
type: item.mimeType,
buffer: await item.buffer(),
user,
ip,
transaction,
ctx: createContext(user, transaction),
});
if (attachment) {
attachments.set(item.id, attachment);
@@ -60,7 +60,7 @@ export default class RevisionCreatedNotificationsTask extends BaseTask<RevisionE
event: NotificationEventType.MentionedInDocument,
userId: recipient.id,
revisionId: event.modelId,
actorId: document.updatedBy.id,
actorId: mention.actorId,
teamId: document.teamId,
documentId: document.id,
});
+13 -30
View File
@@ -4,7 +4,7 @@ import { UserRole } from "@shared/types";
import auth from "@server/middlewares/authentication";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate";
import { ApiKey, Event, User } from "@server/models";
import { ApiKey, User } from "@server/models";
import { authorize, cannot } from "@server/policies";
import { presentApiKey } from "@server/presenters";
import { APIContext, AuthenticationType } from "@server/types";
@@ -21,28 +21,17 @@ router.post(
async (ctx: APIContext<T.APIKeysCreateReq>) => {
const { name, expiresAt } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
authorize(user, "createApiKey", user.team);
const key = await ApiKey.create(
{
name,
userId: user.id,
expiresAt,
},
{ transaction }
);
await Event.createFromContext(ctx, {
name: "api_keys.create",
modelId: key.id,
data: {
name,
},
const apiKey = await ApiKey.createWithCtx(ctx, {
name,
userId: user.id,
expiresAt,
});
ctx.body = {
data: presentApiKey(key),
data: presentApiKey(apiKey),
};
}
);
@@ -54,6 +43,7 @@ router.post(
validate(T.APIKeysListSchema),
async (ctx: APIContext<T.APIKeysListReq>) => {
const { userId } = ctx.input.body;
const { pagination } = ctx.state;
const actor = ctx.state.auth.user;
let where: WhereOptions<User> = {
@@ -77,7 +67,7 @@ router.post(
};
}
const keys = await ApiKey.findAll({
const apiKeys = await ApiKey.findAll({
include: [
{
model: User,
@@ -86,13 +76,13 @@ router.post(
},
],
order: [["createdAt", "DESC"]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
offset: pagination.offset,
limit: pagination.limit,
});
ctx.body = {
pagination: ctx.state.pagination,
data: keys.map(presentApiKey),
pagination,
data: apiKeys.map(presentApiKey),
};
}
);
@@ -113,14 +103,7 @@ router.post(
});
authorize(user, "delete", key);
await key.destroy({ transaction });
await Event.createFromContext(ctx, {
name: "api_keys.delete",
modelId: key.id,
data: {
name: key.name,
},
});
await key.destroyWithCtx(ctx);
ctx.body = {
success: true,
+12 -25
View File
@@ -8,7 +8,7 @@ import auth from "@server/middlewares/authentication";
import { rateLimiter } from "@server/middlewares/rateLimiter";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate";
import { Attachment, Document, Event } from "@server/models";
import { Attachment, Document } from "@server/models";
import AttachmentHelper from "@server/models/helpers/AttachmentHelper";
import { authorize } from "@server/policies";
import { presentAttachment } from "@server/presenters";
@@ -64,26 +64,16 @@ router.post(
userId: user.id,
});
const attachment = await Attachment.create(
{
id: modelId,
key,
acl,
size,
expiresAt: AttachmentHelper.presetToExpiry(preset),
contentType,
documentId,
teamId: user.teamId,
userId: user.id,
},
{ transaction }
);
await Event.createFromContext(ctx, {
name: "attachments.create",
data: {
name,
},
modelId,
const attachment = await Attachment.createWithCtx(ctx, {
id: modelId,
key,
acl,
size,
expiresAt: AttachmentHelper.presetToExpiry(preset),
contentType,
documentId,
teamId: user.teamId,
userId: user.id,
});
const presignedPost = await FileStorage.getPresignedPost(
@@ -139,10 +129,7 @@ router.post(
}
authorize(user, "delete", attachment);
await attachment.destroy({ transaction });
await Event.createFromContext(ctx, {
name: "attachments.delete",
});
await attachment.destroyWithCtx(ctx);
ctx.body = {
success: true,
+14 -30
View File
@@ -165,7 +165,6 @@ router.post(
async (ctx: APIContext<T.CollectionsImportReq>) => {
const { transaction } = ctx.state;
const { attachmentId, permission, format } = ctx.input.body;
const { user } = ctx.state.auth;
authorize(user, "importCollection", user.team);
@@ -174,29 +173,16 @@ router.post(
});
authorize(user, "read", attachment);
const fileOperation = await FileOperation.create(
{
type: FileOperationType.Import,
state: FileOperationState.Creating,
format,
size: attachment.size,
key: attachment.key,
userId: user.id,
teamId: user.teamId,
options: {
permission,
},
},
{
transaction,
}
);
await Event.createFromContext(ctx, {
name: "fileOperations.create",
modelId: fileOperation.id,
data: {
type: FileOperationType.Import,
await FileOperation.createWithCtx(ctx, {
type: FileOperationType.Import,
state: FileOperationState.Creating,
format,
size: attachment.size,
key: attachment.key,
userId: user.id,
teamId: user.teamId,
options: {
permission,
},
});
@@ -560,8 +546,8 @@ router.post(
validate(T.CollectionsExportSchema),
transaction(),
async (ctx: APIContext<T.CollectionsExportReq>) => {
const { transaction } = ctx.state;
const { id, format, includeAttachments } = ctx.input.body;
const { transaction } = ctx.state;
const { user } = ctx.state.auth;
const team = await Team.findByPk(user.teamId, { transaction });
@@ -578,8 +564,7 @@ router.post(
team,
format,
includeAttachments,
ip: ctx.request.ip,
transaction,
ctx,
});
ctx.body = {
@@ -598,9 +583,9 @@ router.post(
validate(T.CollectionsExportAllSchema),
transaction(),
async (ctx: APIContext<T.CollectionsExportAllReq>) => {
const { transaction } = ctx.state;
const { format, includeAttachments } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const team = await Team.findByPk(user.teamId, { transaction });
authorize(user, "createExport", team);
@@ -609,8 +594,7 @@ router.post(
team,
format,
includeAttachments,
ip: ctx.request.ip,
transaction,
ctx,
});
ctx.body = {
+167 -21
View File
@@ -2907,13 +2907,19 @@ describe("#documents.restore", () => {
expect(body.message).toEqual("collectionId: Invalid uuid");
});
it("should allow restore of trashed documents", async () => {
it("should allow restore of trashed drafts of a collection", async () => {
const user = await buildUser();
const document = await buildDocument({
const collection = await buildCollection({
userId: user.id,
teamId: user.teamId,
});
const document = await buildDraftDocument({
userId: user.id,
teamId: user.teamId,
collectionId: collection.id,
});
await document.destroy();
const res = await server.post("/api/documents.restore", {
body: {
token: user.getJwtToken(),
@@ -2921,17 +2927,46 @@ describe("#documents.restore", () => {
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.deletedAt).toEqual(null);
expect(body.data.collectionId).toEqual(collection.id);
});
it("should allow restore of trashed drafts without collection", async () => {
it("should allow restore of trashed drafts with collectionId", async () => {
const user = await buildUser();
const document = await buildDraftDocument({
const collection = await buildCollection({
userId: user.id,
teamId: user.teamId,
});
await document.delete(user);
const document = await buildDraftDocument({
userId: user.id,
teamId: user.teamId,
collectionId: null,
});
await document.destroy();
const res = await server.post("/api/documents.restore", {
body: {
token: user.getJwtToken(),
id: document.id,
collectionId: collection.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.deletedAt).toEqual(null);
expect(body.data.collectionId).toEqual(collection.id);
});
it("should allow restore of trashed documents", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
await document.destroy();
const res = await server.post("/api/documents.restore", {
body: {
token: user.getJwtToken(),
@@ -2967,7 +3002,127 @@ describe("#documents.restore", () => {
expect(body.data.collectionId).toEqual(collection.id);
});
it("should not allow restore of documents in deleted collection", async () => {
it("should allow restore of documents from a deleted collection", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({
userId: user.id,
teamId: team.id,
});
const anotherCollection = await buildCollection({
userId: user.id,
teamId: team.id,
});
const document = await buildDocument({
userId: user.id,
collectionId: collection.id,
teamId: team.id,
});
await document.delete(user);
await collection.destroy({ hooks: false });
const res = await server.post("/api/documents.restore", {
body: {
token: user.getJwtToken(),
id: document.id,
collectionId: anotherCollection.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.deletedAt).toEqual(null);
expect(body.data.archivedAt).toEqual(null);
expect(body.data.collectionId).toEqual(anotherCollection.id);
});
it("should allow restore of collection templates", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({
userId: user.id,
teamId: team.id,
});
const template = await buildDocument({
template: true,
userId: user.id,
collectionId: collection.id,
teamId: team.id,
});
await template.delete(user);
const res = await server.post("/api/documents.restore", {
body: {
token: user.getJwtToken(),
id: template.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.deletedAt).toEqual(null);
expect(body.data.collectionId).toEqual(collection.id);
});
it("should allow restore of templates from a deleted collection", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({
userId: user.id,
teamId: team.id,
});
const anotherCollection = await buildCollection({
userId: user.id,
teamId: team.id,
});
const template = await buildDocument({
template: true,
userId: user.id,
collectionId: collection.id,
teamId: team.id,
});
await template.delete(user);
await collection.destroy({ hooks: false });
const res = await server.post("/api/documents.restore", {
body: {
token: user.getJwtToken(),
id: template.id,
collectionId: anotherCollection.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.deletedAt).toEqual(null);
expect(body.data.collectionId).toEqual(anotherCollection.id);
});
it("should allow restore of workspace templates", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const template = await buildDocument({
template: true,
userId: user.id,
teamId: team.id,
collectionId: null,
});
await template.delete(user);
const res = await server.post("/api/documents.restore", {
body: {
token: user.getJwtToken(),
id: template.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.deletedAt).toEqual(null);
expect(body.data.collectionId).toEqual(null);
});
it("should not allow restore of documents to a deleted collection", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({
@@ -2979,9 +3134,9 @@ describe("#documents.restore", () => {
collectionId: collection.id,
teamId: team.id,
});
await document.destroy();
await document.delete(user);
await collection.destroy({ hooks: false });
// passing deleted collection's id
const res = await server.post("/api/documents.restore", {
body: {
token: user.getJwtToken(),
@@ -2989,26 +3144,15 @@ describe("#documents.restore", () => {
collectionId: collection.id,
},
});
// not passing collection's id
const anotherRes = await server.post("/api/documents.restore", {
body: {
token: user.getJwtToken(),
id: document.id,
},
});
const body = await res.json();
const anotherBody = await anotherRes.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual(
"Unable to restore, the collection may have been deleted or archived"
);
expect(anotherRes.status).toEqual(400);
expect(anotherBody.message).toEqual(
"Unable to restore, the collection may have been deleted or archived"
);
});
it("should not allow restore of documents in archived collection", async () => {
it("should not allow restore of documents to an archived collection", async () => {
const user = await buildUser();
const collection = await buildCollection({
teamId: user.teamId,
@@ -3021,6 +3165,7 @@ describe("#documents.restore", () => {
await document.destroy();
collection.archivedAt = new Date();
await collection.save();
const res = await server.post("/api/documents.restore", {
body: {
token: user.getJwtToken(),
@@ -3029,6 +3174,7 @@ describe("#documents.restore", () => {
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual(
"Unable to restore, the collection may have been deleted or archived"
+21 -15
View File
@@ -819,7 +819,7 @@ router.post(
const srcCollection = sourceCollectionId
? await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(sourceCollectionId)
}).findByPk(sourceCollectionId, { paranoid: false })
: undefined;
const destCollection = destCollectionId
@@ -828,13 +828,15 @@ router.post(
}).findByPk(destCollectionId)
: undefined;
if (!destCollection?.isActive) {
// In case of workspace templates, both source and destination collections are undefined.
if (!document.isWorkspaceTemplate && !destCollection?.isActive) {
throw ValidationError(
"Unable to restore, the collection may have been deleted or archived"
);
}
if (sourceCollectionId !== destCollectionId) {
// Skip this for workspace templates and drafts of a deleted collection as they won't have sourceCollectionId.
if (sourceCollectionId && sourceCollectionId !== destCollectionId) {
authorize(user, "updateDocument", srcCollection);
await srcCollection?.removeDocumentInStructure(document, {
save: true,
@@ -842,7 +844,19 @@ router.post(
});
}
if (document.deletedAt) {
if (document.deletedAt && document.isWorkspaceTemplate) {
authorize(user, "restore", document);
await document.restore({ transaction });
await Event.createFromContext(ctx, {
name: "documents.restore",
documentId: document.id,
collectionId: document.collectionId,
data: {
title: document.title,
},
});
} else if (document.deletedAt) {
authorize(user, "restore", document);
authorize(user, "updateDocument", destCollection);
@@ -1272,10 +1286,9 @@ router.post(
document,
title,
publish,
transaction,
recursive,
parentDocumentId,
ip: ctx.request.ip,
ctx,
});
ctx.body = {
@@ -1534,7 +1547,6 @@ router.post(
collectionId,
parentDocumentId,
publish,
ip: ctx.request.ip,
});
const response: DocumentImportTaskResponse = await job.finished();
if ("error" in response) {
@@ -1629,12 +1641,7 @@ router.post(
const document = await documentCreator({
title,
text: await TextHelper.replaceImagesWithAttachments(
text,
user,
ctx.request.ip,
transaction
),
text: await TextHelper.replaceImagesWithAttachments(ctx, text, user),
icon,
color,
createdAt,
@@ -1646,8 +1653,7 @@ router.post(
fullWidth,
user,
editorVersion,
ip: ctx.request.ip,
transaction,
ctx,
});
if (collection) {
@@ -282,6 +282,7 @@ describe("#fileOperations.delete", () => {
expect(
await Event.count({
where: {
name: "fileOperations.delete",
teamId: team.id,
},
})
@@ -1,7 +1,6 @@
import Router from "koa-router";
import { WhereOptions } from "sequelize";
import { UserRole } from "@shared/types";
import fileOperationDeleter from "@server/commands/fileOperationDeleter";
import { ValidationError } from "@server/errors";
import auth from "@server/middlewares/authentication";
import { transaction } from "@server/middlewares/transaction";
@@ -116,15 +115,11 @@ router.post(
const fileOperation = await FileOperation.unscoped().findByPk(id, {
rejectOnEmpty: true,
transaction,
lock: transaction.LOCK.UPDATE,
});
authorize(user, "delete", fileOperation);
await fileOperationDeleter({
fileOperation,
user,
ip: ctx.request.ip,
transaction,
});
await fileOperation.destroyWithCtx(ctx);
ctx.body = {
success: true,
+1
View File
@@ -51,6 +51,7 @@ export const SharesUpdateSchema = BaseSchema.extend({
id: z.string().uuid(),
includeChildDocuments: z.boolean().optional(),
published: z.boolean().optional(),
allowIndexing: z.boolean().optional(),
urlId: z
.string()
.regex(UrlHelper.SHARE_URL_SLUG_REGEX, {
+6 -1
View File
@@ -230,7 +230,8 @@ router.post(
auth(),
validate(T.SharesUpdateSchema),
async (ctx: APIContext<T.SharesUpdateReq>) => {
const { id, includeChildDocuments, published, urlId } = ctx.input.body;
const { id, includeChildDocuments, published, urlId, allowIndexing } =
ctx.input.body;
const { user } = ctx.state.auth;
authorize(user, "share", user.team);
@@ -257,6 +258,10 @@ router.post(
share.urlId = urlId;
}
if (allowIndexing !== undefined) {
share.allowIndexing = allowIndexing;
}
await share.save();
await Event.createFromContext(ctx, {
name: "shares.update",
+4 -13
View File
@@ -1,8 +1,6 @@
import Router from "koa-router";
import { Sequelize } from "sequelize";
import starCreator from "@server/commands/starCreator";
import starDestroyer from "@server/commands/starDestroyer";
import starUpdater from "@server/commands/starUpdater";
import auth from "@server/middlewares/authentication";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate";
@@ -46,12 +44,11 @@ router.post(
}
const star = await starCreator({
ctx,
user,
documentId,
collectionId,
ip: ctx.request.ip,
index,
transaction,
});
ctx.body = {
@@ -130,19 +127,13 @@ router.post(
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
let star = await Star.findByPk(id, {
const star = await Star.findByPk(id, {
transaction,
lock: transaction.LOCK.UPDATE,
});
authorize(user, "update", star);
star = await starUpdater({
user,
star,
ip: ctx.request.ip,
index,
transaction,
});
await star.updateWithCtx(ctx, { index });
ctx.body = {
data: presentStar(star),
@@ -167,7 +158,7 @@ router.post(
});
authorize(user, "delete", star);
await starDestroyer({ user, star, ip: ctx.request.ip, transaction });
await star.destroyWithCtx(ctx);
ctx.body = {
success: true,
+10 -2
View File
@@ -54,6 +54,7 @@ export const renderApp = async (
rootShareId?: string;
isShare?: boolean;
analytics?: Integration<IntegrationType.Analytics>[];
allowIndexing?: boolean;
} = {}
) => {
const {
@@ -61,6 +62,7 @@ export const renderApp = async (
description = "A modern team knowledge base for your internal documentation, product specs, support answers, meeting notes, onboarding, &amp; more…",
canonical = "",
shortcutIcon = `${env.CDN_URL || ""}/images/favicon-32.png`,
allowIndexing = true,
} = options;
if (ctx.request.path === "/realtime/") {
@@ -91,6 +93,10 @@ export const renderApp = async (
</script>
`;
const noIndexTag = allowIndexing
? ""
: '<meta name="robots" content="noindex, nofollow">';
const scriptTags = env.isProduction
? `<script type="module" nonce="${ctx.state.cspNonce}" src="${
env.CDN_URL || ""
@@ -112,6 +118,7 @@ export const renderApp = async (
.replace(/\{lang\}/g, unicodeCLDRtoISO639(env.DEFAULT_LANGUAGE))
.replace(/\{title\}/g, escape(title))
.replace(/\{description\}/g, escape(description))
.replace(/\{noindex\}/g, noIndexTag)
.replace(
/\{manifest-url\}/g,
options.isShare ? "" : "/static/manifest.webmanifest"
@@ -131,8 +138,8 @@ export const renderShare = async (ctx: Context, next: Next) => {
const documentSlug = ctx.params.documentSlug;
// Find the share record if publicly published so that the document title
// can be be returned in the server-rendered HTML. This allows it to appear in
// unfurls with more reliablity
// can be returned in the server-rendered HTML. This allows it to appear in
// unfurls with more reliability
let share, document, team;
let analytics: Integration<IntegrationType.Analytics>[] = [];
@@ -188,5 +195,6 @@ export const renderShare = async (ctx: Context, next: Next) => {
canonical: share
? `${share.canonicalUrl}${documentSlug && document ? document.url : ""}`
: undefined,
allowIndexing: share?.allowIndexing,
});
};
+95
View File
@@ -151,5 +151,100 @@ ${resizeObserverScript(ctx)}
return;
}
if (
parsed.host.endsWith("pinterest.com") &&
parsed.protocol === "https:" &&
ctx.path === "/embeds/pinterest"
) {
const pinterestJs = "https://assets.pinterest.com/js/pinit.js";
const csp = ctx.response.get("Content-Security-Policy");
const pathParts = parsed.pathname.split("/").filter(Boolean);
const isProfile =
pathParts.length === 1 ||
(pathParts.length === 2 && pathParts[1].startsWith("_"));
const pinType = isProfile ? "embedUser" : "embedBoard";
ctx.set(
"Content-Security-Policy",
csp
.replace(
"script-src",
"script-src assets.pinterest.com widgets.pinterest.com"
)
.replace(
"style-src",
"style-src assets.pinterest.com widgets.pinterest.com"
)
);
ctx.set("X-Frame-Options", "sameorigin");
ctx.type = "html";
ctx.body = `
<html>
<head>
<style>
html, body, iframe {
margin: 0;
padding: 0;
width: 100%;
min-height: 100px;
}
.pinterest-container {
width: 100%;
max-width: 100vw;
display: flex;
justify-content: center;
}
.pinterest-container > span {
width: 100% !important;
max-width: none !important;
}
.pinterest-container iframe {
width: 100% !important;
max-width: none !important;
}
span[class*="_bd"] {
height: 100% !important;
}
@media (prefers-color-scheme: dark) {
.pinterest-container > span {
border-color: rgb(35, 38, 41) !important;
background-color: rgb(22, 25, 28) !important;
}
[class$="_pinner"],
[class$="_board"] {
color: #e6e6e6 !important;
}
[class$="_button"] {
border-color: rgb(38, 42, 50) !important;
background-color: rgba(3, 58, 120, 0.1) !important;
}
}
</style>
<base target="_parent">
${iframeCheckScript(ctx)}
</head>
<body>
<div class="pinterest-container">
<a
data-pin-do="${pinType}"
data-pin-board-width="100%"
href="${url}"
style="width:100%;max-width:none;"
></a>
</div>
<script type="text/javascript" async defer src="${pinterestJs}"></script>
${resizeObserverScript(ctx)}
</body>
</html>`;
return;
}
return next();
};
+1
View File
@@ -132,6 +132,7 @@ router.get("/s/:shareId/*", shareDomains(), renderShare);
router.get("/embeds/gitlab", renderEmbed);
router.get("/embeds/github", renderEmbed);
router.get("/embeds/dropbox", renderEmbed);
router.get("/embeds/pinterest", renderEmbed);
// catch all for application
router.get("*", shareDomains(), async (ctx, next) => {
+1
View File
@@ -9,6 +9,7 @@
<meta name="mobile-web-app-capable" content="yes" />
<meta name="description" content="{description}" />
<meta name="darkreader-lock" />
{noindex}
<link rel="manifest" href="{manifest-url}" />
<link rel="canonical" href="{canonical-url}" data-react-helmet="true" />
{prefetch}
+2
View File
@@ -1,6 +1,7 @@
import { faker } from "@faker-js/faker";
import { Transaction } from "sequelize";
import sharedEnv from "@shared/env";
import { createContext } from "@server/context";
import env from "@server/env";
import { User } from "@server/models";
import onerror from "@server/onerror";
@@ -45,6 +46,7 @@ export function withAPIContext<T>(
transaction,
};
return fn({
...createContext(user, transaction),
state,
request: {
ip: faker.internet.ip(),
+10 -3
View File
@@ -49,8 +49,8 @@ export type AuthenticationResult = AccountProvisionerResult & {
export type Authentication = {
user: User;
token: string;
type: AuthenticationType;
token?: string;
type?: AuthenticationType;
};
export type Pagination = {
@@ -77,8 +77,15 @@ export interface APIContext<ReqT = BaseReq, ResT = BaseRes>
DefaultContext & IRouterParamContext<AppState>,
ResT
> {
/** Typed and validated version of request, consisting of validated body, query, etc */
/** Typed and validated version of request, consisting of validated body, query, etc. */
input: ReqT;
/** The current request's context, which is passed to database mutations. */
context: {
transaction?: Transaction;
auth: Authentication;
ip?: string;
};
}
type BaseEvent<T extends Model> = {
+31
View File
@@ -0,0 +1,31 @@
import { DocumentConverter } from "./DocumentConverter";
describe("csvToMarkdown", () => {
it("should convert csv to markdown with comma", async () => {
const csv = `name,age
John,25
Jane,24`;
const markdown = `| name | age |
| --- | --- |
| John | 25 |
| Jane | 24 |
`;
expect(await DocumentConverter.csvToMarkdown(csv)).toEqual(markdown);
});
it("should convert csv to markdown with semicolon", async () => {
const csv = `name;age
John;25
"Joan ""the bone"", Anne";24`;
const markdown = `| name | age |
| --- | --- |
| John | 25 |
| Joan "the bone", Anne | 24 |
`;
expect(await DocumentConverter.csvToMarkdown(csv)).toEqual(markdown);
});
});
+46 -1
View File
@@ -1,3 +1,4 @@
import { parse } from "@fast-csv/parse";
import escapeRegExp from "lodash/escapeRegExp";
import { simpleParser } from "mailparser";
import mammoth from "mammoth";
@@ -30,6 +31,8 @@ export class DocumentConverter {
case "text/plain":
case "text/markdown":
return this.fileToMarkdown(content);
case "text/csv":
return this.csvToMarkdown(content);
default:
break;
}
@@ -71,7 +74,49 @@ export class DocumentConverter {
return turndownService.turndown(content);
}
public static async fileToMarkdown(content: Buffer | string) {
public static csvToMarkdown(content: Buffer | string): Promise<string> {
return new Promise((resolve, reject) => {
const text = this.fileToMarkdown(content).trim();
const firstLine = text.split("\n")[0];
// Determine the separator used in the CSV file based on number of occurrences of each separator on first line
const delimiter = [";", ",", "\t"].reduce(
(acc, separator) => {
const count = (
firstLine.match(new RegExp(escapeRegExp(separator), "g")) || []
).length;
return count > acc.count ? { count, separator } : acc;
},
{ count: 0, separator: "," }
).separator;
const lines: string[][] = [];
const stream = parse({ delimiter })
.on("error", (error) => {
reject(
FileImportError(`There was an error parsing the CSV file: ${error}`)
);
})
.on("data", (row) => lines.push(row))
.on("end", () => {
const headers = lines[0];
const table = lines
.slice(1)
.map((cells) => `| ${cells.join(" | ")} |`)
.join("\n");
const headerLine = `| ${headers.join(" | ")} |`;
const separatorLine = `| ${headers.map(() => "---").join(" | ")} |`;
resolve(`${headerLine}\n${separatorLine}\n${table}\n`);
});
stream.write(text);
stream.end();
});
}
public static fileToMarkdown(content: Buffer | string) {
if (content instanceof Buffer) {
content = content.toString("utf8");
}
+25 -2
View File
@@ -3,16 +3,39 @@ import * as React from "react";
type Props = {
children?: React.ReactNode;
className?: string;
/**
* Capture all events, pointer events, or click events.
* @default "all"
*/
captureEvents?: "all" | "pointer" | "click";
};
const EventBoundary: React.FC<Props> = ({ children, className }: Props) => {
const EventBoundary: React.FC<Props> = ({
children,
className,
captureEvents = "all",
}: Props) => {
const stopEvent = React.useCallback((event: React.SyntheticEvent) => {
event.preventDefault();
event.stopPropagation();
}, []);
let props = {};
if (captureEvents === "all" || captureEvents === "pointer") {
props = {
onPointerDown: stopEvent,
onPointerUp: stopEvent,
};
}
if (captureEvents === "all" || captureEvents === "click") {
props = {
...props,
onClick: stopEvent,
};
}
return (
<span onPointerDown={stopEvent} onClick={stopEvent} className={className}>
<span {...props} className={className}>
{children}
</span>
);
+1 -1
View File
@@ -46,7 +46,7 @@ export const ImageZoom = ({ caption, children }: Props) => {
return (
<React.Suspense fallback={fallback}>
<Styles />
<EventBoundary>
<EventBoundary captureEvents="click">
<Zoom zoomMargin={EditorStyleHelper.padding} ZoomContent={ZoomContent}>
<div>{children}</div>
</Zoom>
+9 -7
View File
@@ -12,6 +12,7 @@ export type Props = {
editorStyle?: React.CSSProperties;
grow?: boolean;
theme: DefaultTheme;
userId?: string;
};
export const fadeIn = keyframes`
@@ -885,7 +886,9 @@ h6 {
}
.${EditorStyleHelper.comment} {
&:not([data-resolved]) {
&:not([data-resolved]):not([data-draft]), &[data-draft][data-user-id="${
props.userId ?? ""
}"] {
border-bottom: 2px solid ${props.theme.commentMarkBackground};
transition: background 100ms ease-in-out;
border-radius: 2px;
@@ -1297,20 +1300,19 @@ mark {
// Hide code without display none so toolbar can still be positioned against it
&:not(.code-active) {
height: ${props.staticHTML ? "auto" : "0"};
height: ${props.staticHTML || props.readOnly ? "auto" : "0"};
margin: -0.75em 0;
overflow: hidden;
// Allows the margin to collapse correctly by moving div out of the flow
position: ${props.staticHTML ? "relative" : "absolute"};
position: ${props.staticHTML || props.readOnly ? "relative" : "absolute"};
}
}
/* Hide code without display none so toolbar can still be positioned against it */
.ProseMirror[contenteditable="false"] .code-block[data-language=mermaidjs] {
height: ${props.staticHTML ? "auto" : "0"};
margin: -0.5em 0;
overflow: hidden;
height: 0;
overflow: hidden;
margin: -0.5em 0 0 0;
}
.code-block.with-line-numbers {
+44
View File
@@ -0,0 +1,44 @@
import * as React from "react";
import styled from "styled-components";
import Frame from "../components/Frame";
import { EmbedProps as Props } from ".";
function Pinterest({ matches, ...props }: Props) {
const boardUrl = props.attrs.href;
const frame = React.useRef<HTMLIFrameElement>(null);
const [height, setHeight] = React.useState(400);
React.useEffect(() => {
const handler = (event: MessageEvent<{ type: string; value: number }>) => {
const contentWindow =
frame.current?.contentWindow ||
frame.current?.contentDocument?.defaultView;
if (
event.data.type === "frame-resized" &&
event.source === contentWindow
) {
setHeight(event.data.value);
}
};
window.addEventListener("message", handler);
return () => window.removeEventListener("message", handler);
}, []);
return (
<PinterestFrame
{...props}
ref={frame}
src={`/embeds/pinterest?url=${encodeURIComponent(boardUrl)}`}
title="Pinterest Content"
height={`${height}px`}
width="100%"
/>
);
}
const PinterestFrame = styled(Frame)`
border-radius: 18px;
`;
export default Pinterest;
+13
View File
@@ -14,6 +14,7 @@ import GitLabSnippet from "./GitLabSnippet";
import InVision from "./InVision";
import JSFiddle from "./JSFiddle";
import Linkedin from "./Linkedin";
import Pinterest from "./Pinterest";
import Spotify from "./Spotify";
import Trello from "./Trello";
import Vimeo from "./Vimeo";
@@ -608,6 +609,18 @@ const embeds: EmbedDescriptor[] = [
icon: <Img src="/images/vimeo.png" alt="Vimeo" />,
component: Vimeo,
}),
new EmbedDescriptor({
title: "Pinterest",
keywords: "board moodboard pins",
regexMatch: [
// Match board URLs but exclude pins
/^(?:https?:\/\/)?(?:(?:www\.|[a-z]{2}\.)?pinterest\.(?:com|[a-z]{2,3}))\/(?!pin\/)([^/]+)\/([^/]+)\/?$/,
// Match profile URLs but exclude pins
/^(?:https?:\/\/)?(?:(?:www\.|[a-z]{2}\.)?pinterest\.(?:com|[a-z]{2,3}))\/(?!pin\/)([^/]+)\/?$/,
],
icon: <Img src="/images/pinterest.png" alt="Pinterest" />,
component: Pinterest,
}),
new EmbedDescriptor({
title: "Whimsical",
keywords: "whiteboard",
+28 -13
View File
@@ -1,6 +1,7 @@
import debounce from "lodash/debounce";
import last from "lodash/last";
import sortBy from "lodash/sortBy";
import type MermaidUnsafe from "mermaid";
import { Node } from "prosemirror-model";
import {
Plugin,
@@ -36,7 +37,7 @@ class Cache {
private static data: Map<string, string> = new Map();
}
let mermaid: typeof import("mermaid")["default"];
let mermaid: typeof MermaidUnsafe;
type RendererFunc = (
block: { node: Node; pos: number },
@@ -72,8 +73,16 @@ class MermaidRenderer {
return;
}
// Create a temporary element that will render the diagram off-screen. This is necessary
// as Mermaid will error if the element is not visible, such as if the heading is collapsed
const renderElement = document.createElement("div");
renderElement.style.position = "absolute";
renderElement.style.left = "-9999px";
renderElement.style.top = "-9999px";
document.body.appendChild(renderElement);
try {
mermaid = mermaid ?? (await import("mermaid")).default;
mermaid ??= (await import("mermaid")).default;
mermaid.initialize({
startOnLoad: true,
// TODO: Make dynamic based on the width of the editor or remove in
@@ -84,20 +93,24 @@ class MermaidRenderer {
theme: isDark ? "dark" : "default",
darkMode: isDark,
});
mermaid.render(
const { svg, bindFunctions } = await mermaid.render(
`mermaid-diagram-${this.diagramId}`,
text,
(svgCode, bindFunctions) => {
this.currentTextContent = text;
if (text) {
Cache.set(cacheKey, svgCode);
}
element.classList.remove("parse-error", "empty");
element.innerHTML = svgCode;
bindFunctions?.(element);
},
element
// If the element is not visible we use an off-screen element to render the diagram
element.offsetParent === null ? renderElement : element
);
this.currentTextContent = text;
// Cache the rendered SVG so we won't need to calculate it again in the same session
if (text) {
Cache.set(cacheKey, svg);
}
element.classList.remove("parse-error", "empty");
element.innerHTML = svg;
// Allow the user to interact with the diagram
bindFunctions?.(element);
} catch (error) {
const isEmpty = block.node.textContent.trim().length === 0;
@@ -108,6 +121,8 @@ class MermaidRenderer {
element.innerText = error;
element.classList.add("parse-error");
}
} finally {
renderElement.remove();
}
};
+4
View File
@@ -35,6 +35,10 @@ this is code
).toBe(true);
});
test("returns true for latex fence", () => {
expect(isMarkdown(`\$\$i\$\$`)).toBe(true);
});
test("returns false for non-closed fence", () => {
expect(
isMarkdown(`\`\`\`

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