mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
15 Commits
v1.3.0
...
email-diff
| Author | SHA1 | Date | |
|---|---|---|---|
| d4c594423f | |||
| 2bf237d54b | |||
| 3565e68725 | |||
| 61039e9d0d | |||
| 6d09122d56 | |||
| 5fb6097153 | |||
| ec17874568 | |||
| 40c3e9e85f | |||
| 9f739f3788 | |||
| f6837b4742 | |||
| 1560e3c9f7 | |||
| ca74908dc5 | |||
| de7ec1119b | |||
| 2093b4297f | |||
| 3df82c500b |
@@ -110,6 +110,7 @@
|
||||
"mobx": "^4.15.4",
|
||||
"mobx-react": "^6.3.1",
|
||||
"natural-sort": "^1.0.0",
|
||||
"node-htmldiff": "^0.9.3",
|
||||
"nodemailer": "^6.4.16",
|
||||
"outline-icons": "^1.27.0",
|
||||
"oy-vey": "^0.10.0",
|
||||
|
||||
@@ -41,11 +41,13 @@ export const CollectionNotificationEmail = ({
|
||||
<Body>
|
||||
<Heading>{collection.name}</Heading>
|
||||
<p>
|
||||
{actor.name} {eventName} the collection "{collection.name}".
|
||||
{actor.name} {eventName} the collection “{collection.name}”.
|
||||
</p>
|
||||
<EmptySpace height={10} />
|
||||
<p>
|
||||
<Button href={`${process.env.URL}${collection.url}`}>
|
||||
<Button
|
||||
href={`${process.env.URL}${collection.url}?ref=notification-email`}
|
||||
>
|
||||
Open Collection
|
||||
</Button>
|
||||
</p>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import theme from "../../shared/styles/theme";
|
||||
import { User, Document, Team, Collection } from "../models";
|
||||
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";
|
||||
@@ -15,6 +17,7 @@ export type Props = {
|
||||
document: Document,
|
||||
collection: Collection,
|
||||
eventName: string,
|
||||
summary: string,
|
||||
unsubscribeUrl: string,
|
||||
};
|
||||
|
||||
@@ -38,26 +41,34 @@ export const DocumentNotificationEmail = ({
|
||||
document,
|
||||
collection,
|
||||
eventName = "published",
|
||||
summary,
|
||||
unsubscribeUrl,
|
||||
}: Props) => {
|
||||
const link = `${team.url}${document.url}?ref=notification-email`;
|
||||
|
||||
return (
|
||||
<EmailTemplate>
|
||||
<Header />
|
||||
|
||||
<Body>
|
||||
<Heading>
|
||||
"{document.title}" {eventName}
|
||||
“{document.title}” {eventName}
|
||||
</Heading>
|
||||
<p>
|
||||
{actor.name} {eventName} the document "{document.title}", in the{" "}
|
||||
{collection.name} collection.
|
||||
</p>
|
||||
<hr />
|
||||
<EmptySpace height={10} />
|
||||
<p>{document.getSummary()}</p>
|
||||
<EmptySpace height={10} />
|
||||
{summary && (
|
||||
<>
|
||||
<EmptySpace height={20} />
|
||||
<Diff href={link}>
|
||||
<div dangerouslySetInnerHTML={{ __html: summary }} />
|
||||
</Diff>
|
||||
<EmptySpace height={20} />
|
||||
</>
|
||||
)}
|
||||
<p>
|
||||
<Button href={`${team.url}${document.url}`}>Open Document</Button>
|
||||
<Button href={link}>Open Document</Button>
|
||||
</p>
|
||||
</Body>
|
||||
|
||||
@@ -65,3 +76,211 @@ export const DocumentNotificationEmail = ({
|
||||
</EmailTemplate>
|
||||
);
|
||||
};
|
||||
|
||||
export const css = `
|
||||
font-family: ${theme.fontFamily};
|
||||
font-weight: ${theme.fontWeight};
|
||||
font-size: 1em;
|
||||
line-height: 1.7em;
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
img {
|
||||
text-align: center;
|
||||
max-width: 100%;
|
||||
max-height: 75vh;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
img.image-right-50 {
|
||||
float: right;
|
||||
width: 50%;
|
||||
margin-left: 2em;
|
||||
margin-bottom: 1em;
|
||||
clear: initial;
|
||||
}
|
||||
|
||||
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: ${theme.noticeInfoBackground};
|
||||
color: ${theme.noticeInfoText};
|
||||
border-radius: 4px;
|
||||
padding: 8px 16px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.notice-tip {
|
||||
background: ${theme.noticeTipBackground};
|
||||
color: ${theme.noticeTipText};
|
||||
}
|
||||
|
||||
.notice-warning {
|
||||
background: ${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;
|
||||
|
||||
code {
|
||||
font-size: 13px;
|
||||
background: none;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border-radius: 4px;
|
||||
margin-top: 1em;
|
||||
box-sizing: border-box;
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
tr {
|
||||
position: relative;
|
||||
border-bottom: 1px solid ${theme.tableDivider};
|
||||
}
|
||||
td,
|
||||
th {
|
||||
position: relative;
|
||||
vertical-align: top;
|
||||
border: 1px solid ${theme.tableDivider};
|
||||
position: relative;
|
||||
padding: 4px 8px;
|
||||
min-width: 100px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -47,7 +47,7 @@ export const InviteEmail = ({
|
||||
</p>
|
||||
<EmptySpace height={10} />
|
||||
<p>
|
||||
<Button href={teamUrl}>Join now</Button>
|
||||
<Button href={`${teamUrl}?ref=invite-email`}>Join now</Button>
|
||||
</p>
|
||||
</Body>
|
||||
|
||||
|
||||
@@ -43,7 +43,9 @@ export const WelcomeEmail = ({ teamUrl }: Props) => {
|
||||
</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 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import theme from "../../../shared/styles/theme";
|
||||
|
||||
type Props = {|
|
||||
children: React.Node,
|
||||
href?: string,
|
||||
|};
|
||||
|
||||
export default ({ children, ...rest }: Props) => {
|
||||
const style = {
|
||||
borderRadius: "4px",
|
||||
background: theme.secondaryBackground,
|
||||
padding: ".5em 1em",
|
||||
color: theme.text,
|
||||
display: "block",
|
||||
textDecoration: "none",
|
||||
};
|
||||
|
||||
return (
|
||||
<a width="100%" style={style} {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
@@ -3,9 +3,9 @@ import { Table, TBody, TR, TD } from "oy-vey";
|
||||
import * as React from "react";
|
||||
import theme from "../../../shared/styles/theme";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
children: React.Node,
|
||||
};
|
||||
|};
|
||||
|
||||
export default (props: Props) => (
|
||||
<Table width="550" padding="40">
|
||||
|
||||
@@ -100,6 +100,8 @@ export type RevisionEvent = {
|
||||
documentId: string,
|
||||
collectionId: string,
|
||||
teamId: string,
|
||||
actorId: string,
|
||||
modelId: string,
|
||||
};
|
||||
|
||||
export type CollectionImportEvent = {
|
||||
|
||||
+3
-1
@@ -13,6 +13,7 @@ import {
|
||||
type Props as DocumentNotificationEmailT,
|
||||
DocumentNotificationEmail,
|
||||
documentNotificationEmailText,
|
||||
css as documentNotificationEmailCSS,
|
||||
} from "./emails/DocumentNotificationEmail";
|
||||
import { ExportEmail, exportEmailText } from "./emails/ExportEmail";
|
||||
import {
|
||||
@@ -146,8 +147,9 @@ export class Mailer {
|
||||
this.sendMail({
|
||||
to: opts.to,
|
||||
title: `“${opts.document.title}” ${opts.eventName}`,
|
||||
previewText: `${opts.actor.name} ${opts.eventName} a new document`,
|
||||
previewText: `${opts.actor.name} ${opts.eventName} a document`,
|
||||
html: <DocumentNotificationEmail {...opts} />,
|
||||
headCSS: documentNotificationEmailCSS,
|
||||
text: documentNotificationEmailText(opts),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,32 +1,60 @@
|
||||
// @flow
|
||||
import debug from "debug";
|
||||
import type { DocumentEvent, CollectionEvent, Event } from "../events";
|
||||
import type {
|
||||
DocumentEvent,
|
||||
RevisionEvent,
|
||||
CollectionEvent,
|
||||
Event,
|
||||
} from "../events";
|
||||
import mailer from "../mailer";
|
||||
import {
|
||||
View,
|
||||
Document,
|
||||
Team,
|
||||
Collection,
|
||||
Revision,
|
||||
User,
|
||||
NotificationSetting,
|
||||
Attachment,
|
||||
} from "../models";
|
||||
import { Op } from "../sequelize";
|
||||
import markdownDiff from "../utils/markdownDiff";
|
||||
|
||||
import parseAttachmentIds from "../utils/parseAttachmentIds";
|
||||
import { getSignedImageUrl } from "../utils/s3";
|
||||
|
||||
const log = debug("services");
|
||||
|
||||
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 getSignedImageUrl(attachment.key, 86400 * 4);
|
||||
text = text.replace(attachment.redirectUrl, accessUrl);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
export default class Notifications {
|
||||
async on(event: Event) {
|
||||
switch (event.name) {
|
||||
case "documents.publish":
|
||||
case "documents.update.debounced":
|
||||
return this.documentUpdated(event);
|
||||
return this.documentPublished(event);
|
||||
case "revisions.create":
|
||||
return this.revisionCreated(event);
|
||||
case "collections.create":
|
||||
return this.collectionCreated(event);
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
async documentUpdated(event: DocumentEvent) {
|
||||
async documentPublished(event: DocumentEvent) {
|
||||
// never send notifications when batch importing documents
|
||||
if (event.data && event.data.source === "import") return;
|
||||
|
||||
@@ -45,10 +73,7 @@ export default class Notifications {
|
||||
[Op.ne]: document.lastModifiedById,
|
||||
},
|
||||
teamId: document.teamId,
|
||||
event:
|
||||
event.name === "documents.publish"
|
||||
? "documents.publish"
|
||||
: "documents.update",
|
||||
event: "documents.publish",
|
||||
},
|
||||
include: [
|
||||
{
|
||||
@@ -59,25 +84,14 @@ export default class Notifications {
|
||||
],
|
||||
});
|
||||
|
||||
const eventName =
|
||||
event.name === "documents.publish" ? "published" : "updated";
|
||||
const eventName = "published";
|
||||
|
||||
for (const setting of notificationSettings) {
|
||||
// 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)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
return;
|
||||
continue;
|
||||
}
|
||||
|
||||
// If this user has viewed the document since the last update was made
|
||||
@@ -96,7 +110,7 @@ export default class Notifications {
|
||||
log(
|
||||
`suppressing notification to ${setting.userId} because update viewed`
|
||||
);
|
||||
return;
|
||||
continue;
|
||||
}
|
||||
|
||||
mailer.documentNotification({
|
||||
@@ -105,12 +119,119 @@ export default class Notifications {
|
||||
document,
|
||||
team,
|
||||
collection,
|
||||
summary: document.getSummary(),
|
||||
actor: document.updatedBy,
|
||||
unsubscribeUrl: setting.unsubscribeUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async revisionCreated(event: RevisionEvent) {
|
||||
const revision = await Revision.findByPk(event.modelId, {
|
||||
include: [
|
||||
{
|
||||
model: Document,
|
||||
as: "document",
|
||||
include: [
|
||||
{
|
||||
model: Collection,
|
||||
as: "collection",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (!revision) return;
|
||||
|
||||
const { document } = revision;
|
||||
const { collection } = document;
|
||||
if (!collection || !document) return;
|
||||
|
||||
const team = await Team.findByPk(document.teamId);
|
||||
if (!team) 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",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const eventName = "updated";
|
||||
|
||||
for (const setting of notificationSettings) {
|
||||
// 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]: document.updatedAt,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (view) {
|
||||
log(
|
||||
`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 summary = markdownDiff(previous ? previous.text : "", revision.text);
|
||||
|
||||
console.log(summary);
|
||||
summary = await replaceImageAttachments(summary);
|
||||
console.log(summary);
|
||||
|
||||
mailer.documentNotification({
|
||||
to: setting.user.email,
|
||||
eventName,
|
||||
document,
|
||||
team,
|
||||
collection,
|
||||
summary,
|
||||
actor: revision.user,
|
||||
unsubscribeUrl: setting.unsubscribeUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async collectionCreated(event: CollectionEvent) {
|
||||
const collection = await Collection.findByPk(event.collectionId, {
|
||||
include: [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import mailer from "../mailer";
|
||||
import { View, NotificationSetting } from "../models";
|
||||
import { View, NotificationSetting, Revision } from "../models";
|
||||
import { buildDocument, buildCollection, buildUser } from "../test/factories";
|
||||
import { flushdb } from "../test/support";
|
||||
import NotificationsService from "./notifications";
|
||||
@@ -89,9 +89,10 @@ describe("documents.publish", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("documents.update.debounced", () => {
|
||||
describe("revisions.create", () => {
|
||||
test("should send a notification to other collaborator", async () => {
|
||||
const document = await buildDocument();
|
||||
const revision = await Revision.createFromDocument(document);
|
||||
const collaborator = await buildUser({ teamId: document.teamId });
|
||||
document.collaboratorIds = [collaborator.id];
|
||||
await document.save();
|
||||
@@ -103,8 +104,9 @@ describe("documents.update.debounced", () => {
|
||||
});
|
||||
|
||||
await Notifications.on({
|
||||
name: "documents.update.debounced",
|
||||
name: "revisions.create",
|
||||
documentId: document.id,
|
||||
modelId: revision.id,
|
||||
collectionId: document.collectionId,
|
||||
teamId: document.teamId,
|
||||
actorId: document.createdById,
|
||||
@@ -115,6 +117,7 @@ describe("documents.update.debounced", () => {
|
||||
|
||||
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 });
|
||||
document.collaboratorIds = [collaborator.id];
|
||||
await document.save();
|
||||
@@ -128,9 +131,10 @@ describe("documents.update.debounced", () => {
|
||||
await View.touch(document.id, collaborator.id, true);
|
||||
|
||||
await Notifications.on({
|
||||
name: "documents.update.debounced",
|
||||
name: "revisions.create",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
modelId: revision.id,
|
||||
teamId: document.teamId,
|
||||
actorId: document.createdById,
|
||||
});
|
||||
@@ -138,12 +142,13 @@ describe("documents.update.debounced", () => {
|
||||
expect(mailer.documentNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should not send a notification to last editor", async () => {
|
||||
test("should not send a notification to the 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,
|
||||
@@ -152,8 +157,9 @@ describe("documents.update.debounced", () => {
|
||||
});
|
||||
|
||||
await Notifications.on({
|
||||
name: "documents.update.debounced",
|
||||
name: "revisions.create",
|
||||
documentId: document.id,
|
||||
modelId: revision.id,
|
||||
collectionId: document.collectionId,
|
||||
teamId: document.teamId,
|
||||
actorId: document.createdById,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @flow
|
||||
import type { DocumentEvent, RevisionEvent } from "../events";
|
||||
import { Revision, Document } from "../models";
|
||||
import { Revision, Document, Event } from "../models";
|
||||
|
||||
export default class Revisions {
|
||||
async on(event: DocumentEvent | RevisionEvent) {
|
||||
@@ -22,7 +22,15 @@ export default class Revisions {
|
||||
return;
|
||||
}
|
||||
|
||||
await Revision.createFromDocument(document);
|
||||
const revision = await Revision.createFromDocument(document);
|
||||
Event.add({
|
||||
name: "revisions.create",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
modelId: revision.id,
|
||||
teamId: document.teamId,
|
||||
actorId: revision.userId,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
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
|
||||
@@ -2,9 +2,11 @@
|
||||
require("dotenv").config({ silent: true });
|
||||
|
||||
// test environment variables
|
||||
process.env.URL = "http://localhost:3000";
|
||||
process.env.DATABASE_URL = process.env.DATABASE_URL_TEST;
|
||||
process.env.NODE_ENV = "test";
|
||||
process.env.GOOGLE_CLIENT_ID = "123";
|
||||
process.env.AZURE_CLIENT_ID = "";
|
||||
process.env.SLACK_KEY = "123";
|
||||
process.env.DEPLOYMENT = "";
|
||||
process.env.ALLOWED_DOMAINS = "allowed-domain.com";
|
||||
|
||||
@@ -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,57 @@
|
||||
// @flow
|
||||
import { findIndex, findLastIndex } from "lodash";
|
||||
import diff from "node-htmldiff";
|
||||
import { renderToHtml } from "rich-markdown-editor";
|
||||
|
||||
export default function markdownDiff(
|
||||
before: string,
|
||||
after: string,
|
||||
fullDiff: boolean = false,
|
||||
buffer: number = 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();
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// @flow
|
||||
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>'
|
||||
);
|
||||
});
|
||||
+2
-2
@@ -163,13 +163,13 @@ export const deleteFromS3 = (key: string) => {
|
||||
.promise();
|
||||
};
|
||||
|
||||
export const getSignedImageUrl = async (key: string) => {
|
||||
export const getSignedImageUrl = async (key: string, expires: number = 60) => {
|
||||
const isDocker = process.env.AWS_S3_UPLOAD_BUCKET_URL.match(/http:\/\/s3:/);
|
||||
|
||||
const params = {
|
||||
Bucket: AWS_S3_UPLOAD_BUCKET_NAME,
|
||||
Key: key,
|
||||
Expires: 60,
|
||||
Expires: expires,
|
||||
};
|
||||
|
||||
return isDocker
|
||||
|
||||
@@ -9437,6 +9437,11 @@ node-gyp-build@^3.8.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 sha1-AgcE44FZfl5EmkcImW7fI+67f8w=
|
||||
|
||||
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