JSON to client (#5553)

This commit is contained in:
Tom Moor
2024-05-24 08:29:00 -04:00
committed by GitHub
parent e1e8257df7
commit d51267b8bc
71 changed files with 651 additions and 378 deletions

View File

@@ -31,7 +31,6 @@ import {
import * as React from "react";
import { toast } from "sonner";
import { ExportContentType, TeamPreference } from "@shared/types";
import MarkdownHelper from "@shared/utils/MarkdownHelper";
import { getEventFiles } from "@shared/utils/files";
import DocumentDelete from "~/scenes/DocumentDelete";
import DocumentMove from "~/scenes/DocumentMove";
@@ -454,7 +453,7 @@ export const copyDocumentAsMarkdown = createAction({
? stores.documents.get(activeDocumentId)
: undefined;
if (document) {
copy(MarkdownHelper.toMarkdown(document));
copy(document.toMarkdown());
toast.success(t("Markdown copied to clipboard"));
}
},

View File

@@ -5,6 +5,7 @@ import { s } from "@shared/styles";
import User from "~/models/User";
import Avatar from "~/components/Avatar";
import Flex from "~/components/Flex";
import { AvatarSize } from "./Avatar/Avatar";
type Props = {
users: User[];
@@ -17,7 +18,7 @@ type Props = {
function Facepile({
users,
overflow = 0,
size = 32,
size = AvatarSize.Large,
limit = 8,
renderAvatar = DefaultAvatar,
...rest
@@ -43,7 +44,7 @@ function Facepile({
}
function DefaultAvatar(user: User) {
return <Avatar model={user} size={32} />;
return <Avatar model={user} size={AvatarSize.Large} />;
}
const AvatarWrapper = styled.div`
@@ -62,11 +63,11 @@ const More = styled.div<{ size: number }>`
min-width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
border-radius: 100%;
background: ${(props) => props.theme.slate};
color: ${s("text")};
background: ${(props) => props.theme.textTertiary};
color: ${s("white")};
border: 2px solid ${s("background")};
text-align: center;
font-size: 11px;
font-size: 12px;
font-weight: 600;
`;

View File

@@ -39,8 +39,8 @@ import { basicExtensions as extensions } from "@shared/editor/nodes";
import Node from "@shared/editor/nodes/Node";
import ReactNode from "@shared/editor/nodes/ReactNode";
import { EventType } from "@shared/editor/types";
import { UserPreferences } from "@shared/types";
import ProsemirrorHelper from "@shared/utils/ProsemirrorHelper";
import { ProsemirrorData, UserPreferences } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import EventEmitter from "@shared/utils/events";
import Flex from "~/components/Flex";
import { PortalContext } from "~/components/Portal";
@@ -59,7 +59,7 @@ export type Props = {
/** The user id of the current user */
userId?: string;
/** The editor content, should only be changed if you wish to reset the content */
value?: string;
value?: string | ProsemirrorData;
/** The initial editor content as a markdown string or JSON object */
defaultValue: string | object;
/** Placeholder displayed when the editor is empty */

View File

@@ -3,12 +3,19 @@ import i18n, { t } from "i18next";
import capitalize from "lodash/capitalize";
import floor from "lodash/floor";
import { action, autorun, computed, observable, set } from "mobx";
import { Node, Schema } from "prosemirror-model";
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
import { richExtensions } from "@shared/editor/nodes";
import type {
JSONObject,
NavigationNode,
ProsemirrorData,
} from "@shared/types";
import {
ExportContentType,
FileOperationFormat,
NotificationEventType,
} from "@shared/types";
import type { JSONObject, NavigationNode } from "@shared/types";
import Storage from "@shared/utils/Storage";
import { isRTL } from "@shared/utils/rtl";
import slugify from "@shared/utils/slugify";
@@ -61,6 +68,9 @@ export default class Document extends ParanoidModel {
@observable
id: string;
@observable.shallow
data: ProsemirrorData;
/**
* The original data source of the document, if imported.
*/
@@ -111,12 +121,6 @@ export default class Document extends ParanoidModel {
@Relation(() => Collection, { onDelete: "cascade" })
collection?: Collection;
/**
* The text content of the document as Markdown.
*/
@observable
text: string;
/**
* The title of the document.
*/
@@ -515,6 +519,17 @@ export default class Document extends ParanoidModel {
recursive?: boolean;
}) => this.store.duplicate(this, options);
/**
* Returns the first blocks of the document, useful for displaying a preview.
*
* @param blocks The number of blocks to return, defaults to 4.
* @returns A new ProseMirror document.
*/
getSummary = (blocks = 4) => ({
...this.data,
content: this.data.content.slice(0, blocks),
});
@computed
get pinned(): boolean {
return !!this.store.rootStore.pins.orderedData.find(
@@ -535,19 +550,40 @@ export default class Document extends ParanoidModel {
return !this.isDeleted && !this.isTemplate && !this.isArchived;
}
@computed
get childDocuments() {
return this.store.orderedData.filter(
(doc) => doc.parentDocumentId === this.id
);
}
@computed
get asNavigationNode(): NavigationNode {
return {
id: this.id,
title: this.title,
children: this.store.orderedData
.filter((doc) => doc.parentDocumentId === this.id)
.map((doc) => doc.asNavigationNode),
children: this.childDocuments.map((doc) => doc.asNavigationNode),
url: this.url,
isDraft: this.isDraft,
};
}
/**
* Returns the markdown representation of the document derived from the ProseMirror data.
*
* @returns The markdown representation of the document as a string.
*/
toMarkdown = () => {
const extensionManager = new ExtensionManager(richExtensions);
const serializer = extensionManager.serializer();
const schema = new Schema({
nodes: extensionManager.nodes,
marks: extensionManager.marks,
});
const markdown = serializer.serialize(Node.fromJSON(schema, this.data));
return markdown;
};
download = (contentType: ExportContentType) =>
client.post(
`/documents.export`,

View File

@@ -1,4 +1,5 @@
import { computed } from "mobx";
import { ProsemirrorData } from "@shared/types";
import { isRTL } from "@shared/utils/rtl";
import Document from "./Document";
import User from "./User";
@@ -18,8 +19,8 @@ class Revision extends Model {
/** The document title when the revision was created */
title: string;
/** Markdown string of the content when revision was created */
text: string;
/** Prosemirror data of the content when revision was created */
data: ProsemirrorData;
/** The emoji of the document when the revision was created */
emoji: string | null;

View File

@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
import { PAGINATION_SYMBOL } from "~/stores/base/Store";
import Collection from "~/models/Collection";
import Avatar from "~/components/Avatar";
import { AvatarSize } from "~/components/Avatar/Avatar";
import Facepile from "~/components/Facepile";
import Fade from "~/components/Fade";
import NudeButton from "~/components/NudeButton";
@@ -66,7 +67,7 @@ const MembershipPreview = ({ collection, limit = 8 }: Props) => {
return null;
}
const overflow = usersCount - groupsCount - collectionUsers.length;
const overflow = usersCount + groupsCount - collectionUsers.length;
return (
<NudeButton
@@ -107,7 +108,9 @@ const MembershipPreview = ({ collection, limit = 8 }: Props) => {
users={sortBy(collectionUsers, "lastActiveAt")}
overflow={overflow}
limit={limit}
renderAvatar={(user) => <Avatar model={user} size={32} />}
renderAvatar={(item) => (
<Avatar model={item} size={AvatarSize.Large} />
)}
/>
</Fade>
</NudeButton>

View File

@@ -123,10 +123,10 @@ function SharedDocumentScene(props: Props) {
React.useEffect(() => {
async function fetchData() {
try {
const response = await documents.fetchWithSharedTree(documentSlug, {
const res = await documents.fetchWithSharedTree(documentSlug, {
shareId,
});
setResponse(response);
setResponse(res);
} catch (err) {
setError(err);
}

View File

@@ -8,7 +8,7 @@ import { toast } from "sonner";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import { JSONObject } from "@shared/types";
import { ProsemirrorData } from "@shared/types";
import { dateToRelative } from "@shared/utils/date";
import { Minute } from "@shared/utils/time";
import Comment from "~/models/Comment";
@@ -100,7 +100,7 @@ function CommentThreadItem({
const [isEditing, setEditing, setReadOnly] = useBoolean();
const formRef = React.useRef<HTMLFormElement>(null);
const handleChange = (value: (asString: boolean) => JSONObject) => {
const handleChange = (value: (asString: boolean) => ProsemirrorData) => {
setData(value(false));
};

View File

@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
import * as React from "react";
import { useLocation, RouteComponentProps, StaticContext } from "react-router";
import { NavigationNode, TeamPreference } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { RevisionHelper } from "@shared/utils/RevisionHelper";
import Document from "~/models/Document";
import Revision from "~/models/Revision";
@@ -92,7 +93,7 @@ function DataLoader({ match, children }: Props) {
}
}
void fetchDocument();
}, [ui, documents, document, shareId, documentSlug]);
}, [ui, documents, shareId, documentSlug]);
React.useEffect(() => {
async function fetchRevision() {
@@ -161,7 +162,7 @@ function DataLoader({ match, children }: Props) {
collectionId: document.collectionId,
parentDocumentId: nested ? document.id : document.parentDocumentId,
title,
text: "",
data: ProsemirrorHelper.getEmptyDocument(),
});
return newDocument.url;

View File

@@ -1,6 +1,9 @@
import cloneDeep from "lodash/cloneDeep";
import debounce from "lodash/debounce";
import isEqual from "lodash/isEqual";
import { action, observable } from "mobx";
import { observer } from "mobx-react";
import { Node } from "prosemirror-model";
import { AllSelection } from "prosemirror-state";
import * as React from "react";
import { WithTranslation, withTranslation } from "react-i18next";
@@ -16,9 +19,8 @@ import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import { NavigationNode } from "@shared/types";
import { Heading } from "@shared/utils/ProsemirrorHelper";
import { ProsemirrorHelper, Heading } from "@shared/utils/ProsemirrorHelper";
import { parseDomain } from "@shared/utils/domains";
import getTasks from "@shared/utils/getTasks";
import RootStore from "~/stores/RootStore";
import Document from "~/models/Document";
import Revision from "~/models/Revision";
@@ -34,6 +36,7 @@ import PlaceholderDocument from "~/components/PlaceholderDocument";
import RegisterKeyDown from "~/components/RegisterKeyDown";
import withStores from "~/components/withStores";
import type { Editor as TEditor } from "~/editor";
import { SearchResult } from "~/editor/components/LinkEditor";
import { client } from "~/utils/ApiClient";
import { replaceTitleVariables } from "~/utils/date";
import { emojiToUrl } from "~/utils/emoji";
@@ -73,13 +76,13 @@ type Props = WithTranslation &
RootStore &
RouteComponentProps<Params, StaticContext, LocationState> & {
sharedTree?: NavigationNode;
abilities: Record<string, any>;
abilities: Record<string, boolean>;
document: Document;
revision?: Revision;
readOnly: boolean;
shareId?: string;
onCreateLink?: (title: string, nested?: boolean) => Promise<string>;
onSearchLink?: (term: string) => any;
onSearchLink?: (term: string) => Promise<SearchResult[]>;
};
@observer
@@ -108,8 +111,6 @@ class DocumentScene extends React.Component<Props> {
@observable
headings: Heading[] = [];
getEditorText: () => string = () => this.props.document.text;
componentDidMount() {
this.updateIsDirty();
}
@@ -140,8 +141,8 @@ class DocumentScene extends React.Component<Props> {
return;
}
const { view, parser } = editorRef;
const doc = parser.parse(template.text);
const { view, schema } = editorRef;
const doc = Node.fromJSON(schema, template.data);
if (doc) {
view.dispatch(
@@ -168,10 +169,8 @@ class DocumentScene extends React.Component<Props> {
if (template.emoji) {
this.props.document.emoji = template.emoji;
}
if (template.text) {
this.props.document.text = template.text;
}
this.props.document.data = cloneDeep(template.data);
this.updateIsDirty();
return this.onSave({
@@ -292,15 +291,18 @@ class DocumentScene extends React.Component<Props> {
}
// get the latest version of the editor text value
const text = this.getEditorText ? this.getEditorText() : document.text;
// prevent save before anything has been written (single hash is empty doc)
if (text.trim() === "" && document.title.trim() === "") {
const doc = this.editor.current?.view.state.doc;
if (!doc) {
return;
}
document.text = text;
document.tasks = getTasks(document.text);
// prevent save before anything has been written (single hash is empty doc)
if (ProsemirrorHelper.isEmpty(doc) && document.title.trim() === "") {
return;
}
document.data = doc.toJSON();
document.tasks = ProsemirrorHelper.getTasksSummary(doc);
// prevent autosave if nothing has changed
if (options.autosave && !this.isEditorDirty && !document.isDirty()) {
@@ -340,12 +342,11 @@ class DocumentScene extends React.Component<Props> {
updateIsDirty = () => {
const { document } = this.props;
const editorText = this.getEditorText().trim();
this.isEditorDirty = editorText !== document.text.trim();
const doc = this.editor.current?.view.state.doc;
this.isEditorDirty = !isEqual(doc?.toJSON(), document.data);
// a single hash is a doc with just an empty title
this.isEmpty =
(!editorText || editorText === "#" || editorText === "\\") && !this.title;
this.isEmpty = (!doc || ProsemirrorHelper.isEmpty(doc)) && !this.title;
};
updateIsDirtyDebounced = debounce(this.updateIsDirty, 500);
@@ -358,9 +359,8 @@ class DocumentScene extends React.Component<Props> {
this.isUploading = false;
};
handleChange = (getEditorText: () => string) => {
handleChange = () => {
const { document } = this.props;
this.getEditorText = getEditorText;
// Keep derived task list in sync
const tasks = this.editor.current?.getTasks();
@@ -503,8 +503,8 @@ class DocumentScene extends React.Component<Props> {
isDraft={document.isDraft}
template={document.isTemplate}
document={document}
value={readOnly ? document.text : undefined}
defaultValue={document.text}
value={readOnly ? document.data : undefined}
defaultValue={document.data}
embedsDisabled={embedsDisabled}
onSynced={this.onSynced}
onFileUploadStart={this.onFileUploadStart}

View File

@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
import { useHistory, useLocation, useRouteMatch } from "react-router-dom";
import { toast } from "sonner";
import { UserPreference } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import CenteredContent from "~/components/CenteredContent";
import Flex from "~/components/Flex";
import PlaceholderDocument from "~/components/PlaceholderDocument";
@@ -49,7 +50,7 @@ function DocumentNew({ template }: Props) {
templateId: query.get("templateId") ?? undefined,
template,
title: "",
text: "",
data: ProsemirrorHelper.getEmptyDocument(),
});
history.replace(
template || !user.separateEditMode

View File

@@ -22,6 +22,7 @@ import env from "~/env";
import type {
FetchOptions,
PaginationParams,
PartialWithId,
Properties,
SearchResult,
} from "~/types";
@@ -473,6 +474,14 @@ export default class DocumentsStore extends Store<Document> {
return this.data.get(res.data.id);
};
override fetch = (id: string, options: FetchOptions = {}) =>
super.fetch(
id,
options,
(res: { data: { document: PartialWithId<Document> } }) =>
res.data.document
);
@action
fetchWithSharedTree = async (
id: string,
@@ -507,7 +516,6 @@ export default class DocumentsStore extends Store<Document> {
const res = await client.post("/documents.info", {
id,
shareId: options.shareId,
apiVersion: 2,
});
invariant(res?.data, "Document not available");

View File

@@ -35,7 +35,6 @@ export default class RevisionsStore extends Store<Revision> {
id: "latest",
documentId: document.id,
title: document.title,
text: document.text,
createdAt: document.updatedAt,
createdBy: document.createdBy,
},

View File

@@ -229,7 +229,11 @@ export default abstract class Store<T extends Model> {
}
@action
async fetch(id: string, options: JSONObject = {}): Promise<T> {
async fetch(
id: string,
options: JSONObject = {},
accessor = (res: unknown) => (res as { data: PartialWithId<T> }).data
): Promise<T> {
if (!this.actions.includes(RPCAction.Info)) {
throw new Error(`Cannot fetch ${this.modelName}`);
}
@@ -248,7 +252,7 @@ export default abstract class Store<T extends Model> {
return runInAction(`info#${this.modelName}`, () => {
invariant(res?.data, "Data should be available");
this.addPolicies(res.policies);
return this.add(res.data);
return this.add(accessor(res));
});
} catch (err) {
if (err instanceof AuthorizationError || err instanceof NotFoundError) {

View File

@@ -84,6 +84,7 @@ class ApiClient {
Accept: "application/json",
"cache-control": "no-cache",
"x-editor-version": EDITOR_VERSION,
"x-api-version": "3",
pragma: "no-cache",
...options?.headers,
};

View File

@@ -518,7 +518,7 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
subscription,
payload: {
id: event.documentId,
model: model && (await presentDocument(model)),
model: model && (await presentDocument(undefined, model)),
},
});
}
@@ -544,7 +544,7 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
payload: {
id: event.modelId,
model: model && presentMembership(model),
document: model && (await presentDocument(model.document!)),
document: model && (await presentDocument(undefined, model.document!)),
user: model && presentUser(model.user),
},
});

View File

@@ -8,7 +8,7 @@ import * as Y from "yjs";
import Logger from "@server/logging/Logger";
import { trace } from "@server/logging/tracing";
import Document from "@server/models/Document";
import ProsemirrorHelper from "@server/models/helpers/ProsemirrorHelper";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import { sequelize } from "@server/storage/database";
import documentCollaborativeUpdater from "../commands/documentCollaborativeUpdater";
import { withContext } from "./types";
@@ -51,11 +51,20 @@ export default class PersistenceExtension implements Extension {
return ydoc;
}
Logger.info(
"database",
`Document ${documentId} is not in state, creating from markdown`
);
const ydoc = ProsemirrorHelper.toYDoc(document.text, fieldName);
let ydoc;
if (document.content) {
Logger.info(
"database",
`Document ${documentId} is not in state, creating from content`
);
ydoc = ProsemirrorHelper.toYDoc(document.content, fieldName);
} else {
Logger.info(
"database",
`Document ${documentId} is not in state, creating from text`
);
ydoc = ProsemirrorHelper.toYDoc(document.text, fieldName);
}
const state = ProsemirrorHelper.toState(ydoc);
await document.update(
{

View File

@@ -1,12 +1,23 @@
import path from "path";
import { readFile } from "fs-extra";
import invariant from "invariant";
import { UserRole } from "@shared/types";
import { CollectionPermission, UserRole } from "@shared/types";
import WelcomeEmail from "@server/emails/templates/WelcomeEmail";
import env from "@server/env";
import {
InvalidAuthenticationError,
AuthenticationProviderDisabledError,
} from "@server/errors";
import { traceFunction } from "@server/logging/tracing";
import { AuthenticationProvider, Collection, Team, User } from "@server/models";
import {
AuthenticationProvider,
Collection,
Document,
Team,
User,
} from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import { sequelize } from "@server/storage/database";
import teamProvisioner from "./teamProvisioner";
import userProvisioner from "./userProvisioner";
@@ -174,7 +185,7 @@ async function accountProvisioner({
}
if (provision) {
await team.provisionFirstCollection(user.id);
await provisionFirstCollection(team, user);
}
}
@@ -186,6 +197,60 @@ async function accountProvisioner({
};
}
async function provisionFirstCollection(team: Team, user: User) {
await sequelize.transaction(async (transaction) => {
const collection = await Collection.create(
{
name: "Welcome",
description: `This collection is a quick guide to what ${env.APP_NAME} is all about. Feel free to delete this collection once your team is up to speed with the basics!`,
teamId: team.id,
createdById: user.id,
sort: Collection.DEFAULT_SORT,
permission: CollectionPermission.ReadWrite,
},
{
transaction,
}
);
// For the first collection we go ahead and create some intitial documents to get
// the team started. You can edit these in /server/onboarding/x.md
const onboardingDocs = [
"Integrations & API",
"Our Editor",
"Getting Started",
"What is Outline",
];
for (const title of onboardingDocs) {
const text = await readFile(
path.join(process.cwd(), "server", "onboarding", `${title}.md`),
"utf8"
);
const document = await Document.create(
{
version: 2,
isWelcome: true,
parentDocumentId: null,
collectionId: collection.id,
teamId: collection.teamId,
lastModifiedById: collection.createdById,
createdById: collection.createdById,
title,
text,
},
{ transaction }
);
document.content = await DocumentHelper.toJSON(document);
await document.publish(collection.createdById, collection.id, {
transaction,
});
}
});
}
export default traceFunction({
spanName: "accountProvisioner",
})(accountProvisioner);

View File

@@ -21,6 +21,7 @@ describe("commentCreator", () => {
type: "paragraph",
content: [
{
content: [],
type: "text",
text: "test",
},

View File

@@ -1,4 +1,5 @@
import { Transaction } from "sequelize";
import { ProsemirrorData } from "@shared/types";
import { Comment, User, Event } from "@server/models";
type Props = {
@@ -6,7 +7,7 @@ type Props = {
/** The user creating the comment */
user: User;
/** The comment as data in Prosemirror schema format */
data: Record<string, any>;
data: ProsemirrorData;
/** The document to comment within */
documentId: string;
/** The parent comment we're replying to, if any */

View File

@@ -1,6 +1,7 @@
import { Transaction } from "sequelize";
import { ProsemirrorData } from "@shared/types";
import { Event, Comment, User } from "@server/models";
import ProsemirrorHelper from "@server/models/helpers/ProsemirrorHelper";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
type Props = {
/** The user updating the comment */
@@ -9,8 +10,8 @@ type Props = {
resolvedBy?: User;
/** The existing comment */
comment: Comment;
/** The index to comment the document at */
data: Record<string, any>;
/** The comment data */
data: ProsemirrorData;
/** The IP address of the user creating the comment */
ip: string;
transaction: Transaction;

View File

@@ -2,6 +2,7 @@ import { yDocToProsemirrorJSON } from "@getoutline/y-prosemirror";
import uniq from "lodash/uniq";
import { Node } from "prosemirror-model";
import * as Y from "yjs";
import { ProsemirrorData } from "@shared/types";
import { schema, serializer } from "@server/editor";
import Logger from "@server/logging/Logger";
import { Document, Event } from "@server/models";
@@ -41,7 +42,7 @@ export default async function documentCollaborativeUpdater({
});
const state = Y.encodeStateAsUpdate(ydoc);
const content = yDocToProsemirrorJSON(ydoc, "default");
const content = yDocToProsemirrorJSON(ydoc, "default") as ProsemirrorData;
const node = Node.fromJSON(schema, content);
const text = serializer.serialize(node, undefined);
const isUnchanged = document.text === text;

View File

@@ -1,7 +1,7 @@
import { Transaction } from "sequelize";
import { Optional } from "utility-types";
import { Document, Event, User } from "@server/models";
import TextHelper from "@server/models/helpers/TextHelper";
import { TextHelper } from "@server/models/helpers/TextHelper";
type Props = Optional<
Pick<
@@ -10,6 +10,7 @@ type Props = Optional<
| "urlId"
| "title"
| "text"
| "content"
| "emoji"
| "collectionId"
| "parentDocumentId"

View File

@@ -48,6 +48,7 @@ export default async function documentDuplicator({
emoji: document.emoji,
template: document.template,
title: title ?? document.title,
content: document.content,
text: document.text,
...sharedProperties,
});

View File

@@ -6,8 +6,8 @@ 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 { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import { TextHelper } from "@server/models/helpers/TextHelper";
import { DocumentConverter } from "@server/utils/DocumentConverter";
import { InvalidRequestError } from "../errors";

View File

@@ -2,9 +2,10 @@ import uniq from "lodash/uniq";
import { Op, QueryTypes } from "sequelize";
import Logger from "@server/logging/Logger";
import { Document, Attachment } from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import DeleteAttachmentTask from "@server/queues/tasks/DeleteAttachmentTask";
import { sequelize } from "@server/storage/database";
import parseAttachmentIds from "@server/utils/parseAttachmentIds";
export default async function documentPermanentDeleter(documents: Document[]) {
const activeDocument = documents.find((doc) => !doc.deletedAt);
@@ -25,7 +26,9 @@ export default async function documentPermanentDeleter(documents: Document[]) {
for (const document of documents) {
// Find any attachments that are referenced in the text content
const attachmentIdsInText = parseAttachmentIds(document.text);
const attachmentIdsInText = ProsemirrorHelper.parseAttachmentIds(
DocumentHelper.toProsemirror(document)
);
// Find any attachments that were originally uploaded to this document
const attachmentIdsForDocument = (

View File

@@ -1,6 +1,6 @@
import { Transaction } from "sequelize";
import { Event, Document, User } from "@server/models";
import DocumentHelper from "@server/models/helpers/DocumentHelper";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
type Props = {
/** The user updating the document */

View File

@@ -4,8 +4,8 @@ import { Day } from "@shared/utils/time";
import { Collection, Comment, Document } from "@server/models";
import HTMLHelper from "@server/models/helpers/HTMLHelper";
import NotificationSettingsHelper from "@server/models/helpers/NotificationSettingsHelper";
import ProsemirrorHelper from "@server/models/helpers/ProsemirrorHelper";
import TextHelper from "@server/models/helpers/TextHelper";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import { TextHelper } from "@server/models/helpers/TextHelper";
import BaseEmail, { EmailProps } from "./BaseEmail";
import Body from "./components/Body";
import Button from "./components/Button";

View File

@@ -4,8 +4,8 @@ import { Day } from "@shared/utils/time";
import { Collection, Comment, Document } from "@server/models";
import HTMLHelper from "@server/models/helpers/HTMLHelper";
import NotificationSettingsHelper from "@server/models/helpers/NotificationSettingsHelper";
import ProsemirrorHelper from "@server/models/helpers/ProsemirrorHelper";
import TextHelper from "@server/models/helpers/TextHelper";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import { TextHelper } from "@server/models/helpers/TextHelper";
import BaseEmail, { EmailProps } from "./BaseEmail";
import Body from "./components/Body";
import Button from "./components/Button";

View File

@@ -2,7 +2,7 @@ import * as React from "react";
import { NotificationEventType } from "@shared/types";
import { Day } from "@shared/utils/time";
import { Document, Collection, Revision } from "@server/models";
import DocumentHelper from "@server/models/helpers/DocumentHelper";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import HTMLHelper from "@server/models/helpers/HTMLHelper";
import NotificationSettingsHelper from "@server/models/helpers/NotificationSettingsHelper";
import SubscriptionHelper from "@server/models/helpers/SubscriptionHelper";

View File

@@ -10,7 +10,7 @@ import {
DefaultScope,
} from "sequelize-typescript";
import type { ProsemirrorData } from "@shared/types";
import ProsemirrorHelper from "@shared/utils/ProsemirrorHelper";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { CommentValidation } from "@shared/validations";
import { schema } from "@server/editor";
import Document from "./Document";

View File

@@ -1,5 +1,6 @@
import { EmptyResultError } from "sequelize";
import slugify from "@shared/utils/slugify";
import { parser } from "@server/editor";
import Document from "@server/models/Document";
import {
buildDocument,
@@ -208,20 +209,7 @@ describe("#findByPk", () => {
});
describe("tasks", () => {
test("should consider all the possible checkTtems", async () => {
const document = await buildDocument({
text: `- [x] test
- [X] test
- [ ] test
- [-] test
- [_] test`,
});
const tasks = document.tasks;
expect(tasks.completed).toBe(4);
expect(tasks.total).toBe(5);
});
test("should return tasks keys set to 0 if checkItems isn't present", async () => {
test("should return tasks keys set to 0 if check items isn't present", async () => {
const document = await buildDocument({
text: `text`,
});
@@ -230,11 +218,12 @@ describe("tasks", () => {
expect(tasks.total).toBe(0);
});
test("should return tasks keys set to 0 if the text contains broken checkItems", async () => {
test("should return tasks keys set to 0 if the text contains broken check items", async () => {
const document = await buildDocument({
text: `- [x ] test
- [ x ] test
- [ ] test`,
text: `
- [x ] test
- [ x ] test
- [ ] test`,
});
const tasks = document.tasks;
expect(tasks.completed).toBe(0);
@@ -243,8 +232,9 @@ describe("tasks", () => {
test("should return tasks", async () => {
const document = await buildDocument({
text: `- [x] list item
- [ ] list item`,
text: `
- [x] list item
- [ ] list item`,
});
const tasks = document.tasks;
expect(tasks.completed).toBe(1);
@@ -253,15 +243,21 @@ describe("tasks", () => {
test("should update tasks on save", async () => {
const document = await buildDocument({
text: `- [x] list item
- [ ] list item`,
text: `
- [x] list item
- [ ] list item`,
});
const tasks = document.tasks;
expect(tasks.completed).toBe(1);
expect(tasks.total).toBe(2);
document.text = `- [x] list item
- [ ] list item
- [ ] list item`;
document.content = parser
.parse(
`
- [x] list item
- [ ] list item
- [ ] list item`
)
?.toJSON();
await document.save();
const newTasks = document.tasks;
expect(newTasks.completed).toBe(1);

View File

@@ -47,8 +47,8 @@ import type {
ProsemirrorData,
SourceMetadata,
} from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { UrlHelper } from "@shared/utils/UrlHelper";
import getTasks from "@shared/utils/getTasks";
import slugify from "@shared/utils/slugify";
import { DocumentValidation } from "@shared/validations";
import { ValidationError } from "@server/errors";
@@ -63,7 +63,7 @@ import UserMembership from "./UserMembership";
import View from "./View";
import ParanoidModel from "./base/ParanoidModel";
import Fix from "./decorators/Fix";
import DocumentHelper from "./helpers/DocumentHelper";
import { DocumentHelper } from "./helpers/DocumentHelper";
import Length from "./validators/Length";
export const DOCUMENT_VERSION = 2;
@@ -75,9 +75,6 @@ type AdditionalFindOptions = {
};
@DefaultScope(() => ({
attributes: {
exclude: ["state"],
},
include: [
{
model: User,
@@ -337,7 +334,9 @@ class Document extends ParanoidModel<
}
get tasks() {
return getTasks(this.text || "");
return ProsemirrorHelper.getTasksSummary(
DocumentHelper.toProsemirror(this)
);
}
// hooks
@@ -411,7 +410,7 @@ class Document extends ParanoidModel<
}
@BeforeUpdate
static processUpdate(model: Document) {
static async processUpdate(model: Document) {
// ensure documents have a title
model.title = model.title || "";
@@ -431,7 +430,7 @@ class Document extends ParanoidModel<
// backfill content if it's missing
if (!model.content) {
model.content = DocumentHelper.toJSON(model);
model.content = await DocumentHelper.toJSON(model);
}
// ensure the last modifying user is a collaborator
@@ -608,7 +607,6 @@ class Document extends ParanoidModel<
// allow default preloading of collection membership if `userId` is passed in find options
// almost every endpoint needs the collection membership to determine policy permissions.
const scope = this.scope([
...(includeState ? [] : ["withoutState"]),
"withDrafts",
{
method: ["withCollectionPermissions", userId, rest.paranoid],

View File

@@ -1,8 +1,5 @@
import crypto from "crypto";
import fs from "fs";
import path from "path";
import { URL } from "url";
import util from "util";
import { subMinutes } from "date-fns";
import {
InferAttributes,
@@ -30,12 +27,7 @@ import {
BeforeCreate,
} from "sequelize-typescript";
import { TeamPreferenceDefaults } from "@shared/constants";
import {
CollectionPermission,
TeamPreference,
TeamPreferences,
UserRole,
} from "@shared/types";
import { TeamPreference, TeamPreferences, UserRole } from "@shared/types";
import { getBaseDomain, RESERVED_SUBDOMAINS } from "@shared/utils/domains";
import env from "@server/env";
import { ValidationError } from "@server/errors";
@@ -55,8 +47,6 @@ import IsUrlOrRelativePath from "./validators/IsUrlOrRelativePath";
import Length from "./validators/Length";
import NotContainsUrl from "./validators/NotContainsUrl";
const readFile = util.promisify(fs.readFile);
@Scopes(() => ({
withDomains: {
include: [{ model: TeamDomain }],
@@ -279,57 +269,6 @@ class Team extends ParanoidModel<
});
};
provisionFirstCollection = async (userId: string) => {
await this.sequelize!.transaction(async (transaction) => {
const collection = await Collection.create(
{
name: "Welcome",
description: `This collection is a quick guide to what ${env.APP_NAME} is all about. Feel free to delete this collection once your team is up to speed with the basics!`,
teamId: this.id,
createdById: userId,
sort: Collection.DEFAULT_SORT,
permission: CollectionPermission.ReadWrite,
},
{
transaction,
}
);
// For the first collection we go ahead and create some intitial documents to get
// the team started. You can edit these in /server/onboarding/x.md
const onboardingDocs = [
"Integrations & API",
"Our Editor",
"Getting Started",
"What is Outline",
];
for (const title of onboardingDocs) {
const text = await readFile(
path.join(process.cwd(), "server", "onboarding", `${title}.md`),
"utf8"
);
const document = await Document.create(
{
version: 2,
isWelcome: true,
parentDocumentId: null,
collectionId: collection.id,
teamId: collection.teamId,
lastModifiedById: collection.createdById,
createdById: collection.createdById,
title,
text,
},
{ transaction }
);
await document.publish(collection.createdById, collection.id, {
transaction,
});
}
});
};
public collectionIds = async function (paranoid = true) {
const models = await Collection.findAll({
attributes: ["id"],

View File

@@ -1,6 +1,6 @@
import Revision from "@server/models/Revision";
import { buildDocument } from "@server/test/factories";
import DocumentHelper from "./DocumentHelper";
import { DocumentHelper } from "./DocumentHelper";
describe("DocumentHelper", () => {
beforeAll(() => {

View File

@@ -7,14 +7,14 @@ import { JSDOM } from "jsdom";
import { Node } from "prosemirror-model";
import * as Y from "yjs";
import textBetween from "@shared/editor/lib/textBetween";
import MarkdownHelper from "@shared/utils/MarkdownHelper";
import { parser, schema } from "@server/editor";
import { ProsemirrorData } from "@shared/types";
import { parser, serializer, schema } from "@server/editor";
import { addTags } from "@server/logging/tracer";
import { trace } from "@server/logging/tracing";
import { Document, Revision } from "@server/models";
import diff from "@server/utils/diff";
import ProsemirrorHelper from "./ProsemirrorHelper";
import TextHelper from "./TextHelper";
import { ProsemirrorHelper } from "./ProsemirrorHelper";
import { TextHelper } from "./TextHelper";
type HTMLOptions = {
/** Whether to include the document title in the generated HTML (defaults to true) */
@@ -35,32 +35,18 @@ type HTMLOptions = {
};
@trace()
export default class DocumentHelper {
export class DocumentHelper {
/**
* Returns the document as JSON content. This method uses the collaborative state if available,
* otherwise it falls back to Markdown.
*
* @param document The document or revision to convert
* @returns The document content as JSON
*/
static toJSON(document: Document | Revision) {
if ("state" in document && document.state) {
const ydoc = new Y.Doc();
Y.applyUpdate(ydoc, document.state);
return yDocToProsemirrorJSON(ydoc, "default");
}
const node = parser.parse(document.text) || Node.fromJSON(schema, {});
return node.toJSON();
}
/**
* Returns the document as a Prosemirror Node. This method uses the collaborative state if
* available, otherwise it falls back to Markdown.
* Returns the document as a Prosemirror Node. This method uses the derived content if available
* then the collaborative state, otherwise it falls back to Markdown.
*
* @param document The document or revision to convert
* @returns The document content as a Prosemirror Node
*/
static toProsemirror(document: Document | Revision) {
if ("content" in document && document.content) {
return Node.fromJSON(schema, document.content);
}
if ("state" in document && document.state) {
const ydoc = new Y.Doc();
Y.applyUpdate(ydoc, document.state);
@@ -69,6 +55,55 @@ export default class DocumentHelper {
return parser.parse(document.text) || Node.fromJSON(schema, {});
}
/**
* Returns the document as a plain JSON object. This method uses the derived content if available
* then the collaborative state, otherwise it falls back to Markdown.
*
* @param document The document or revision to convert
* @param options Options for the conversion
* @returns The document content as a plain JSON object
*/
static async toJSON(
document: Document | Revision,
options?: {
/** The team context */
teamId: string;
/** Whether to sign attachment urls, and if so for how many seconds is the signature valid */
signedUrls: number;
/** Marks to remove from the document */
removeMarks?: string[];
}
): Promise<ProsemirrorData> {
let doc: Node | null;
let json;
if ("content" in document && document.content) {
doc = Node.fromJSON(schema, document.content);
} else if ("state" in document && document.state) {
const ydoc = new Y.Doc();
Y.applyUpdate(ydoc, document.state);
doc = Node.fromJSON(schema, yDocToProsemirrorJSON(ydoc, "default"));
} else {
doc = parser.parse(document.text);
}
if (doc && options?.signedUrls) {
json = await ProsemirrorHelper.signAttachmentUrls(
doc,
options.teamId,
options.signedUrls
);
} else {
json = doc?.toJSON() ?? {};
}
if (options?.removeMarks) {
json = ProsemirrorHelper.removeMarks(json, options.removeMarks);
}
return json;
}
/**
* Returns the document as plain text. This method uses the
* collaborative state if available, otherwise it falls back to Markdown.
@@ -88,19 +123,30 @@ export default class DocumentHelper {
}
/**
* Returns the document as Markdown. This is a lossy conversion and should
* only be used for export.
* Returns the document as Markdown. This is a lossy conversion and should nly be used for export.
*
* @param document The document or revision to convert
* @returns The document title and content as a Markdown string
*/
static toMarkdown(document: Document | Revision) {
return MarkdownHelper.toMarkdown(document);
const text = serializer
.serialize(DocumentHelper.toProsemirror(document))
.replace(/\n\\(\n|$)/g, "\n\n")
.replace(/“/g, '"')
.replace(/”/g, '"')
.replace(//g, "'")
.replace(//g, "'")
.trim();
const title = `${document.emoji ? document.emoji + " " : ""}${
document.title
}`;
return `# ${title}\n\n${text}`;
}
/**
* Returns the document as plain HTML. This is a lossy conversion and should
* only be used for export.
* Returns the document as plain HTML. This is a lossy conversion and should only be used for export.
*
* @param document The document or revision to convert
* @param options Options for the HTML output

View File

@@ -1,5 +1,8 @@
import { prosemirrorToYDoc } from "@getoutline/y-prosemirror";
import { JSDOM } from "jsdom";
import compact from "lodash/compact";
import flatten from "lodash/flatten";
import uniq from "lodash/uniq";
import { Node, DOMSerializer, Fragment, Mark } from "prosemirror-model";
import * as React from "react";
import { renderToString } from "react-dom/server";
@@ -9,10 +12,14 @@ import EditorContainer from "@shared/editor/components/Styles";
import embeds from "@shared/editor/embeds";
import GlobalStyles from "@shared/styles/globals";
import light from "@shared/styles/theme";
import { ProsemirrorData } from "@shared/types";
import { attachmentRedirectRegex } from "@shared/utils/ProsemirrorHelper";
import { isRTL } from "@shared/utils/rtl";
import { schema, parser } from "@server/editor";
import Logger from "@server/logging/Logger";
import { trace } from "@server/logging/tracing";
import Attachment from "@server/models/Attachment";
import FileStorage from "@server/storage/files";
export type HTMLOptions = {
/** A title, if it should be included */
@@ -36,15 +43,22 @@ type MentionAttrs = {
};
@trace()
export default class ProsemirrorHelper {
export class ProsemirrorHelper {
/**
* Returns the input text as a Y.Doc.
*
* @param markdown The text to parse
* @returns The content as a Y.Doc.
*/
static toYDoc(markdown: string, fieldName = "default"): Y.Doc {
let node = parser.parse(markdown);
static toYDoc(input: string | ProsemirrorData, fieldName = "default"): Y.Doc {
if (typeof input === "object") {
return prosemirrorToYDoc(
ProsemirrorHelper.toProsemirror(input),
fieldName
);
}
let node = parser.parse(input);
// in the editor embeds are created at runtime by converting links into
// embeds where they match.Because we're converting to a CRDT structure on
@@ -106,7 +120,7 @@ export default class ProsemirrorHelper {
* @param data The object to parse
* @returns The content as a Prosemirror Node
*/
static toProsemirror(data: Record<string, any>) {
static toProsemirror(data: ProsemirrorData) {
return Node.fromJSON(schema, data);
}
@@ -116,10 +130,10 @@ export default class ProsemirrorHelper {
* @param node The node to parse mentions from
* @returns An array of mention attributes
*/
static parseMentions(node: Node) {
static parseMentions(doc: Node) {
const mentions: MentionAttrs[] = [];
node.descendants((node: Node) => {
doc.descendants((node: Node) => {
if (
node.type.name === "mention" &&
!mentions.some((m) => m.id === node.attrs.id)
@@ -138,6 +152,117 @@ export default class ProsemirrorHelper {
return mentions;
}
/**
* Removes all marks from the node that match the given types.
*
* @param data The ProsemirrorData object to remove marks from
* @param marks The mark types to remove
* @returns The content with marks removed
*/
static removeMarks(data: ProsemirrorData, marks: string[]) {
function removeMarksInner(node: ProsemirrorData) {
if (node.marks) {
node.marks = node.marks.filter((mark) => !marks.includes(mark.type));
}
if (node.content) {
node.content.forEach(removeMarksInner);
}
return node;
}
return removeMarksInner(data);
}
/**
* Returns the document as a plain JSON object with attachment URLs signed.
*
* @param node The node to convert to JSON
* @param teamId The team ID to use for signing
* @param expiresIn The number of seconds until the signed URL expires
* @returns The content as a JSON object
*/
static async signAttachmentUrls(doc: Node, teamId: string, expiresIn = 60) {
const attachmentIds = ProsemirrorHelper.parseAttachmentIds(doc);
const attachments = await Attachment.findAll({
where: {
id: attachmentIds,
teamId,
},
});
const mapping: Record<string, string> = {};
await Promise.all(
attachments.map(async (attachment) => {
const signedUrl = await FileStorage.getSignedUrl(
attachment.key,
expiresIn
);
mapping[attachment.redirectUrl] = signedUrl;
})
);
const json = doc.toJSON() as ProsemirrorData;
function replaceAttachmentUrls(node: ProsemirrorData) {
if (node.attrs?.src) {
node.attrs.src = mapping[node.attrs.src as string] || node.attrs.src;
} else if (node.attrs?.href) {
node.attrs.href = mapping[node.attrs.href as string] || node.attrs.href;
} else if (node.marks) {
node.marks.forEach((mark) => {
if (mark.attrs?.href) {
mark.attrs.href =
mapping[mark.attrs.href as string] || mark.attrs.href;
}
});
}
if (node.content) {
node.content.forEach(replaceAttachmentUrls);
}
return node;
}
return replaceAttachmentUrls(json);
}
/**
* Returns an array of attachment IDs in the node.
*
* @param node The node to parse attachments from
* @returns An array of attachment IDs
*/
static parseAttachmentIds(doc: Node) {
const urls: string[] = [];
doc.descendants((node) => {
node.marks.forEach((mark) => {
if (mark.type.name === "link") {
urls.push(mark.attrs.href);
}
});
if (["image", "video"].includes(node.type.name)) {
urls.push(node.attrs.src);
}
if (node.type.name === "attachment") {
urls.push(node.attrs.href);
}
});
return uniq(
compact(
flatten(
urls.map((url) =>
[...url.matchAll(attachmentRedirectRegex)].map(
(match) => match.groups?.id
)
)
)
)
);
}
/**
* Returns the node as HTML. This is a lossy conversion and should only be used
* for export.

View File

@@ -12,7 +12,7 @@ import Share from "@server/models/Share";
import Team from "@server/models/Team";
import User from "@server/models/User";
import { sequelize } from "@server/storage/database";
import DocumentHelper from "./DocumentHelper";
import { DocumentHelper } from "./DocumentHelper";
type SearchResponse = {
results: {

View File

@@ -1,5 +1,5 @@
import { buildUser } from "@server/test/factories";
import TextHelper from "./TextHelper";
import { TextHelper } from "./TextHelper";
describe("TextHelper", () => {
beforeAll(() => {

View File

@@ -16,7 +16,7 @@ import parseAttachmentIds from "@server/utils/parseAttachmentIds";
import parseImages from "@server/utils/parseImages";
@trace()
export default class TextHelper {
export class TextHelper {
/**
* Replaces template variables in the given text with the current date and time.
*

View File

@@ -2,7 +2,7 @@ import size from "lodash/size";
import { Node } from "prosemirror-model";
import { addAttributeOptions } from "sequelize-typescript";
import { ProsemirrorData } from "@shared/types";
import ProsemirrorHelper from "@shared/utils/ProsemirrorHelper";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { schema } from "@server/editor";
/**

View File

@@ -1,6 +1,8 @@
import { traceFunction } from "@server/logging/tracing";
import { Document } from "@server/models";
import TextHelper from "@server/models/helpers/TextHelper";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import { TextHelper } from "@server/models/helpers/TextHelper";
import { APIContext } from "@server/types";
import presentUser from "./user";
type Options = {
@@ -8,6 +10,7 @@ type Options = {
};
async function presentDocument(
ctx: APIContext | undefined,
document: Document,
options: Options | null | undefined = {}
) {
@@ -15,17 +18,32 @@ async function presentDocument(
isPublic: false,
...options,
};
const text = options.isPublic
? await TextHelper.attachmentsToSignedUrls(document.text, document.teamId)
: document.text;
const asData = !ctx || Number(ctx?.headers["x-api-version"] ?? 0) >= 3;
const text =
options.isPublic && !asData
? await TextHelper.attachmentsToSignedUrls(document.text, document.teamId)
: document.text;
const data: Record<string, any> = {
id: document.id,
url: document.url,
urlId: document.urlId,
title: document.title,
data: asData
? await DocumentHelper.toJSON(
document,
options.isPublic
? {
signedUrls: 60,
teamId: document.teamId,
removeMarks: ["comment"],
}
: undefined
)
: undefined,
text: asData ? undefined : text,
emoji: document.emoji,
text,
tasks: document.tasks,
createdAt: document.createdAt,
createdBy: undefined,
@@ -41,7 +59,7 @@ async function presentDocument(
collectionId: undefined,
parentDocumentId: undefined,
lastViewedAt: undefined,
isCollectionDeleted: await document.isCollectionDeleted(),
isCollectionDeleted: undefined,
};
if (!!document.views && document.views.length > 0) {
@@ -51,6 +69,7 @@ async function presentDocument(
if (!options.isPublic) {
const source = await document.$get("import");
data.isCollectionDeleted = await document.isCollectionDeleted();
data.collectionId = document.collectionId;
data.parentDocumentId = document.parentDocumentId;
data.createdBy = presentUser(document.createdBy);

View File

@@ -1,8 +1,12 @@
import { Notification } from "@server/models";
import { APIContext } from "@server/types";
import presentUser from "./user";
import { presentComment, presentDocument } from ".";
export default async function presentNotification(notification: Notification) {
export default async function presentNotification(
ctx: APIContext | undefined,
notification: Notification
) {
return {
id: notification.id,
viewedAt: notification.viewedAt,
@@ -18,7 +22,7 @@ export default async function presentNotification(notification: Notification) {
: undefined,
documentId: notification.documentId,
document: notification.document
? await presentDocument(notification.document)
? await presentDocument(ctx, notification.document)
: undefined,
revisionId: notification.revisionId,
collectionId: notification.collectionId,

View File

@@ -1,7 +1,7 @@
import parseTitle from "@shared/utils/parseTitle";
import { traceFunction } from "@server/logging/tracing";
import { Revision } from "@server/models";
import DocumentHelper from "@server/models/helpers/DocumentHelper";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import presentUser from "./user";
async function presentRevision(revision: Revision, diff?: string) {
@@ -12,7 +12,7 @@ async function presentRevision(revision: Revision, diff?: string) {
id: revision.id,
documentId: revision.documentId,
title: strippedTitle,
text: DocumentHelper.toMarkdown(revision),
data: await DocumentHelper.toJSON(revision),
emoji: revision.emoji ?? emoji,
html: diff,
createdAt: revision.createdAt,

View File

@@ -81,7 +81,7 @@ export default class WebsocketsProcessor {
if (!document) {
return;
}
const data = await presentDocument(document);
const data = await presentDocument(undefined, document);
const channels = await this.getDocumentEventChannels(event, document);
return socketio.to(channels).emit(event.name, data);
}
@@ -452,7 +452,7 @@ export default class WebsocketsProcessor {
return;
}
const data = await presentNotification(notification);
const data = await presentNotification(undefined, notification);
return socketio.to(`user-${event.userId}`).emit(event.name, data);
}

View File

@@ -18,7 +18,7 @@ export default class CleanupDeletedDocumentsTask extends BaseTask<Props> {
`Permanently destroying upto ${limit} documents older than 30 days…`
);
const documents = await Document.scope("withDrafts").findAll({
attributes: ["id", "teamId", "text", "deletedAt"],
attributes: ["id", "teamId", "content", "text", "deletedAt"],
where: {
deletedAt: {
[Op.lt]: subDays(new Date(), 30),

View File

@@ -2,7 +2,7 @@ import { NotificationEventType } from "@shared/types";
import subscriptionCreator from "@server/commands/subscriptionCreator";
import { Comment, Document, Notification, User } from "@server/models";
import NotificationHelper from "@server/models/helpers/NotificationHelper";
import ProsemirrorHelper from "@server/models/helpers/ProsemirrorHelper";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import { sequelize } from "@server/storage/database";
import { CommentEvent } from "@server/types";
import BaseTask, { TaskPriority } from "./BaseTask";

View File

@@ -1,6 +1,6 @@
import { NotificationEventType } from "@shared/types";
import { Comment, Document, Notification, User } from "@server/models";
import ProsemirrorHelper from "@server/models/helpers/ProsemirrorHelper";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import { CommentEvent, CommentUpdateEvent } from "@server/types";
import BaseTask, { TaskPriority } from "./BaseTask";

View File

@@ -1,7 +1,7 @@
import { NotificationEventType } from "@shared/types";
import { createSubscriptionsForDocument } from "@server/commands/subscriptionCreator";
import { Document, Notification, User } from "@server/models";
import DocumentHelper from "@server/models/helpers/DocumentHelper";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import NotificationHelper from "@server/models/helpers/NotificationHelper";
import { DocumentEvent } from "@server/types";
import BaseTask, { TaskPriority } from "./BaseTask";

View File

@@ -6,7 +6,7 @@ import Logger from "@server/logging/Logger";
import { Collection } from "@server/models";
import Attachment from "@server/models/Attachment";
import Document from "@server/models/Document";
import DocumentHelper from "@server/models/helpers/DocumentHelper";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import ZipHelper from "@server/utils/ZipHelper";
import { serializeFilename } from "@server/utils/fs";
import parseAttachmentIds from "@server/utils/parseAttachmentIds";

View File

@@ -10,12 +10,12 @@ import {
Document,
FileOperation,
} from "@server/models";
import DocumentHelper from "@server/models/helpers/DocumentHelper";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import { presentAttachment, presentCollection } from "@server/presenters";
import { CollectionJSONExport, JSONExportMetadata } from "@server/types";
import ZipHelper from "@server/utils/ZipHelper";
import { serializeFilename } from "@server/utils/fs";
import parseAttachmentIds from "@server/utils/parseAttachmentIds";
import packageJson from "../../../package.json";
import ExportTask from "./ExportTask";
@@ -87,7 +87,9 @@ export default class ExportJSONTask extends ExportTask {
? await Attachment.findAll({
where: {
teamId: document.teamId,
id: parseAttachmentIds(document.text),
id: ProsemirrorHelper.parseAttachmentIds(
DocumentHelper.toProsemirror(document)
),
},
})
: [];

View File

@@ -6,7 +6,7 @@ import { createSubscriptionsForDocument } from "@server/commands/subscriptionCre
import env from "@server/env";
import Logger from "@server/logging/Logger";
import { Document, Revision, Notification, User, View } from "@server/models";
import DocumentHelper from "@server/models/helpers/DocumentHelper";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import NotificationHelper from "@server/models/helpers/NotificationHelper";
import { RevisionEvent } from "@server/types";
import BaseTask, { TaskPriority } from "./BaseTask";

View File

@@ -17,7 +17,7 @@ import {
User,
GroupPermission,
} from "@server/models";
import DocumentHelper from "@server/models/helpers/DocumentHelper";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import {
buildShare,
buildCollection,
@@ -3467,7 +3467,6 @@ describe("#documents.update", () => {
token: user.getJwtToken(),
id: document.id,
title: document.title,
text: document.text,
},
});
expect(res.status).toEqual(200);

View File

@@ -45,7 +45,8 @@ import {
UserMembership,
} from "@server/models";
import AttachmentHelper from "@server/models/helpers/AttachmentHelper";
import DocumentHelper from "@server/models/helpers/DocumentHelper";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import SearchHelper from "@server/models/helpers/SearchHelper";
import { authorize, cannot } from "@server/policies";
import {
@@ -63,7 +64,6 @@ import FileStorage from "@server/storage/files";
import { APIContext } from "@server/types";
import { RateLimiterStrategy } from "@server/utils/RateLimiter";
import ZipHelper from "@server/utils/ZipHelper";
import parseAttachmentIds from "@server/utils/parseAttachmentIds";
import { getTeamFromContext } from "@server/utils/passport";
import { assertPresent } from "@server/validation";
import pagination from "../middlewares/pagination";
@@ -191,7 +191,7 @@ router.post(
}
const data = await Promise.all(
documents.map((document) => presentDocument(document))
documents.map((document) => presentDocument(ctx, document))
);
const policies = presentPolicies(user, documents);
ctx.body = {
@@ -224,7 +224,7 @@ router.post(
limit: ctx.state.pagination.limit,
});
const data = await Promise.all(
documents.map((document) => presentDocument(document))
documents.map((document) => presentDocument(ctx, document))
);
const policies = presentPolicies(user, documents);
@@ -287,7 +287,7 @@ router.post(
limit: ctx.state.pagination.limit,
});
const data = await Promise.all(
documents.map((document) => presentDocument(document))
documents.map((document) => presentDocument(ctx, document))
);
const policies = presentPolicies(user, documents);
@@ -343,7 +343,7 @@ router.post(
return document;
});
const data = await Promise.all(
documents.map((document) => presentDocument(document))
documents.map((document) => presentDocument(ctx, document))
);
const policies = presentPolicies(user, documents);
@@ -399,7 +399,7 @@ router.post(
limit: ctx.state.pagination.limit,
});
const data = await Promise.all(
documents.map((document) => presentDocument(document))
documents.map((document) => presentDocument(ctx, document))
);
const policies = presentPolicies(user, documents);
@@ -416,8 +416,9 @@ router.post(
auth({ optional: true }),
validate(T.DocumentsInfoSchema),
async (ctx: APIContext<T.DocumentsInfoReq>) => {
const { id, shareId, apiVersion } = ctx.input.body;
const { id, shareId } = ctx.input.body;
const { user } = ctx.state.auth;
const apiVersion = getAPIVersion(ctx);
const teamFromCtx = await getTeamFromContext(ctx);
const { document, share, collection } = await documentLoader({
id,
@@ -426,7 +427,7 @@ router.post(
teamId: teamFromCtx?.id,
});
const isPublic = cannot(user, "read", document);
const serializedDocument = await presentDocument(document, {
const serializedDocument = await presentDocument(ctx, document, {
isPublic,
});
@@ -435,7 +436,7 @@ router.post(
// Passing apiVersion=2 has a single effect, to change the response payload to
// include top level keys for document, sharedTree, and team.
const data =
apiVersion === 2
apiVersion >= 2
? {
document: serializedDocument,
team: team?.getPreference(TeamPreference.PublicBranding)
@@ -572,7 +573,9 @@ router.post(
contentType === "text/markdown" ? "md" : mime.extension(contentType);
const fileName = slugify(document.titleWithDefault);
const attachmentIds = parseAttachmentIds(document.text);
const attachmentIds = ProsemirrorHelper.parseAttachmentIds(
DocumentHelper.toProsemirror(document)
);
const attachments = attachmentIds.length
? await Attachment.findAll({
where: {
@@ -729,7 +732,7 @@ router.post(
}
ctx.body = {
data: await presentDocument(document),
data: await presentDocument(ctx, document),
policies: presentPolicies(user, [document]),
};
}
@@ -769,7 +772,7 @@ router.post(
});
const policies = presentPolicies(user, documents);
const data = await Promise.all(
documents.map((document) => presentDocument(document))
documents.map((document) => presentDocument(ctx, document))
);
ctx.body = {
@@ -895,7 +898,7 @@ router.post(
const data = await Promise.all(
results.map(async (result) => {
const document = await presentDocument(result.document);
const document = await presentDocument(ctx, result.document);
return { ...result, document };
})
);
@@ -982,7 +985,7 @@ router.post(
invariant(reloaded, "document not found");
ctx.body = {
data: await presentDocument(reloaded),
data: await presentDocument(ctx, reloaded),
policies: presentPolicies(user, [reloaded]),
};
}
@@ -995,9 +998,10 @@ router.post(
transaction(),
async (ctx: APIContext<T.DocumentsUpdateReq>) => {
const { transaction } = ctx.state;
const { id, apiVersion, insightsEnabled, publish, collectionId, ...input } =
const { id, insightsEnabled, publish, collectionId, ...input } =
ctx.input.body;
const editorVersion = ctx.headers["x-editor-version"] as string | undefined;
const { user } = ctx.state.auth;
let collection: Collection | null | undefined;
@@ -1052,15 +1056,7 @@ router.post(
document.collection = collection;
ctx.body = {
data:
apiVersion === 2
? {
document: await presentDocument(document),
collection: collection
? presentCollection(collection)
: undefined,
}
: await presentDocument(document),
data: await presentDocument(ctx, document),
policies: presentPolicies(user, [document, collection]),
};
}
@@ -1120,7 +1116,7 @@ router.post(
ctx.body = {
data: {
documents: await Promise.all(
response.map((document) => presentDocument(document))
response.map((document) => presentDocument(ctx, document))
),
},
policies: presentPolicies(user, response),
@@ -1173,7 +1169,7 @@ router.post(
ctx.body = {
data: {
documents: await Promise.all(
documents.map((document) => presentDocument(document))
documents.map((document) => presentDocument(ctx, document))
),
collections: await Promise.all(
collections.map((collection) => presentCollection(collection))
@@ -1211,7 +1207,7 @@ router.post(
});
ctx.body = {
data: await presentDocument(document),
data: await presentDocument(ctx, document),
policies: presentPolicies(user, [document]),
};
}
@@ -1276,7 +1272,7 @@ router.post(
auth(),
validate(T.DocumentsUnpublishSchema),
async (ctx: APIContext<T.DocumentsUnpublishReq>) => {
const { id, apiVersion } = ctx.input.body;
const { id } = ctx.input.body;
const { user } = ctx.state.auth;
const document = await Document.findByPk(id, {
@@ -1309,15 +1305,7 @@ router.post(
});
ctx.body = {
data:
apiVersion === 2
? {
document: await presentDocument(document),
collection: document.collection
? presentCollection(document.collection)
: undefined,
}
: await presentDocument(document),
data: await presentDocument(ctx, document),
policies: presentPolicies(user, [document]),
};
}
@@ -1395,7 +1383,7 @@ router.post(
});
ctx.body = {
data: await presentDocument(document),
data: await presentDocument(ctx, document),
policies: presentPolicies(user, [document]),
};
}
@@ -1481,7 +1469,7 @@ router.post(
document.collection = collection;
ctx.body = {
data: await presentDocument(document),
data: await presentDocument(ctx, document),
policies: presentPolicies(user, [document]),
};
}
@@ -1760,4 +1748,16 @@ router.post(
}
);
// Remove this helper once apiVersion is removed (#6175)
function getAPIVersion(ctx: APIContext) {
return Number(
ctx.headers["x-api-version"] ??
(typeof ctx.input.body === "object" &&
ctx.input.body &&
"apiVersion" in ctx.input.body &&
ctx.input.body.apiVersion) ??
0
);
}
export default router;

View File

@@ -118,7 +118,7 @@ export const DocumentsInfoSchema = BaseSchema.extend({
.refine((val) => isUUID(val) || UrlHelper.SHARE_URL_SLUG_REGEX.test(val))
.optional(),
/** Version of the API to be used */
/** @deprecated Version of the API to be used, remove in a few releases */
apiVersion: z.number().optional(),
}),
}).refine((req) => !(isEmpty(req.body.id) && isEmpty(req.body.shareId)), {
@@ -241,7 +241,7 @@ export const DocumentsUpdateSchema = BaseSchema.extend({
/** Boolean to denote if text should be appended */
append: z.boolean().optional(),
/** Version of the API to be used */
/** @deprecated Version of the API to be used, remove in a few releases */
apiVersion: z.number().optional(),
/** Whether the editing session is complete */
@@ -287,7 +287,7 @@ export type DocumentsDeleteReq = z.infer<typeof DocumentsDeleteSchema>;
export const DocumentsUnpublishSchema = BaseSchema.extend({
body: BaseIdSchema.extend({
/** Version of the API to be used */
/** @deprecated Version of the API to be used, remove in a few releases */
apiVersion: z.number().optional(),
}),
});

View File

@@ -117,7 +117,9 @@ router.post(
pagination: { ...ctx.state.pagination, total },
data: {
notifications: await Promise.all(
notifications.map(presentNotification)
notifications.map((notification) =>
presentNotification(ctx, notification)
)
),
unseen,
},
@@ -172,7 +174,7 @@ router.post(
});
ctx.body = {
data: await presentNotification(notification),
data: await presentNotification(ctx, notification),
policies: presentPolicies(user, [notification]),
};
}

View File

@@ -96,7 +96,7 @@ router.post(
data: {
pins: pins.map(presentPin),
documents: await Promise.all(
documents.map((document: Document) => presentDocument(document))
documents.map((document: Document) => presentDocument(ctx, document))
),
},
policies,

View File

@@ -105,7 +105,23 @@ describe("#revisions.diff", () => {
});
await Revision.createFromDocument(document);
await document.update({ text: "New text" });
await document.update({
content: {
type: "doc",
content: [
{
type: "paragraph",
content: [
{
content: [],
type: "text",
text: "New text",
},
],
},
],
},
});
const revision1 = await Revision.createFromDocument(document);
const res = await server.post("/api/revisions.diff", {

View File

@@ -6,7 +6,7 @@ import { ValidationError } from "@server/errors";
import auth from "@server/middlewares/authentication";
import validate from "@server/middlewares/validate";
import { Document, Revision } from "@server/models";
import DocumentHelper from "@server/models/helpers/DocumentHelper";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import { authorize } from "@server/policies";
import { presentRevision } from "@server/presenters";
import { APIContext } from "@server/types";

View File

@@ -112,7 +112,7 @@ router.post(
data: {
stars: stars.map(presentStar),
documents: await Promise.all(
documents.map((document: Document) => presentDocument(document))
documents.map((document: Document) => presentDocument(ctx, document))
),
},
policies,

View File

@@ -63,7 +63,7 @@ router.post(
data: {
memberships: memberships.map(presentMembership),
documents: await Promise.all(
documents.map((document: Document) => presentDocument(document))
documents.map((document: Document) => presentDocument(ctx, document))
),
},
policies,

View File

@@ -2,6 +2,7 @@ import "./bootstrap";
import { yDocToProsemirrorJSON } from "@getoutline/y-prosemirror";
import { Node } from "prosemirror-model";
import * as Y from "yjs";
import { ProsemirrorData } from "@shared/types";
import { parser, schema } from "@server/editor";
import { Document } from "@server/models";
@@ -31,7 +32,10 @@ export default async function main(exit = false) {
if ("state" in document && document.state) {
const ydoc = new Y.Doc();
Y.applyUpdate(ydoc, document.state);
document.content = yDocToProsemirrorJSON(ydoc, "default");
document.content = yDocToProsemirrorJSON(
ydoc,
"default"
) as ProsemirrorData;
} else {
const node = parser.parse(document.text) || Node.fromJSON(schema, {});
document.content = node.toJSON();

View File

@@ -13,6 +13,7 @@ import {
NotificationEventType,
UserRole,
} from "@shared/types";
import { parser } from "@server/editor";
import {
Share,
Team,
@@ -371,10 +372,12 @@ export async function buildDocument(
overrides.collectionId = collection.id;
}
const text = overrides.text ?? "This is the text in an example document";
const document = await Document.create(
{
title: faker.lorem.words(4),
text: "This is the text in an example document",
content: overrides.content ?? parser.parse(text)?.toJSON(),
text,
publishedAt: isNull(overrides.collectionId) ? null : new Date(),
lastModifiedById: overrides.userId,
createdById: overrides.userId,
@@ -410,6 +413,7 @@ export async function buildComment(overrides: {
type: "paragraph",
content: [
{
content: [],
type: "text",
text: "test",
},

View File

@@ -1,5 +1,5 @@
import { Node } from "prosemirror-model";
import ProsemirrorHelper from "@shared/utils/ProsemirrorHelper";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { schema } from "@server/editor";
// Note: The test is here rather than shared to access the schema

View File

@@ -1,10 +1,9 @@
import compact from "lodash/compact";
import uniq from "lodash/uniq";
const attachmentRedirectRegex =
/\/api\/attachments\.redirect\?id=(?<id>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/gi;
const attachmentPublicRegex =
/public\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/(?<id>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/gi;
import {
attachmentPublicRegex,
attachmentRedirectRegex,
} from "@shared/utils/ProsemirrorHelper";
export default function parseAttachmentIds(
text: string,

View File

@@ -1,3 +1,3 @@
const EDITOR_VERSION = "12.0.0";
const EDITOR_VERSION = "13.0.0";
export default EDITOR_VERSION;

View File

@@ -374,4 +374,18 @@ export type JSONValue =
export type JSONObject = { [x: string]: JSONValue };
export type ProsemirrorData = JSONObject;
export type ProsemirrorData = {
type: string;
content: ProsemirrorData[];
text?: string;
attrs?: JSONObject;
marks?: {
type: string;
attrs: JSONObject;
}[];
};
export type ProsemirrorDoc = {
type: "doc";
content: ProsemirrorData[];
};

View File

@@ -1,18 +0,0 @@
import MarkdownHelper from "./MarkdownHelper";
describe("#MarkdownHelper", () => {
it("should serialize title and text", () => {
expect(MarkdownHelper.toMarkdown({ title: "Title", text: "Test" })).toEqual(
"# Title\n\nTest"
);
});
it("should trim backslashes", () => {
expect(
MarkdownHelper.toMarkdown({
title: "Title",
text: "One\n\\\nTest\n\\",
})
).toEqual("# Title\n\nOne\n\nTest");
});
});

View File

@@ -1,29 +0,0 @@
interface DocumentInterface {
emoji?: string | null;
title: string;
text: string;
}
export default class MarkdownHelper {
/**
* Returns the document as cleaned Markdown for export.
*
* @param document The document or revision to convert
* @returns The document title and content as a Markdown string
*/
static toMarkdown(document: DocumentInterface) {
const text = document.text
.replace(/\n\\(\n|$)/g, "\n\n")
.replace(/“/g, '"')
.replace(/”/g, '"')
.replace(//g, "'")
.replace(//g, "'")
.trim();
const title = `${document.emoji ? document.emoji + " " : ""}${
document.title
}`;
return `# ${title}\n\n${text}`;
}
}

View File

@@ -1,6 +1,7 @@
import { Node, Schema } from "prosemirror-model";
import headingToSlug from "../editor/lib/headingToSlug";
import textBetween from "../editor/lib/textBetween";
import { ProsemirrorData } from "../types";
export type Heading = {
/* The heading in plain text */
@@ -27,7 +28,30 @@ export type Task = {
completed: boolean;
};
export default class ProsemirrorHelper {
export const attachmentRedirectRegex =
/\/api\/attachments\.redirect\?id=(?<id>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/gi;
export const attachmentPublicRegex =
/public\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/(?<id>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/gi;
export class ProsemirrorHelper {
/**
* Get a new empty document.
*
* @returns A new empty document as JSON.
*/
static getEmptyDocument(): ProsemirrorData {
return {
type: "doc",
content: [
{
content: [],
type: "paragraph",
},
],
};
}
/**
* Returns the node as plain text.
*
@@ -160,6 +184,21 @@ export default class ProsemirrorHelper {
return tasks;
}
/**
* Returns a summary of total and completed tasks in the node.
*
* @param doc Prosemirror document node
* @returns Object with completed and total keys
*/
static getTasksSummary(doc: Node): { completed: number; total: number } {
const tasks = ProsemirrorHelper.getTasks(doc);
return {
completed: tasks.filter((t) => t.completed).length,
total: tasks.length,
};
}
/**
* Iterates through the document to find all of the headings and their level.
*

View File

@@ -1,23 +0,0 @@
const CHECKBOX_REGEX = /\[(X|\s|_|-)\]\s(.*)?/gi;
export default function getTasks(text: string) {
const matches = [...text.matchAll(CHECKBOX_REGEX)];
const total = matches.length;
if (!total) {
return {
completed: 0,
total: 0,
};
} else {
const notCompleted = matches.reduce(
(accumulator, match) =>
match[1] === " " ? accumulator + 1 : accumulator,
0
);
return {
completed: total - notCompleted,
total,
};
}
}