feat: Show diff when navigating revision history (#4069)
* tidy * Add title to HTML export * fix: Add compatability for documents without collab state * Add HTML download option to UI * docs * fix nodes that required document to render * Refactor to allow for styling of HTML export * div>article for easier programatic content extraction * Allow DocumentHelper to be used with Revisions * Add revisions.diff endpoint, first version * Allow arbitrary revisions to be compared * test * HTML driven revision viewer * fix: Dark mode styles for document diffs * Add revision restore button to header * test * Support RTL languages in revision history viewer * fix: RTL support Remove unneccessary API requests * Prefetch revision data * Animate history sidebar * fix: Cannot toggle history from timestamp fix: Animation on each revision click * Clarify currently editing history item
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { FindOptions } from "sequelize";
|
||||
import { FindOptions, Op } from "sequelize";
|
||||
import {
|
||||
DataType,
|
||||
BelongsTo,
|
||||
@@ -97,6 +97,20 @@ class Revision extends IdModel {
|
||||
);
|
||||
}
|
||||
|
||||
// instance methods
|
||||
|
||||
previous(): Promise<Revision | null> {
|
||||
return (this.constructor as typeof Revision).findOne({
|
||||
where: {
|
||||
documentId: this.documentId,
|
||||
createdAt: {
|
||||
[Op.lt]: this.createdAt,
|
||||
},
|
||||
},
|
||||
order: [["createdAt", "DESC"]],
|
||||
});
|
||||
}
|
||||
|
||||
migrateVersion = function () {
|
||||
let migrated = false;
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
yDocToProsemirrorJSON,
|
||||
} from "@getoutline/y-prosemirror";
|
||||
import { JSDOM } from "jsdom";
|
||||
import diff from "node-htmldiff";
|
||||
import { Node, DOMSerializer } from "prosemirror-model";
|
||||
import * as React from "react";
|
||||
import { renderToString } from "react-dom/server";
|
||||
@@ -11,21 +12,30 @@ import * as Y from "yjs";
|
||||
import EditorContainer from "@shared/editor/components/Styles";
|
||||
import GlobalStyles from "@shared/styles/globals";
|
||||
import light from "@shared/styles/theme";
|
||||
import { isRTL } from "@shared/utils/rtl";
|
||||
import unescape from "@shared/utils/unescape";
|
||||
import { parser, schema } from "@server/editor";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import type Document from "@server/models/Document";
|
||||
import Document from "@server/models/Document";
|
||||
import type Revision from "@server/models/Revision";
|
||||
|
||||
type HTMLOptions = {
|
||||
/** Whether to include the document title in the generated HTML (defaults to true) */
|
||||
includeTitle?: boolean;
|
||||
/** Whether to include style tags in the generated HTML (defaults to true) */
|
||||
includeStyles?: boolean;
|
||||
};
|
||||
|
||||
export default class DocumentHelper {
|
||||
/**
|
||||
* Returns the document as a Prosemirror Node. This method uses the
|
||||
* collaborative state if available, otherwise it falls back to Markdown->HTML.
|
||||
*
|
||||
* @param document The document to convert
|
||||
* @param document The document or revision to convert
|
||||
* @returns The document content as a Prosemirror Node
|
||||
*/
|
||||
static toProsemirror(document: Document) {
|
||||
if (document.state) {
|
||||
static toProsemirror(document: Document | Revision) {
|
||||
if ("state" in document && document.state) {
|
||||
const ydoc = new Y.Doc();
|
||||
Y.applyUpdate(ydoc, document.state);
|
||||
return Node.fromJSON(schema, yDocToProsemirrorJSON(ydoc, "default"));
|
||||
@@ -37,10 +47,10 @@ export default class DocumentHelper {
|
||||
* Returns the document as Markdown. This is a lossy conversion and should
|
||||
* only be used for export.
|
||||
*
|
||||
* @param document The document to convert
|
||||
* @param document The document or revision to convert
|
||||
* @returns The document title and content as a Markdown string
|
||||
*/
|
||||
static toMarkdown(document: Document) {
|
||||
static toMarkdown(document: Document | Revision) {
|
||||
const text = unescape(document.text);
|
||||
|
||||
if (document.version) {
|
||||
@@ -54,10 +64,11 @@ export default class DocumentHelper {
|
||||
* Returns the document as plain HTML. This is a lossy conversion and should
|
||||
* only be used for export.
|
||||
*
|
||||
* @param document The document to convert
|
||||
* @param document The document or revision to convert
|
||||
* @param options Options for the HTML output
|
||||
* @returns The document title and content as a HTML string
|
||||
*/
|
||||
static toHTML(document: Document) {
|
||||
static toHTML(document: Document | Revision, options?: HTMLOptions) {
|
||||
const node = DocumentHelper.toProsemirror(document);
|
||||
const sheet = new ServerStyleSheet();
|
||||
let html, styleTags;
|
||||
@@ -68,6 +79,18 @@ export default class DocumentHelper {
|
||||
padding: 0 1em;
|
||||
`;
|
||||
|
||||
const rtl = isRTL(document.title);
|
||||
const children = (
|
||||
<>
|
||||
{options?.includeTitle !== false && (
|
||||
<h1 dir={rtl ? "rtl" : "ltr"}>{document.title}</h1>
|
||||
)}
|
||||
<EditorContainer dir={rtl ? "rtl" : "ltr"} rtl={rtl}>
|
||||
<div id="content" className="ProseMirror"></div>
|
||||
</EditorContainer>
|
||||
</>
|
||||
);
|
||||
|
||||
// First render the containing document which has all the editor styles,
|
||||
// global styles, layout and title.
|
||||
try {
|
||||
@@ -75,13 +98,14 @@ export default class DocumentHelper {
|
||||
sheet.collectStyles(
|
||||
<ThemeProvider theme={light}>
|
||||
<>
|
||||
<GlobalStyles />
|
||||
<Centered>
|
||||
<h1>{document.title}</h1>
|
||||
<EditorContainer rtl={false}>
|
||||
<div id="content" className="ProseMirror"></div>
|
||||
</EditorContainer>
|
||||
</Centered>
|
||||
{options?.includeStyles === false ? (
|
||||
<article>{children}</article>
|
||||
) : (
|
||||
<>
|
||||
<GlobalStyles />
|
||||
<Centered>{children}</Centered>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</ThemeProvider>
|
||||
)
|
||||
@@ -97,7 +121,11 @@ export default class DocumentHelper {
|
||||
|
||||
// Render the Prosemirror document using virtual DOM and serialize the
|
||||
// result to a string
|
||||
const dom = new JSDOM(`<!DOCTYPE html>${styleTags}${html}`);
|
||||
const dom = new JSDOM(
|
||||
`<!DOCTYPE html>${
|
||||
options?.includeStyles === false ? "" : styleTags
|
||||
}${html}`
|
||||
);
|
||||
const doc = dom.window.document;
|
||||
const target = doc.getElementById("content");
|
||||
|
||||
@@ -113,6 +141,43 @@ export default class DocumentHelper {
|
||||
return dom.serialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a HTML diff between after documents or revisions.
|
||||
*
|
||||
* @param before The before document
|
||||
* @param after The after document
|
||||
* @param options Options passed to HTML generation
|
||||
* @returns The diff as a HTML string
|
||||
*/
|
||||
static diff(
|
||||
before: Document | Revision | null,
|
||||
after: Revision,
|
||||
options?: HTMLOptions
|
||||
) {
|
||||
if (!before) {
|
||||
return DocumentHelper.toHTML(after, options);
|
||||
}
|
||||
|
||||
const beforeHTML = DocumentHelper.toHTML(before, options);
|
||||
const afterHTML = DocumentHelper.toHTML(after, options);
|
||||
const beforeDOM = new JSDOM(beforeHTML);
|
||||
const afterDOM = new JSDOM(afterHTML);
|
||||
|
||||
// Extract the content from the article tag and diff the HTML, we don't
|
||||
// care about the surrounding layout and stylesheets.
|
||||
const diffedContentAsHTML = diff(
|
||||
beforeDOM.window.document.getElementsByTagName("article")[0].innerHTML,
|
||||
afterDOM.window.document.getElementsByTagName("article")[0].innerHTML
|
||||
);
|
||||
|
||||
// Inject the diffed content into the original document with styling and
|
||||
// serialize back to a string.
|
||||
beforeDOM.window.document.getElementsByTagName(
|
||||
"article"
|
||||
)[0].innerHTML = diffedContentAsHTML;
|
||||
return beforeDOM.serialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the given Markdown to the document, this essentially creates a
|
||||
* single change in the collaborative state that makes all the edits to get
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Revision } from "@server/models";
|
||||
import presentUser from "./user";
|
||||
|
||||
export default async function present(revision: Revision) {
|
||||
export default async function present(revision: Revision, diff?: string) {
|
||||
await revision.migrateVersion();
|
||||
return {
|
||||
id: revision.id,
|
||||
documentId: revision.documentId,
|
||||
title: revision.title,
|
||||
text: revision.text,
|
||||
html: diff,
|
||||
createdAt: revision.createdAt,
|
||||
createdBy: presentUser(revision.user),
|
||||
};
|
||||
|
||||
@@ -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,9 @@ export default class DebounceProcessor extends BaseProcessor {
|
||||
globalEventQueue.add(
|
||||
{ ...event, name: "documents.update.delayed" },
|
||||
{
|
||||
delay: 5 * 60 * 1000,
|
||||
// speed up revision creation in development, we don't have all the
|
||||
// time in the world.
|
||||
delay: (env.ENVIRONMENT === "development" ? 1 : 5) * 60 * 1000,
|
||||
}
|
||||
);
|
||||
break;
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
presentDocument,
|
||||
presentPolicies,
|
||||
} from "@server/presenters";
|
||||
import slugify from "@server/utils/slugify";
|
||||
import {
|
||||
assertUuid,
|
||||
assertSort,
|
||||
@@ -471,7 +472,7 @@ router.post(
|
||||
ctx.set("Content-Type", contentType);
|
||||
ctx.set(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${document.title}.${mime.extension(
|
||||
`attachment; filename="${slugify(document.title)}.${mime.extension(
|
||||
contentType
|
||||
)}"`
|
||||
);
|
||||
|
||||
@@ -39,6 +39,91 @@ describe("#revisions.info", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("#revisions.diff", () => {
|
||||
it("should return the document HTML if no previous revision", async () => {
|
||||
const { user, document } = await seed();
|
||||
const revision = await Revision.createFromDocument(document);
|
||||
const res = await server.post("/api/revisions.diff", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: revision.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
|
||||
// Can't compare entire HTML output due to generated class names
|
||||
expect(body.data).toContain("<html");
|
||||
expect(body.data).toContain("<style");
|
||||
expect(body.data).toContain("<h1");
|
||||
expect(body.data).not.toContain("<ins");
|
||||
expect(body.data).not.toContain("<del");
|
||||
expect(body.data).toContain(document.title);
|
||||
});
|
||||
|
||||
it("should allow returning HTML directly with accept header", async () => {
|
||||
const { user, document } = await seed();
|
||||
const revision = await Revision.createFromDocument(document);
|
||||
const res = await server.post("/api/revisions.diff", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: revision.id,
|
||||
},
|
||||
headers: {
|
||||
accept: "text/html",
|
||||
},
|
||||
});
|
||||
const body = await res.text();
|
||||
expect(res.status).toEqual(200);
|
||||
|
||||
// Can't compare entire HTML output due to generated class names
|
||||
expect(body).toContain("<html");
|
||||
expect(body).toContain("<style");
|
||||
expect(body).toContain("<h1");
|
||||
expect(body).not.toContain("<ins");
|
||||
expect(body).not.toContain("<del");
|
||||
expect(body).toContain(document.title);
|
||||
});
|
||||
|
||||
it("should compare to previous revision by default", async () => {
|
||||
const { user, document } = await seed();
|
||||
await Revision.createFromDocument(document);
|
||||
|
||||
await document.update({ text: "New text" });
|
||||
const revision1 = await Revision.createFromDocument(document);
|
||||
|
||||
const res = await server.post("/api/revisions.diff", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: revision1.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
|
||||
// Can't compare entire HTML output due to generated class names
|
||||
expect(body.data).toContain("<html");
|
||||
expect(body.data).toContain("<style");
|
||||
expect(body.data).toContain("<h1");
|
||||
expect(body.data).toContain("<ins");
|
||||
expect(body.data).toContain("<del");
|
||||
expect(body.data).toContain(document.title);
|
||||
});
|
||||
|
||||
it("should require authorization", async () => {
|
||||
const document = await buildDocument();
|
||||
const revision = await Revision.createFromDocument(document);
|
||||
const user = await buildUser();
|
||||
const res = await server.post("/api/revisions.diff", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: revision.id,
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#revisions.list", () => {
|
||||
it("should return a document's revisions", async () => {
|
||||
const { user, document } = await seed();
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import Router from "koa-router";
|
||||
import { Op } from "sequelize";
|
||||
import { ValidationError } from "@server/errors";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { Document, Revision } from "@server/models";
|
||||
import DocumentHelper from "@server/models/helpers/DocumentHelper";
|
||||
import { authorize } from "@server/policies";
|
||||
import { presentRevision } from "@server/presenters";
|
||||
import slugify from "@server/utils/slugify";
|
||||
import { assertPresent, assertSort, assertUuid } from "@server/validation";
|
||||
import pagination from "./middlewares/pagination";
|
||||
|
||||
@@ -21,9 +25,68 @@ router.post("revisions.info", auth(), async (ctx) => {
|
||||
});
|
||||
authorize(user, "read", document);
|
||||
|
||||
const before = await revision.previous();
|
||||
|
||||
ctx.body = {
|
||||
pagination: ctx.state.pagination,
|
||||
data: await presentRevision(revision),
|
||||
data: await presentRevision(
|
||||
revision,
|
||||
DocumentHelper.diff(before, revision, {
|
||||
includeTitle: false,
|
||||
includeStyles: false,
|
||||
})
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
router.post("revisions.diff", auth(), async (ctx) => {
|
||||
const { id, compareToId } = ctx.body;
|
||||
assertUuid(id, "id is required");
|
||||
|
||||
const { user } = ctx.state;
|
||||
const revision = await Revision.findByPk(id, {
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
const document = await Document.findByPk(revision.documentId, {
|
||||
userId: user.id,
|
||||
});
|
||||
authorize(user, "read", document);
|
||||
|
||||
let before;
|
||||
if (compareToId) {
|
||||
assertUuid(compareToId, "compareToId must be a UUID");
|
||||
before = await Revision.findOne({
|
||||
where: {
|
||||
id: compareToId,
|
||||
documentId: revision.documentId,
|
||||
createdAt: {
|
||||
[Op.lt]: revision.createdAt,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!before) {
|
||||
throw ValidationError(
|
||||
"Revision could not be found, compareToId must be a revision of the same document before the provided revision"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
before = await revision.previous();
|
||||
}
|
||||
|
||||
const accept = ctx.request.headers["accept"];
|
||||
const content = DocumentHelper.diff(before, revision);
|
||||
|
||||
if (accept?.includes("text/html")) {
|
||||
ctx.set("Content-Type", "text/html");
|
||||
ctx.set(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${slugify(document.title)}.html"`
|
||||
);
|
||||
ctx.body = content;
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.body = {
|
||||
data: content,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user