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:
Tom Moor
2022-09-08 11:17:52 +02:00
committed by GitHub
parent 97f70edd93
commit fa75d5585f
36 changed files with 2075 additions and 1610 deletions

View File

@@ -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
)}"`
);

View File

@@ -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();

View File

@@ -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,
};
});