mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bff6a80b67 | |||
| 07ad87f65f | |||
| dd471328db | |||
| 04f0983e20 |
@@ -130,6 +130,7 @@
|
||||
"mobx": "^4.15.4",
|
||||
"mobx-react": "^6.3.1",
|
||||
"natural-sort": "^1.0.0",
|
||||
"node-htmldiff": "^0.9.3",
|
||||
"nodemailer": "^6.6.1",
|
||||
"outline-icons": "^1.42.0",
|
||||
"oy-vey": "^0.10.0",
|
||||
|
||||
@@ -21,6 +21,7 @@ export default async function revisionCreator({
|
||||
{
|
||||
name: "revisions.create",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
modelId: revision.id,
|
||||
teamId: document.teamId,
|
||||
actorId: user.id,
|
||||
|
||||
@@ -67,6 +67,7 @@ export default abstract class BaseEmail<T extends EmailProps, S = any> {
|
||||
previewText: this.preview(data),
|
||||
component: this.render(data),
|
||||
text: this.renderAsText(data),
|
||||
headCSS: this.headCSS ? this.headCSS(data) : undefined,
|
||||
});
|
||||
Metrics.increment("email.sent", {
|
||||
templateName,
|
||||
@@ -114,6 +115,14 @@ export default abstract class BaseEmail<T extends EmailProps, S = any> {
|
||||
*/
|
||||
protected abstract render(props: S & T): JSX.Element;
|
||||
|
||||
/**
|
||||
* Allows injecting additional CSS into the head of the email.
|
||||
*
|
||||
* @param props Props in email constructor
|
||||
* @returns A string of CSS
|
||||
*/
|
||||
protected headCSS?(props: T): string;
|
||||
|
||||
/**
|
||||
* beforeSend hook allows async loading additional data that was not passed
|
||||
* through the serialized worker props.
|
||||
|
||||
@@ -4,11 +4,13 @@ import { Document } from "@server/models";
|
||||
import BaseEmail from "./BaseEmail";
|
||||
import Body from "./components/Body";
|
||||
import Button from "./components/Button";
|
||||
import Diff from "./components/Diff";
|
||||
import EmailTemplate from "./components/EmailLayout";
|
||||
import EmptySpace from "./components/EmptySpace";
|
||||
import Footer from "./components/Footer";
|
||||
import Header from "./components/Header";
|
||||
import Heading from "./components/Heading";
|
||||
import { css } from "./components/css";
|
||||
|
||||
type InputProps = {
|
||||
to: string;
|
||||
@@ -18,6 +20,7 @@ type InputProps = {
|
||||
eventName: string;
|
||||
teamUrl: string;
|
||||
unsubscribeUrl: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
type BeforeSend = {
|
||||
@@ -45,7 +48,11 @@ export default class DocumentNotificationEmail extends BaseEmail<
|
||||
}
|
||||
|
||||
protected preview({ actorName, eventName }: Props): string {
|
||||
return `${actorName} ${eventName} a new document`;
|
||||
return `${actorName} ${eventName} a document`;
|
||||
}
|
||||
|
||||
protected headCSS(): string {
|
||||
return css;
|
||||
}
|
||||
|
||||
protected renderAsText({
|
||||
@@ -71,7 +78,10 @@ Open Document: ${teamUrl}${document.url}
|
||||
eventName = "published",
|
||||
teamUrl,
|
||||
unsubscribeUrl,
|
||||
content,
|
||||
}: Props) {
|
||||
const link = `${teamUrl}${document.url}?ref=notification-email`;
|
||||
|
||||
return (
|
||||
<EmailTemplate>
|
||||
<Header />
|
||||
@@ -84,12 +94,17 @@ Open Document: ${teamUrl}${document.url}
|
||||
{actorName} {eventName} the document "{document.title}", in the{" "}
|
||||
{collectionName} collection.
|
||||
</p>
|
||||
<hr />
|
||||
<EmptySpace height={10} />
|
||||
<p>{document.getSummary()}</p>
|
||||
<EmptySpace height={10} />
|
||||
{content && (
|
||||
<>
|
||||
<EmptySpace height={20} />
|
||||
<Diff href={link}>
|
||||
<div dangerouslySetInnerHTML={{ __html: content }} />
|
||||
</Diff>
|
||||
<EmptySpace height={20} />
|
||||
</>
|
||||
)}
|
||||
<p>
|
||||
<Button href={`${teamUrl}${document.url}`}>Open Document</Button>
|
||||
<Button href={link}>Open Document</Button>
|
||||
</p>
|
||||
</Body>
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ Join now: ${teamUrl}
|
||||
</p>
|
||||
<EmptySpace height={10} />
|
||||
<p>
|
||||
<Button href={teamUrl}>Join now</Button>
|
||||
<Button href={`${teamUrl}?ref=invite-email`}>Join now</Button>
|
||||
</p>
|
||||
</Body>
|
||||
|
||||
|
||||
@@ -59,7 +59,9 @@ If you haven't signed up yet, you can do so here: ${teamUrl}
|
||||
<p>If you haven't signed up yet, you can do so here:</p>
|
||||
<EmptySpace height={10} />
|
||||
<p>
|
||||
<Button href={teamUrl}>Join now</Button>
|
||||
<Button href={`${teamUrl}?ref=invite-reminder-email`}>
|
||||
Join now
|
||||
</Button>
|
||||
</p>
|
||||
</Body>
|
||||
|
||||
|
||||
@@ -59,7 +59,9 @@ ${teamUrl}/home
|
||||
</p>
|
||||
<EmptySpace height={10} />
|
||||
<p>
|
||||
<Button href={`${teamUrl}/home`}>View my dashboard</Button>
|
||||
<Button href={`${teamUrl}/home?ref=welcome-email`}>
|
||||
View my dashboard
|
||||
</Button>
|
||||
</p>
|
||||
</Body>
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import * as React from "react";
|
||||
import theme from "@shared/styles/theme";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
href?: string;
|
||||
};
|
||||
|
||||
export default ({ children, ...rest }: Props) => {
|
||||
const style = {
|
||||
borderRadius: "4px",
|
||||
background: theme.secondaryBackground,
|
||||
padding: ".5em 1em",
|
||||
color: theme.text,
|
||||
display: "block",
|
||||
textDecoration: "none",
|
||||
width: "100%",
|
||||
};
|
||||
|
||||
return (
|
||||
<a style={style} className="content-diff" {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,218 @@
|
||||
import { transparentize } from "polished";
|
||||
import theme from "@shared/styles/theme";
|
||||
|
||||
export const css = `
|
||||
.content-diff {
|
||||
font-family: ${theme.fontFamily};
|
||||
font-weight: ${theme.fontWeight};
|
||||
font-size: 1em;
|
||||
line-height: 1.7em;
|
||||
}
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.content-diff img {
|
||||
text-align: center;
|
||||
max-width: 100%;
|
||||
max-height: 75vh;
|
||||
clear: both;
|
||||
}
|
||||
.content-diff img.image-right-50 {
|
||||
float: right;
|
||||
width: 50%;
|
||||
margin-left: 2em;
|
||||
margin-bottom: 1em;
|
||||
clear: initial;
|
||||
}
|
||||
.content-diff img.image-left-50 {
|
||||
float: left;
|
||||
width: 50%;
|
||||
margin-right: 2em;
|
||||
margin-bottom: 1em;
|
||||
clear: initial;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin: 1em 0 0.5em;
|
||||
font-weight: 500;
|
||||
}
|
||||
.notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: ${transparentize(0.9, theme.noticeInfoBackground)};
|
||||
border-left: 4px solid ${theme.noticeInfoBackground};
|
||||
color: ${theme.noticeInfoText};
|
||||
border-radius: 4px;
|
||||
padding: 8px 10px 8px 8px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.notice-tip {
|
||||
background: ${transparentize(0.9, theme.noticeTipBackground)};
|
||||
border-left: 4px solid ${theme.noticeTipBackground};
|
||||
color: ${theme.noticeTipText};
|
||||
}
|
||||
.notice-warning {
|
||||
background: ${transparentize(0.9, theme.noticeWarningBackground)};
|
||||
border-left: 4px solid ${theme.noticeWarningBackground};
|
||||
color: ${theme.noticeWarningText};
|
||||
}
|
||||
b,
|
||||
strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
a {
|
||||
color: ${theme.link};
|
||||
}
|
||||
ins {
|
||||
background-color: #128a2929;
|
||||
text-decoration: none;
|
||||
}
|
||||
del {
|
||||
background-color: ${theme.slateLight};
|
||||
color: ${theme.slate};
|
||||
text-decoration: strikethrough;
|
||||
}
|
||||
hr {
|
||||
position: relative;
|
||||
height: 1em;
|
||||
border: 0;
|
||||
}
|
||||
hr:before {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
border-top: 1px solid ${theme.horizontalRule};
|
||||
top: 0.5em;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
hr.page-break {
|
||||
page-break-after: always;
|
||||
}
|
||||
hr.page-break:before {
|
||||
border-top: 1px dashed ${theme.horizontalRule};
|
||||
}
|
||||
code {
|
||||
border-radius: 4px;
|
||||
border: 1px solid ${theme.codeBorder};
|
||||
padding: 3px 4px;
|
||||
font-family: ${theme.fontFamilyMono};
|
||||
font-size: 85%;
|
||||
}
|
||||
mark {
|
||||
border-radius: 1px;
|
||||
color: ${theme.textHighlightForeground};
|
||||
background: ${theme.textHighlight};
|
||||
a {
|
||||
color: ${theme.textHighlightForeground};
|
||||
}
|
||||
}
|
||||
ul {
|
||||
padding-left: 0;
|
||||
}
|
||||
.checkbox-list-item {
|
||||
list-style: none;
|
||||
padding: 4px 0;
|
||||
margin: 0;
|
||||
}
|
||||
.checkbox {
|
||||
font-size: 0;
|
||||
display: block;
|
||||
float: left;
|
||||
white-space: nowrap;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-top: 2px;
|
||||
margin-right: 8px;
|
||||
border: 1px solid ${theme.textSecondary};
|
||||
border-radius: 3px;
|
||||
}
|
||||
pre {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
padding: 0.75em 1em;
|
||||
line-height: 1.4em;
|
||||
position: relative;
|
||||
background: ${theme.codeBackground};
|
||||
border-radius: 4px;
|
||||
border: 1px solid ${theme.codeBorder};
|
||||
-webkit-font-smoothing: initial;
|
||||
font-family: ${theme.fontFamilyMono};
|
||||
font-size: 13px;
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
white-space: pre;
|
||||
word-spacing: normal;
|
||||
word-break: normal;
|
||||
-moz-tab-size: 4;
|
||||
-o-tab-size: 4;
|
||||
tab-size: 4;
|
||||
-webkit-hyphens: none;
|
||||
-moz-hyphens: none;
|
||||
-ms-hyphens: none;
|
||||
hyphens: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
pre code {
|
||||
font-size: 13px;
|
||||
background: none;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 0;
|
||||
padding-left: 1.5em;
|
||||
font-style: italic;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
blockquote:before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 2px;
|
||||
border-radius: 1px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: ${theme.quote};
|
||||
}
|
||||
|
||||
.content-diff table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border-radius: 4px;
|
||||
margin-top: 1em;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.content-diff table * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.content-diff table tr {
|
||||
position: relative;
|
||||
border-bottom: 1px solid ${theme.tableDivider};
|
||||
}
|
||||
|
||||
.content-diff table td,
|
||||
.content-diff table th {
|
||||
position: relative;
|
||||
vertical-align: top;
|
||||
border: 1px solid ${theme.tableDivider};
|
||||
position: relative;
|
||||
padding: 4px 8px;
|
||||
min-width: 100px;
|
||||
}
|
||||
`;
|
||||
@@ -1,3 +1,4 @@
|
||||
import env from "@server/env";
|
||||
import Document from "@server/models/Document";
|
||||
import { Event } from "@server/types";
|
||||
import { globalEventQueue } from "..";
|
||||
@@ -15,7 +16,8 @@ export default class DebounceProcessor extends BaseProcessor {
|
||||
globalEventQueue.add(
|
||||
{ ...event, name: "documents.update.delayed" },
|
||||
{
|
||||
delay: 5 * 60 * 1000,
|
||||
// Revision creation time is lowered to half a minute in development
|
||||
delay: (env.ENVIRONMENT === "production" ? 5 : 0.5) * 60 * 1000,
|
||||
}
|
||||
);
|
||||
break;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import DocumentNotificationEmail from "@server/emails/templates/DocumentNotificationEmail";
|
||||
import { View, NotificationSetting } from "@server/models";
|
||||
import { View, NotificationSetting, Revision } from "@server/models";
|
||||
import {
|
||||
buildDocument,
|
||||
buildCollection,
|
||||
@@ -102,6 +102,7 @@ describe("documents.publish", () => {
|
||||
describe("revisions.create", () => {
|
||||
test("should send a notification to other collaborators", async () => {
|
||||
const document = await buildDocument();
|
||||
const revision = await Revision.createFromDocument(document);
|
||||
const collaborator = await buildUser({
|
||||
teamId: document.teamId,
|
||||
});
|
||||
@@ -118,12 +119,14 @@ describe("revisions.create", () => {
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
teamId: document.teamId,
|
||||
modelId: revision.id,
|
||||
});
|
||||
expect(DocumentNotificationEmail.schedule).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should not send a notification if viewed since update", async () => {
|
||||
const document = await buildDocument();
|
||||
const revision = await Revision.createFromDocument(document);
|
||||
const collaborator = await buildUser({
|
||||
teamId: document.teamId,
|
||||
});
|
||||
@@ -142,16 +145,18 @@ describe("revisions.create", () => {
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
teamId: document.teamId,
|
||||
modelId: revision.id,
|
||||
});
|
||||
expect(DocumentNotificationEmail.schedule).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should not send a notification to last editor", async () => {
|
||||
test("should not send a notification to last user that modified", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({
|
||||
teamId: user.teamId,
|
||||
lastModifiedById: user.id,
|
||||
});
|
||||
const revision = await Revision.createFromDocument(document);
|
||||
await NotificationSetting.create({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
@@ -163,6 +168,7 @@ describe("revisions.create", () => {
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
teamId: document.teamId,
|
||||
modelId: revision.id,
|
||||
});
|
||||
expect(DocumentNotificationEmail.schedule).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
Collection,
|
||||
User,
|
||||
NotificationSetting,
|
||||
Revision,
|
||||
Attachment,
|
||||
} from "@server/models";
|
||||
import {
|
||||
DocumentEvent,
|
||||
@@ -16,8 +18,27 @@ import {
|
||||
RevisionEvent,
|
||||
Event,
|
||||
} from "@server/types";
|
||||
import markdownDiff from "@server/utils/markdownDiff";
|
||||
import parseAttachmentIds from "@server/utils/parseAttachmentIds";
|
||||
import { getSignedUrl } from "@server/utils/s3";
|
||||
import BaseProcessor from "./BaseProcessor";
|
||||
|
||||
async function replaceImageAttachments(text: string) {
|
||||
const attachmentIds = parseAttachmentIds(text);
|
||||
|
||||
await Promise.all(
|
||||
attachmentIds.map(async (id) => {
|
||||
const attachment = await Attachment.findByPk(id);
|
||||
if (attachment) {
|
||||
const accessUrl = await getSignedUrl(attachment.key, 86400 * 4);
|
||||
text = text.replace(attachment.redirectUrl, accessUrl);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
export default class NotificationsProcessor extends BaseProcessor {
|
||||
static applicableEvents: Event["name"][] = [
|
||||
"documents.publish",
|
||||
@@ -26,11 +47,17 @@ export default class NotificationsProcessor extends BaseProcessor {
|
||||
];
|
||||
|
||||
async perform(event: Event) {
|
||||
// never send notifications when batch importing documents
|
||||
// @ts-expect-error More granular typing of events needed here
|
||||
if (event.data?.source === "import") {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.name) {
|
||||
case "documents.publish":
|
||||
return this.documentPublished(event);
|
||||
case "revisions.create":
|
||||
return this.documentUpdated(event);
|
||||
|
||||
return this.revisionCreated(event);
|
||||
case "collections.create":
|
||||
return this.collectionCreated(event);
|
||||
|
||||
@@ -38,30 +65,22 @@ export default class NotificationsProcessor extends BaseProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
async documentUpdated(event: DocumentEvent | RevisionEvent) {
|
||||
// never send notifications when batch importing documents
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'data' does not exist on type 'DocumentEv... Remove this comment to see the full error message
|
||||
if (event.data?.source === "import") {
|
||||
return;
|
||||
}
|
||||
const [document, team] = await Promise.all([
|
||||
async documentPublished(event: DocumentEvent) {
|
||||
const [collection, document, team] = await Promise.all([
|
||||
Collection.findByPk(event.collectionId),
|
||||
Document.findByPk(event.documentId),
|
||||
Team.findByPk(event.teamId),
|
||||
]);
|
||||
if (!document || !team || !document.collection) {
|
||||
if (!document || !team || !collection) {
|
||||
return;
|
||||
}
|
||||
const { collection } = document;
|
||||
const notificationSettings = await NotificationSetting.findAll({
|
||||
where: {
|
||||
userId: {
|
||||
[Op.ne]: document.lastModifiedById,
|
||||
},
|
||||
teamId: document.teamId,
|
||||
event:
|
||||
event.name === "documents.publish"
|
||||
? "documents.publish"
|
||||
: "documents.update",
|
||||
event: "documents.publish",
|
||||
},
|
||||
include: [
|
||||
{
|
||||
@@ -71,22 +90,10 @@ export default class NotificationsProcessor extends BaseProcessor {
|
||||
},
|
||||
],
|
||||
});
|
||||
const eventName =
|
||||
event.name === "documents.publish" ? "published" : "updated";
|
||||
|
||||
for (const setting of notificationSettings) {
|
||||
// Suppress notifications for suspended users
|
||||
if (setting.user.isSuspended) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// For document updates we only want to send notifications if
|
||||
// the document has been edited by the user with this notification setting
|
||||
// This could be replaced with ability to "follow" in the future
|
||||
if (
|
||||
eventName === "updated" &&
|
||||
!document.collaboratorIds.includes(setting.userId)
|
||||
) {
|
||||
if (setting.user.isSuspended || !setting.user.email) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -118,18 +125,115 @@ export default class NotificationsProcessor extends BaseProcessor {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!setting.user.email) {
|
||||
continue;
|
||||
}
|
||||
const content = await replaceImageAttachments(document.getSummary());
|
||||
|
||||
await DocumentNotificationEmail.schedule({
|
||||
to: setting.user.email,
|
||||
eventName,
|
||||
eventName: "published",
|
||||
documentId: document.id,
|
||||
teamUrl: team.url,
|
||||
actorName: document.updatedBy.name,
|
||||
collectionName: collection.name,
|
||||
unsubscribeUrl: setting.unsubscribeUrl,
|
||||
content,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async revisionCreated(event: RevisionEvent) {
|
||||
const [collection, document, team, revision] = await Promise.all([
|
||||
Collection.findByPk(event.collectionId),
|
||||
Document.findByPk(event.documentId),
|
||||
Team.findByPk(event.teamId),
|
||||
Revision.findByPk(event.modelId),
|
||||
]);
|
||||
if (!document || !revision || !team || !collection) {
|
||||
return;
|
||||
}
|
||||
const notificationSettings = await NotificationSetting.findAll({
|
||||
where: {
|
||||
userId: {
|
||||
[Op.ne]: revision.userId,
|
||||
},
|
||||
teamId: document.teamId,
|
||||
event: "documents.update",
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
required: true,
|
||||
as: "user",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
for (const setting of notificationSettings) {
|
||||
// Suppress notifications for suspended users
|
||||
if (setting.user.isSuspended || !setting.user.email) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// For document updates we only want to send notifications if
|
||||
// the document has been edited by the user with this notification setting
|
||||
// This could be replaced with ability to "follow" in the future
|
||||
if (!document.collaboratorIds.includes(setting.userId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check the user has access to the collection this document is in. Just
|
||||
// because they were a collaborator once doesn't mean they still are.
|
||||
const collectionIds = await setting.user.collectionIds();
|
||||
|
||||
if (!collectionIds.includes(document.collectionId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If this user has viewed the document since the last update was made
|
||||
// then we can avoid sending them a useless notification, yay.
|
||||
const view = await View.findOne({
|
||||
where: {
|
||||
userId: setting.userId,
|
||||
documentId: event.documentId,
|
||||
updatedAt: {
|
||||
[Op.gt]: revision.createdAt,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (view) {
|
||||
Logger.info(
|
||||
"processor",
|
||||
`suppressing notification to ${setting.userId} because update viewed`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const previous = await Revision.findOne({
|
||||
where: {
|
||||
documentId: document.id,
|
||||
createdAt: {
|
||||
[Op.lt]: revision.createdAt,
|
||||
},
|
||||
},
|
||||
order: [["createdAt", "DESC"]],
|
||||
});
|
||||
|
||||
let content = markdownDiff(previous ? previous.text : "", revision.text);
|
||||
if (!content) {
|
||||
continue;
|
||||
}
|
||||
|
||||
content = await replaceImageAttachments(content);
|
||||
|
||||
await DocumentNotificationEmail.schedule({
|
||||
to: setting.user.email,
|
||||
eventName: "updated",
|
||||
documentId: document.id,
|
||||
teamUrl: team.url,
|
||||
actorName: document.updatedBy.name,
|
||||
collectionName: collection.name,
|
||||
unsubscribeUrl: setting.unsubscribeUrl,
|
||||
content,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+34
@@ -0,0 +1,34 @@
|
||||
# Heading 1
|
||||
|
||||
## Heading 2
|
||||
|
||||
This is a test paragraph
|
||||
|
||||
This is a second test paragraph. This is a second sentence.
|
||||
|
||||
This is a another test paragraph. This is a another sentence.
|
||||
|
||||
- list item 1
|
||||
- list item 2
|
||||
|
||||
```
|
||||
this is a codeblock
|
||||
```
|
||||
|
||||
:::info
|
||||
This is an info block
|
||||
:::
|
||||
|
||||
!!This is a placeholder!!
|
||||
|
||||
==this is a highlight==
|
||||
|
||||
- [ ] checklist item 1
|
||||
- [ ] checklist item 2
|
||||
- [x] checklist item 3
|
||||
|
||||
same on both sides
|
||||
|
||||
same on both sides
|
||||
|
||||
same on both sides
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# Heading 1
|
||||
|
||||
## Heading 2
|
||||
|
||||
This is a test paragraph
|
||||
|
||||
This is a second test paragraph. This is a second sentence.
|
||||
|
||||
This is a another test paragraph. This is a another sentence.
|
||||
|
||||
- list item 1
|
||||
|
||||
```
|
||||
this is a codeblock
|
||||
```
|
||||
|
||||
This is a new paragraph.
|
||||
|
||||
:::info
|
||||
This is an info block
|
||||
:::
|
||||
|
||||
!!This is a placeholder!!
|
||||
|
||||
==this is a highlight==
|
||||
|
||||
- [x] checklist item 1
|
||||
- [x] checklist item 2
|
||||
- [ ] checklist item 3
|
||||
- [ ] checklist item 4
|
||||
- [x] checklist item 5
|
||||
|
||||
same on both sides
|
||||
|
||||
same on both sides
|
||||
|
||||
same on both sides
|
||||
@@ -102,6 +102,7 @@ export type RevisionEvent = {
|
||||
name: "revisions.create";
|
||||
documentId: string;
|
||||
collectionId: string;
|
||||
modelId: string;
|
||||
teamId: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`should diff a complex document 1`] = `
|
||||
"<p>This is a second test paragraph. This is a second sentence.</p>
|
||||
<p>This is a another test paragraph. This is a another sentence.</p>
|
||||
<ul>
|
||||
<li>list item 1</li>
|
||||
<li data-diff-node=\\"del\\" data-operation-index=\\"1\\"><del data-operation-index=\\"1\\">list item 2</del></li></ul>
|
||||
<pre><code>this is a codeblock
|
||||
</code></pre><p data-diff-node=\\"ins\\" data-operation-index=\\"3\\"><ins data-operation-index=\\"3\\">This is a new paragraph.</ins></p>
|
||||
<div class=\\"notice notice-info\\">
|
||||
<p>This is an info block</p>
|
||||
</div>
|
||||
<p><span class=\\"placeholder\\">This is a placeholder</span></p>
|
||||
<p><span class=\\"highlight\\">this is a highlight</span></p>
|
||||
<ul>
|
||||
<li class=\\"checkbox-list-item\\"><span class=\\"checkbox checked\\">[<del data-operation-index=\\"5\\"> ]</del><ins data-operation-index=\\"5\\">x]</ins></span>checklist item 1</li><li class=\\"checkbox-list-item\\"><span class=\\"checkbox checked\\" data-diff-node=\\"ins\\" data-operation-index=\\"7\\"><ins data-operation-index=\\"7\\">[x]</ins></span><ins data-operation-index=\\"7\\">checklist item 2</ins></li>
|
||||
<li class=\\"checkbox-list-item\\"><span class=\\"checkbox \\">[ ]</span>checklist item <del data-operation-index=\\"9\\">2</del><ins data-operation-index=\\"9\\">3</ins></li><li class=\\"checkbox-list-item\\"><span class=\\"checkbox \\" data-diff-node=\\"ins\\" data-operation-index=\\"9\\"><ins data-operation-index=\\"9\\">[ ]</ins></span><ins data-operation-index=\\"9\\">checklist item 4</ins></li>
|
||||
<li class=\\"checkbox-list-item\\"><span class=\\"checkbox checked\\">[x]</span>checklist item <del data-operation-index=\\"11\\">3</del><ins data-operation-index=\\"11\\">5</ins></li>
|
||||
</ul>"
|
||||
`;
|
||||
|
||||
exports[`should return everything inserted when previously empty 1`] = `
|
||||
"<h1 data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">Heading 1</ins></h1><h2 data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">Heading 2</ins></h2><p data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">This is a test paragraph</ins></p><p data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">This is a second test paragraph. This is a second sentence.</ins></p><p data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">This is a another test paragraph. This is a another sentence.</ins></p><ul data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><li data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">list item 1</ins></li><li data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">list item 2</ins></li></ul><pre data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><code data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">this is a codeblock
|
||||
</ins></code></pre><div class=\\"notice notice-info\\" data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><p data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">This is an info block</ins></p></div><p data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><span class=\\"placeholder\\" data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">This is a placeholder</ins></span></p><p data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><span class=\\"highlight\\" data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">this is a highlight</ins></span></p><ul data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><li class=\\"checkbox-list-item\\" data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><span class=\\"checkbox \\" data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">[ ]</ins></span><ins data-operation-index=\\"0\\">checklist item 1</ins></li><li class=\\"checkbox-list-item\\" data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><span class=\\"checkbox \\" data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">[ ]</ins></span><ins data-operation-index=\\"0\\">checklist item 2</ins></li><li class=\\"checkbox-list-item\\" data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><span class=\\"checkbox checked\\" data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">[x]</ins></span><ins data-operation-index=\\"0\\">checklist item 3</ins></li></ul><p data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">same on both sides</ins></p><p data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">same on both sides</ins></p><p data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">same on both sides</ins></p>"
|
||||
`;
|
||||
@@ -0,0 +1,54 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import markdownDiff from "./markdownDiff";
|
||||
|
||||
it("should diff a complex document", async () => {
|
||||
const before = await fs.promises.readFile(
|
||||
path.resolve(process.cwd(), "server", "test", "fixtures", "complex.md"),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const after = await fs.promises.readFile(
|
||||
path.resolve(
|
||||
process.cwd(),
|
||||
"server",
|
||||
"test",
|
||||
"fixtures",
|
||||
"complexModified.md"
|
||||
),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const diff = markdownDiff(before, after);
|
||||
expect(diff).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should return empty string when both sides are empty", () => {
|
||||
const diff = markdownDiff("", "");
|
||||
expect(diff).toEqual("");
|
||||
});
|
||||
|
||||
it("should return everything inserted when previously empty", async () => {
|
||||
const content = await fs.promises.readFile(
|
||||
path.resolve(process.cwd(), "server", "test", "fixtures", "complex.md"),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const diff = markdownDiff("", content);
|
||||
expect(diff).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should return empty for changed nodes", async () => {
|
||||
// Note: This isn't ideal behavior, but it is current behavior. If the diffing
|
||||
// library is improved then we could potentially render the old + new heading
|
||||
// with ins/del tags as appropriate.
|
||||
const diff = markdownDiff("# Heading", "## Heading");
|
||||
expect(diff).toEqual("");
|
||||
});
|
||||
|
||||
it("should return deleted nodes", async () => {
|
||||
const diff = markdownDiff("", "");
|
||||
expect(diff).toEqual(
|
||||
'<p><del data-operation-index="0"><img src="/image.png" alt="caption"></del></p>'
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { findIndex, findLastIndex } from "lodash";
|
||||
import diff from "node-htmldiff";
|
||||
import renderToHtml from "@server/editor/renderToHtml";
|
||||
|
||||
export default function markdownDiff(
|
||||
before: string,
|
||||
after: string,
|
||||
fullDiff = false,
|
||||
buffer = 1
|
||||
) {
|
||||
// The basic idea here is to first render the Markdown to HTML, then diff the
|
||||
// HTML - both sides will have valid HTML so we should have a valid diff as well
|
||||
|
||||
const beforeHtml = renderToHtml(before);
|
||||
const afterHtml = renderToHtml(after);
|
||||
const diffHtml = diff(beforeHtml, afterHtml);
|
||||
|
||||
if (fullDiff) {
|
||||
return diffHtml;
|
||||
}
|
||||
|
||||
if (before === after) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Split diff at paragraphs and find the first and last changed tags
|
||||
// so we can chop around paragraphs rather than return the entire document.
|
||||
//
|
||||
// In an ideal world we'd use an AST here and parse that rather than be doing
|
||||
// operations on strings. I hope this can be revisted in the future with an
|
||||
// improved diffing library.
|
||||
const newParagraph = /(?:^|\n)<p>/;
|
||||
let lines = diffHtml.split(newParagraph);
|
||||
|
||||
const firstChangedLineIndex = findIndex(
|
||||
lines,
|
||||
(value) => value.includes("<ins ") || value.includes("<del ")
|
||||
);
|
||||
const lastChangedLineIndex = findLastIndex(
|
||||
lines,
|
||||
(value) => value.includes("</ins>") || value.includes("</del>")
|
||||
);
|
||||
|
||||
const start = Math.max(0, firstChangedLineIndex - buffer);
|
||||
const end = Math.min(lines.length, lastChangedLineIndex + buffer);
|
||||
lines = lines.slice(start, end);
|
||||
|
||||
if (!lines.length) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return [start > 0 ? "" : undefined, ...lines]
|
||||
.filter((x) => x !== undefined)
|
||||
.join("\n<p>")
|
||||
.trim();
|
||||
}
|
||||
@@ -10639,6 +10639,11 @@ node-gyp-build@^3.9.0:
|
||||
resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-3.9.0.tgz#53a350187dd4d5276750da21605d1cb681d09e25"
|
||||
integrity sha512-zLcTg6P4AbcHPq465ZMFNXx7XpKKJh+7kkN699NiQWisR2uWYOWNWqRHAmbnmKiL4e9aLSlmy5U7rEMUXV59+A==
|
||||
|
||||
node-htmldiff@^0.9.3:
|
||||
version "0.9.3"
|
||||
resolved "https://registry.yarnpkg.com/node-htmldiff/-/node-htmldiff-0.9.3.tgz#020704e381597e5e449a4708996edf23eebb7fcc"
|
||||
integrity sha512-9n4pd+x4qL6zsq5tntW5Bscn4j+oo0qt0RiLsf88knFjkZkPKj1VFGUBRRHVUUBCE7d64VMluj0LjxfJlJ3hpw==
|
||||
|
||||
node-int64@^0.4.0:
|
||||
version "0.4.0"
|
||||
resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b"
|
||||
|
||||
Reference in New Issue
Block a user