JSON to client (#5553)
This commit is contained in:
@@ -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"));
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
`;
|
||||
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -21,6 +21,7 @@ describe("commentCreator", () => {
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
content: [],
|
||||
type: "text",
|
||||
text: "test",
|
||||
},
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { buildUser } from "@server/test/factories";
|
||||
import TextHelper from "./TextHelper";
|
||||
import { TextHelper } from "./TextHelper";
|
||||
|
||||
describe("TextHelper", () => {
|
||||
beforeAll(() => {
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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";
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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)
|
||||
),
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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]),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
const EDITOR_VERSION = "12.0.0";
|
||||
const EDITOR_VERSION = "13.0.0";
|
||||
|
||||
export default EDITOR_VERSION;
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user