Compare commits

..

21 Commits

Author SHA1 Message Date
Tom Moor d4c594423f More email styling 2021-07-08 22:38:42 -04:00
Tom Moor 2bf237d54b Merge branch 'main' into email-diff 2021-07-08 21:20:18 -04:00
Tom Moor 9a1c8f07d1 feat: Add documentId filter to events.list (#2287) 2021-07-08 10:12:06 -07:00
Tom Moor 241cb11493 chore: Automate running yarn-deduplicate, see comment:
https://github.com/outline/outline/pull/2283\#discussion_r665301770
2021-07-07 22:26:56 -04:00
Saumya Pandey 8195791bb2 fix: Make search query string user friendly (#2283)
* Upgrade query-string package and skip empty string

* Run yarn-deduplicate command
2021-07-07 18:45:40 -07:00
Saumya Pandey b037ae5dc1 fix: Improve isChildDocument performance (#2284) 2021-07-07 04:53:40 -07:00
Tom Moor aeba8ce4eb fix: Empty context menu when user does not have permission to update collection 2021-07-06 22:02:31 -04:00
Tom Moor 3565e68725 Merge branch 'main' into email-diff 2021-07-06 20:43:51 -04:00
Tom Moor 429c5fba85 0.57.0 2021-07-06 09:12:54 -04:00
Tom Moor 61039e9d0d Allow images in email diff 2021-06-25 09:41:34 -07:00
Tom Moor 6d09122d56 test: Deletion 2021-06-24 20:10:42 -07:00
Tom Moor 5fb6097153 Improved diff 2021-06-23 23:58:32 -07:00
Tom Moor ec17874568 Remove test harness 2021-06-22 07:35:38 -07:00
Tom Moor 40c3e9e85f test 2021-06-22 07:27:55 -07:00
Tom Moor 9f739f3788 Merge main 2021-06-22 07:26:45 -07:00
Tom Moor f6837b4742 wip 2021-06-20 23:15:04 -07:00
Tom Moor 1560e3c9f7 refactor 2021-06-20 12:49:15 -07:00
Tom Moor ca74908dc5 test 2021-06-20 00:20:37 -07:00
Tom Moor de7ec1119b Integrate into mailer, basic styling 2021-06-19 23:50:36 -07:00
Tom Moor 2093b4297f Merge main 2021-06-19 17:05:19 -07:00
Nan Yu 3df82c500b wip 2021-02-21 11:52:00 -08:00
30 changed files with 827 additions and 154 deletions
+6 -2
View File
@@ -83,7 +83,7 @@ const Submenu = React.forwardRef(({ templateItems, title, ...rest }, ref) => {
);
});
function Template({ items, ...menu }: Props): React.Node {
export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
let filtered = items.filter((item) => item.visible !== false);
// this block literally just trims unneccessary separators
@@ -101,7 +101,11 @@ function Template({ items, ...menu }: Props): React.Node {
return [...acc, item];
}, []);
return filtered.map((item, index) => {
return filtered;
}
function Template({ items, ...menu }: Props): React.Node {
return filterTemplateItems(items).map((item, index) => {
if (item.to) {
return (
<MenuItem
+48 -42
View File
@@ -12,7 +12,7 @@ import CollectionExport from "scenes/CollectionExport";
import CollectionPermissions from "scenes/CollectionPermissions";
import ContextMenu from "components/ContextMenu";
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Template from "components/ContextMenu/Template";
import Template, { filterTemplateItems } from "components/ContextMenu/Template";
import Modal from "components/Modal";
import useStores from "hooks/useStores";
import getDataTransferFiles from "utils/getDataTransferFiles";
@@ -110,6 +110,52 @@ function CollectionMenu({
);
const can = policies.abilities(collection.id);
const items = React.useMemo(
() =>
filterTemplateItems([
{
title: t("New document"),
visible: can.update,
onClick: handleNewDocument,
},
{
title: t("Import document"),
visible: can.update,
onClick: handleImportDocument,
},
{
type: "separator",
},
{
title: `${t("Edit")}`,
visible: can.update,
onClick: () => setShowCollectionEdit(true),
},
{
title: `${t("Permissions")}`,
visible: can.update,
onClick: () => setShowCollectionPermissions(true),
},
{
title: `${t("Export")}`,
visible: !!(collection && can.export),
onClick: () => setShowCollectionExport(true),
},
{
type: "separator",
},
{
title: `${t("Delete")}`,
visible: !!(collection && can.delete),
onClick: () => setShowCollectionDelete(true),
},
]),
[can, collection, handleNewDocument, handleImportDocument, t]
);
if (!items.length) {
return null;
}
return (
<>
@@ -134,47 +180,7 @@ function CollectionMenu({
onClose={onClose}
aria-label={t("Collection")}
>
<Template
{...menu}
items={[
{
title: t("New document"),
visible: can.update,
onClick: handleNewDocument,
},
{
title: t("Import document"),
visible: can.update,
onClick: handleImportDocument,
},
{
type: "separator",
},
{
title: `${t("Edit")}`,
visible: can.update,
onClick: () => setShowCollectionEdit(true),
},
{
title: `${t("Permissions")}`,
visible: can.update,
onClick: () => setShowCollectionPermissions(true),
},
{
title: `${t("Export")}`,
visible: !!(collection && can.export),
onClick: () => setShowCollectionExport(true),
},
{
type: "separator",
},
{
title: `${t("Delete")}`,
visible: !!(collection && can.delete),
onClick: () => setShowCollectionDelete(true),
},
]}
/>
<Template {...menu} items={items} />
</ContextMenu>
{renderModals && (
<>
+19 -17
View File
@@ -149,25 +149,27 @@ function CollectionScene() {
/>
</Action>
{can.update && (
<Action>
<Tooltip
tooltip={t("New document")}
shortcut="n"
delay={500}
placement="bottom"
>
<Button
as={Link}
to={collection ? newDocumentUrl(collection.id) : ""}
disabled={!collection}
icon={<PlusIcon />}
<>
<Action>
<Tooltip
tooltip={t("New document")}
shortcut="n"
delay={500}
placement="bottom"
>
{t("New doc")}
</Button>
</Tooltip>
</Action>
<Button
as={Link}
to={collection ? newDocumentUrl(collection.id) : ""}
disabled={!collection}
icon={<PlusIcon />}
>
{t("New doc")}
</Button>
</Tooltip>
</Action>
<Separator />
</>
)}
<Separator />
<Action>
<CollectionMenu
collection={collection}
+3 -4
View File
@@ -33,7 +33,7 @@ type Props = {|
@observer
class DocumentEditor extends React.Component<Props> {
@observable activeLinkEvent: ?MouseEvent;
ref = React.createRef<Textarea>();
ref = React.createRef<HTMLDivElement | HTMLInputElement>();
focusAtStart = () => {
if (this.props.innerRef.current) {
@@ -143,9 +143,8 @@ class DocumentEditor extends React.Component<Props> {
document={document}
to={documentHistoryUrl(document)}
rtl={
this.ref.current?.textarea
? window.getComputedStyle(this.ref.current.textarea)
.direction === "rtl"
this.ref.current
? window.getComputedStyle(this.ref.current).direction === "rtl"
: false
}
/>
+7 -4
View File
@@ -50,10 +50,13 @@ class Drafts extends React.Component<Props> {
}) => {
this.props.history.replace({
pathname: this.props.location.pathname,
search: queryString.stringify({
...queryString.parse(this.props.location.search),
...search,
}),
search: queryString.stringify(
{
...queryString.parse(this.props.location.search),
...search,
},
{ skipEmptyString: true }
),
});
};
+7 -4
View File
@@ -140,10 +140,13 @@ class Search extends React.Component<Props> {
}) => {
this.props.history.replace({
pathname: this.props.location.pathname,
search: queryString.stringify({
...queryString.parse(this.props.location.search),
...search,
}),
search: queryString.stringify(
{
...queryString.parse(this.props.location.search),
...search,
},
{ skipEmptyString: true }
),
});
};
+7 -4
View File
@@ -13,6 +13,7 @@
"dev": "nodemon --exec \"yarn build:server && yarn build:i18n && node --inspect=0.0.0.0 build/server/index.js\" -e js --ignore build/ --ignore app/",
"lint": "eslint app server shared",
"deploy": "git push heroku master",
"postinstall": "yarn yarn-deduplicate yarn.lock",
"heroku-postbuild": "yarn build && yarn db:migrate",
"sequelize:migrate": "sequelize db:migrate",
"db:create-migration": "sequelize migration:create",
@@ -109,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",
@@ -118,7 +120,7 @@
"pg": "^8.5.1",
"pg-hstore": "^2.3.3",
"polished": "3.6.5",
"query-string": "^4.3.4",
"query-string": "^7.0.1",
"quoted-printable": "^1.0.1",
"randomstring": "1.1.5",
"raw-loader": "^0.5.1",
@@ -200,12 +202,13 @@
"webpack-cli": "^3.3.12",
"webpack-manifest-plugin": "^3.0.0",
"webpack-pwa-manifest": "^4.3.0",
"workbox-webpack-plugin": "^6.1.0"
"workbox-webpack-plugin": "^6.1.0",
"yarn-deduplicate": "^3.1.0"
},
"resolutions": {
"prosemirror-view": "1.18.1",
"dot-prop": "^5.2.0",
"js-yaml": "^3.13.1"
},
"version": "0.56.0"
}
"version": "0.57.0"
}
+7 -4
View File
@@ -16,6 +16,7 @@ router.post("events.list", auth(), pagination(), async (ctx) => {
let {
sort = "createdAt",
actorId,
documentId,
collectionId,
direction,
name,
@@ -31,10 +32,12 @@ router.post("events.list", auth(), pagination(), async (ctx) => {
if (actorId) {
ctx.assertUuid(actorId, "actorId must be a UUID");
where = {
...where,
actorId,
};
where = { ...where, actorId };
}
if (documentId) {
ctx.assertUuid(documentId, "documentId must be a UUID");
where = { ...where, documentId };
}
if (collectionId) {
+49 -1
View File
@@ -1,7 +1,7 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import TestServer from "fetch-test-server";
import app from "../app";
import { buildEvent } from "../test/factories";
import { buildEvent, buildUser } from "../test/factories";
import { flushdb, seed } from "../test/support";
const server = new TestServer(app.callback());
@@ -101,6 +101,54 @@ describe("#events.list", () => {
expect(body.data[0].id).toEqual(auditEvent.id);
});
it("should allow filtering by documentId", async () => {
const { user, admin, document, collection } = await seed();
const event = await buildEvent({
name: "documents.publish",
collectionId: collection.id,
documentId: document.id,
teamId: user.teamId,
actorId: user.id,
});
const res = await server.post("/api/events.list", {
body: {
token: admin.getJwtToken(),
documentId: document.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
expect(body.data[0].id).toEqual(event.id);
});
it("should not return events for documentId without authorization", async () => {
const { user, document, collection } = await seed();
const actor = await buildUser();
await buildEvent({
name: "documents.publish",
collectionId: collection.id,
documentId: document.id,
teamId: user.teamId,
actorId: user.id,
});
const res = await server.post("/api/events.list", {
body: {
token: actor.getJwtToken(),
documentId: document.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(0);
});
it("should allow filtering by event name", async () => {
const { user, admin, document, collection } = await seed();
+4 -2
View File
@@ -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>
+225 -6
View File
@@ -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;
}
}
`;
+1 -1
View File
@@ -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>
+3 -1
View File
@@ -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>
+25
View File
@@ -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>
);
};
+2 -2
View File
@@ -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">
+2
View File
@@ -100,6 +100,8 @@ export type RevisionEvent = {
documentId: string,
collectionId: string,
teamId: string,
actorId: string,
modelId: string,
};
export type CollectionImportEvent = {
+3 -1
View File
@@ -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),
});
};
+5 -2
View File
@@ -395,7 +395,11 @@ Collection.prototype.isChildDocument = function (
let result = false;
const loopChildren = (documents, input) => {
return documents.map((document) => {
if (result) {
return;
}
documents.forEach((document) => {
let parents = [...input];
if (document.id === documentId) {
result = parents.includes(parentDocumentId);
@@ -403,7 +407,6 @@ Collection.prototype.isChildDocument = function (
parents.push(document.id);
loopChildren(document.children, parents);
}
return document;
});
};
+143 -22
View File
@@ -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: [
+12 -6
View File
@@ -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,
+10 -2
View File
@@ -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;
}
+34
View File
@@ -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
View File
@@ -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
View File
@@ -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>"
`;
+57
View File
@@ -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();
}
+55
View File
@@ -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("![caption](/image.png)", "");
expect(diff).toEqual(
'<p><del data-operation-index="0"><img src="/image.png" alt="caption"></del></p>'
);
});
+2 -2
View File
@@ -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
+1 -1
View File
@@ -152,12 +152,12 @@
"Path to document": "Path to document",
"Group member options": "Group member options",
"Remove": "Remove",
"Collection": "Collection",
"New document": "New document",
"Import document": "Import document",
"Edit": "Edit",
"Permissions": "Permissions",
"Delete": "Delete",
"Collection": "Collection",
"Collection permissions": "Collection permissions",
"Edit collection": "Edit collection",
"Delete collection": "Delete collection",
+25 -24
View File
@@ -2354,6 +2354,11 @@
resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d"
integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==
"@yarnpkg/lockfile@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz#e77a97fbd345b76d83245edcd17d393b1b41fb31"
integrity sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==
abab@^2.0.3:
version "2.0.5"
resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.5.tgz#c0b678fb32d60fc1219c784d6a826fe385aeb79a"
@@ -9432,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"
@@ -9605,7 +9615,7 @@ oauth@0.9.x:
resolved "https://registry.yarnpkg.com/oauth/-/oauth-0.9.15.tgz#bd1fefaf686c96b75475aed5196412ff60cfb9c1"
integrity sha1-vR/vr2hslrdUda7VGWQS/2DPucE=
object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1:
object-assign@^4.0.1, object-assign@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=
@@ -10747,18 +10757,10 @@ qs@~6.5.2:
resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"
integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==
query-string@^4.3.4:
version "4.3.4"
resolved "https://registry.yarnpkg.com/query-string/-/query-string-4.3.4.tgz#bbb693b9ca915c232515b228b1a02b609043dbeb"
integrity sha1-u7aTucqRXCMlFbIosaArYJBD2+s=
dependencies:
object-assign "^4.1.0"
strict-uri-encode "^1.0.0"
query-string@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/query-string/-/query-string-7.0.0.tgz#aaad2c8d5c6a6d0c6afada877fecbd56af79e609"
integrity sha512-Iy7moLybliR5ZgrK/1R3vjrXq03S13Vz4Rbm5Jg3EFq1LUmQppto0qtXz4vqZ386MSRjZgnTSZ9QC+NZOSd/XA==
query-string@^7.0.0, query-string@^7.0.1:
version "7.0.1"
resolved "https://registry.yarnpkg.com/query-string/-/query-string-7.0.1.tgz#45bd149cf586aaa582dffc7ec7a8ad97dd02f75d"
integrity sha512-uIw3iRvHnk9to1blJCG3BTc+Ro56CBowJXKmNNAm3RulvPBzWLRqKSiiDk+IplJhsydwtuNMHi8UGQFcCLVfkA==
dependencies:
decode-uri-component "^0.2.0"
filter-obj "^1.1.0"
@@ -12449,11 +12451,6 @@ streamsearch@0.1.2:
resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-0.1.2.tgz#808b9d0e56fc273d809ba57338e929919a1a9f1a"
integrity sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo=
strict-uri-encode@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713"
integrity sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM=
strict-uri-encode@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546"
@@ -14228,12 +14225,7 @@ write@1.0.3:
dependencies:
mkdirp "^0.5.1"
ws@^7.2.3:
version "7.4.0"
resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.0.tgz#a5dd76a24197940d4a8bb9e0e152bb4503764da7"
integrity sha512-kyFwXuV/5ymf+IXhS6f0+eAFvydbaBW3zjpT6hUdAh/hbVjTIB5EHBGi0bPoCLSK2wcuz3BrEkB9LrYv1Nm4NQ==
ws@~7.4.2:
ws@^7.2.3, ws@~7.4.2:
version "7.4.6"
resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.6.tgz#5654ca8ecdeee47c33a9a4bf6d28e2be2980377c"
integrity sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A==
@@ -14396,6 +14388,15 @@ yargs@^15.1.0, yargs@^15.4.1:
y18n "^4.0.0"
yargs-parser "^18.1.2"
yarn-deduplicate@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/yarn-deduplicate/-/yarn-deduplicate-3.1.0.tgz#3018d93e95f855f236a215b591fe8bc4bcabba3e"
integrity sha512-q2VZ6ThNzQpGfNpkPrkmV7x5HT9MOhCUsTxVTzyyZB0eSXz1NTodHn+r29DlLb+peKk8iXxzdUVhQG9pI7moFw==
dependencies:
"@yarnpkg/lockfile" "^1.1.0"
commander "^6.1.0"
semver "^7.3.2"
yeast@0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/yeast/-/yeast-0.1.2.tgz#008e06d8094320c372dbc2f8ed76a0ca6c8ac419"