Allow viewing diff before revision is written (#5399)
This commit is contained in:
@@ -6,6 +6,7 @@ import { Link } from "react-router-dom";
|
|||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import { s, ellipsis } from "@shared/styles";
|
import { s, ellipsis } from "@shared/styles";
|
||||||
import Document from "~/models/Document";
|
import Document from "~/models/Document";
|
||||||
|
import Revision from "~/models/Revision";
|
||||||
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
|
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
|
||||||
import DocumentTasks from "~/components/DocumentTasks";
|
import DocumentTasks from "~/components/DocumentTasks";
|
||||||
import Flex from "~/components/Flex";
|
import Flex from "~/components/Flex";
|
||||||
@@ -19,6 +20,7 @@ type Props = {
|
|||||||
showLastViewed?: boolean;
|
showLastViewed?: boolean;
|
||||||
showParentDocuments?: boolean;
|
showParentDocuments?: boolean;
|
||||||
document: Document;
|
document: Document;
|
||||||
|
revision?: Revision;
|
||||||
replace?: boolean;
|
replace?: boolean;
|
||||||
to?: LocationDescriptor;
|
to?: LocationDescriptor;
|
||||||
};
|
};
|
||||||
@@ -29,6 +31,7 @@ const DocumentMeta: React.FC<Props> = ({
|
|||||||
showLastViewed,
|
showLastViewed,
|
||||||
showParentDocuments,
|
showParentDocuments,
|
||||||
document,
|
document,
|
||||||
|
revision,
|
||||||
children,
|
children,
|
||||||
replace,
|
replace,
|
||||||
to,
|
to,
|
||||||
@@ -64,7 +67,16 @@ const DocumentMeta: React.FC<Props> = ({
|
|||||||
const userName = updatedBy.name;
|
const userName = updatedBy.name;
|
||||||
let content;
|
let content;
|
||||||
|
|
||||||
if (deletedAt) {
|
if (revision) {
|
||||||
|
content = (
|
||||||
|
<span>
|
||||||
|
{revision.createdBy?.id === user.id
|
||||||
|
? t("You updated")
|
||||||
|
: t("{{ userName }} updated", { userName })}{" "}
|
||||||
|
<Time dateTime={revision.createdAt} addSuffix />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
} else if (deletedAt) {
|
||||||
content = (
|
content = (
|
||||||
<span>
|
<span>
|
||||||
{lastUpdatedByCurrentUser
|
{lastUpdatedByCurrentUser
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
PublishIcon,
|
PublishIcon,
|
||||||
MoveIcon,
|
MoveIcon,
|
||||||
UnpublishIcon,
|
UnpublishIcon,
|
||||||
LightningIcon,
|
|
||||||
} from "outline-icons";
|
} from "outline-icons";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -61,18 +60,15 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
|||||||
switch (event.name) {
|
switch (event.name) {
|
||||||
case "revisions.create":
|
case "revisions.create":
|
||||||
icon = <EditIcon size={16} />;
|
icon = <EditIcon size={16} />;
|
||||||
meta = t("{{userName}} edited", opts);
|
meta = latest ? (
|
||||||
|
<>
|
||||||
|
{t("Current version")} · {event.actor.name}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t("{{userName}} edited", opts)
|
||||||
|
);
|
||||||
to = {
|
to = {
|
||||||
pathname: documentHistoryPath(document, event.modelId || ""),
|
pathname: documentHistoryPath(document, event.modelId || "latest"),
|
||||||
state: { retainScrollPosition: true },
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "documents.live_editing":
|
|
||||||
icon = <LightningIcon size={16} />;
|
|
||||||
meta = t("Latest");
|
|
||||||
to = {
|
|
||||||
pathname: documentHistoryPath(document),
|
|
||||||
state: { retainScrollPosition: true },
|
state: { retainScrollPosition: true },
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
@@ -153,7 +149,7 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
|||||||
</Subtitle>
|
</Subtitle>
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
isRevision && isActive && event.modelId ? (
|
isRevision && isActive && event.modelId && !latest ? (
|
||||||
<RevisionMenu document={document} revisionId={event.modelId} />
|
<RevisionMenu document={document} revisionId={event.modelId} />
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useLocation, RouteComponentProps, StaticContext } from "react-router";
|
import { useLocation, RouteComponentProps, StaticContext } from "react-router";
|
||||||
import { NavigationNode, TeamPreference } from "@shared/types";
|
import { NavigationNode, TeamPreference } from "@shared/types";
|
||||||
|
import { RevisionHelper } from "@shared/utils/RevisionHelper";
|
||||||
import Document from "~/models/Document";
|
import Document from "~/models/Document";
|
||||||
import Revision from "~/models/Revision";
|
import Revision from "~/models/Revision";
|
||||||
import Error404 from "~/scenes/Error404";
|
import Error404 from "~/scenes/Error404";
|
||||||
@@ -59,7 +60,14 @@ function DataLoader({ match, children }: Props) {
|
|||||||
documents.getByUrl(match.params.documentSlug) ??
|
documents.getByUrl(match.params.documentSlug) ??
|
||||||
documents.get(match.params.documentSlug);
|
documents.get(match.params.documentSlug);
|
||||||
|
|
||||||
const revision = revisionId ? revisions.get(revisionId) : undefined;
|
const revision = revisionId
|
||||||
|
? revisions.get(
|
||||||
|
revisionId === "latest"
|
||||||
|
? RevisionHelper.latestId(document?.id)
|
||||||
|
: revisionId
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const sharedTree = document
|
const sharedTree = document
|
||||||
? documents.getSharedTree(document.id)
|
? documents.getSharedTree(document.id)
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -94,6 +102,19 @@ function DataLoader({ match, children }: Props) {
|
|||||||
fetchRevision();
|
fetchRevision();
|
||||||
}, [revisions, revisionId]);
|
}, [revisions, revisionId]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
async function fetchRevision() {
|
||||||
|
if (document && revisionId === "latest") {
|
||||||
|
try {
|
||||||
|
await revisions.fetchLatest(document.id);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchRevision();
|
||||||
|
}, [document, revisionId, revisions]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
async function fetchSubscription() {
|
async function fetchSubscription() {
|
||||||
if (document?.id && !revisionId) {
|
if (document?.id && !revisionId) {
|
||||||
|
|||||||
@@ -451,8 +451,8 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
<Header
|
<Header
|
||||||
document={document}
|
document={document}
|
||||||
documentHasHeadings={hasHeadings}
|
documentHasHeadings={hasHeadings}
|
||||||
|
revision={revision}
|
||||||
shareId={shareId}
|
shareId={shareId}
|
||||||
isRevision={!!revision}
|
|
||||||
isDraft={document.isDraft}
|
isDraft={document.isDraft}
|
||||||
isEditing={!readOnly && !team?.seamlessEditing}
|
isEditing={!readOnly && !team?.seamlessEditing}
|
||||||
isSaving={this.isSaving}
|
isSaving={this.isSaving}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Link, useRouteMatch } from "react-router-dom";
|
|||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import { TeamPreference } from "@shared/types";
|
import { TeamPreference } from "@shared/types";
|
||||||
import Document from "~/models/Document";
|
import Document from "~/models/Document";
|
||||||
|
import Revision from "~/models/Revision";
|
||||||
import DocumentMeta from "~/components/DocumentMeta";
|
import DocumentMeta from "~/components/DocumentMeta";
|
||||||
import Fade from "~/components/Fade";
|
import Fade from "~/components/Fade";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
@@ -15,12 +16,19 @@ import { documentPath, documentInsightsPath } from "~/utils/routeHelpers";
|
|||||||
type Props = {
|
type Props = {
|
||||||
/* The document to display meta data for */
|
/* The document to display meta data for */
|
||||||
document: Document;
|
document: Document;
|
||||||
|
revision?: Revision;
|
||||||
isDraft: boolean;
|
isDraft: boolean;
|
||||||
to?: LocationDescriptor;
|
to?: LocationDescriptor;
|
||||||
rtl?: boolean;
|
rtl?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function TitleDocumentMeta({ to, isDraft, document, ...rest }: Props) {
|
function TitleDocumentMeta({
|
||||||
|
to,
|
||||||
|
isDraft,
|
||||||
|
document,
|
||||||
|
revision,
|
||||||
|
...rest
|
||||||
|
}: Props) {
|
||||||
const { auth, views, comments, ui } = useStores();
|
const { auth, views, comments, ui } = useStores();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { team } = auth;
|
const { team } = auth;
|
||||||
@@ -36,7 +44,7 @@ function TitleDocumentMeta({ to, isDraft, document, ...rest }: Props) {
|
|||||||
const commentsCount = comments.inDocument(document.id).length;
|
const commentsCount = comments.inDocument(document.id).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Meta document={document} to={to} replace {...rest}>
|
<Meta document={document} revision={revision} to={to} replace {...rest}>
|
||||||
{team?.getPreference(TeamPreference.Commenting) && (
|
{team?.getPreference(TeamPreference.Commenting) && (
|
||||||
<>
|
<>
|
||||||
•
|
•
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import styled from "styled-components";
|
|||||||
import { NavigationNode } from "@shared/types";
|
import { NavigationNode } from "@shared/types";
|
||||||
import { Theme } from "~/stores/UiStore";
|
import { Theme } from "~/stores/UiStore";
|
||||||
import Document from "~/models/Document";
|
import Document from "~/models/Document";
|
||||||
|
import Revision from "~/models/Revision";
|
||||||
import { Action, Separator } from "~/components/Actions";
|
import { Action, Separator } from "~/components/Actions";
|
||||||
import Badge from "~/components/Badge";
|
import Badge from "~/components/Badge";
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
@@ -40,11 +41,11 @@ import ShareButton from "./ShareButton";
|
|||||||
type Props = {
|
type Props = {
|
||||||
document: Document;
|
document: Document;
|
||||||
documentHasHeadings: boolean;
|
documentHasHeadings: boolean;
|
||||||
|
revision: Revision | undefined;
|
||||||
sharedTree: NavigationNode | undefined;
|
sharedTree: NavigationNode | undefined;
|
||||||
shareId: string | null | undefined;
|
shareId: string | null | undefined;
|
||||||
isDraft: boolean;
|
isDraft: boolean;
|
||||||
isEditing: boolean;
|
isEditing: boolean;
|
||||||
isRevision: boolean;
|
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
isPublishing: boolean;
|
isPublishing: boolean;
|
||||||
publishingIsDisabled: boolean;
|
publishingIsDisabled: boolean;
|
||||||
@@ -65,11 +66,11 @@ type Props = {
|
|||||||
function DocumentHeader({
|
function DocumentHeader({
|
||||||
document,
|
document,
|
||||||
documentHasHeadings,
|
documentHasHeadings,
|
||||||
|
revision,
|
||||||
shareId,
|
shareId,
|
||||||
isEditing,
|
isEditing,
|
||||||
isDraft,
|
isDraft,
|
||||||
isPublishing,
|
isPublishing,
|
||||||
isRevision,
|
|
||||||
isSaving,
|
isSaving,
|
||||||
savingIsDisabled,
|
savingIsDisabled,
|
||||||
publishingIsDisabled,
|
publishingIsDisabled,
|
||||||
@@ -83,6 +84,7 @@ function DocumentHeader({
|
|||||||
const { resolvedTheme } = ui;
|
const { resolvedTheme } = ui;
|
||||||
const { team } = auth;
|
const { team } = auth;
|
||||||
const isMobile = useMobile();
|
const isMobile = useMobile();
|
||||||
|
const isRevision = !!revision;
|
||||||
|
|
||||||
// We cache this value for as long as the component is mounted so that if you
|
// We cache this value for as long as the component is mounted so that if you
|
||||||
// apply a template there is still the option to replace it until the user
|
// apply a template there is still the option to replace it until the user
|
||||||
@@ -287,7 +289,7 @@ function DocumentHeader({
|
|||||||
</Button>
|
</Button>
|
||||||
</Action>
|
</Action>
|
||||||
)}
|
)}
|
||||||
{isRevision && (
|
{revision && revision.createdAt !== document.updatedAt && (
|
||||||
<Action>
|
<Action>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
tooltip={t("Restore version")}
|
tooltip={t("Restore version")}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import * as React from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useHistory, useRouteMatch } from "react-router-dom";
|
import { useHistory, useRouteMatch } from "react-router-dom";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
|
import { RevisionHelper } from "@shared/utils/RevisionHelper";
|
||||||
import Event from "~/models/Event";
|
import Event from "~/models/Event";
|
||||||
import Empty from "~/components/Empty";
|
import Empty from "~/components/Empty";
|
||||||
import PaginatedEventList from "~/components/PaginatedEventList";
|
import PaginatedEventList from "~/components/PaginatedEventList";
|
||||||
@@ -41,8 +42,8 @@ function History() {
|
|||||||
eventsInDocument.unshift(
|
eventsInDocument.unshift(
|
||||||
new Event(
|
new Event(
|
||||||
{
|
{
|
||||||
id: "live",
|
id: RevisionHelper.latestId(document.id),
|
||||||
name: "documents.live_editing",
|
name: "revisions.create",
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
createdAt: document.updatedAt,
|
createdAt: document.updatedAt,
|
||||||
actor: document.updatedBy,
|
actor: document.updatedBy,
|
||||||
|
|||||||
@@ -20,18 +20,17 @@ type Props = Omit<EditorProps, "extensions"> & {
|
|||||||
* Displays revision HTML pre-rendered on the server.
|
* Displays revision HTML pre-rendered on the server.
|
||||||
*/
|
*/
|
||||||
function RevisionViewer(props: Props) {
|
function RevisionViewer(props: Props) {
|
||||||
const { document, shareId, children, revision } = props;
|
const { document, children, revision } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex auto column>
|
<Flex auto column>
|
||||||
<h1 dir={revision.dir}>{revision.title}</h1>
|
<h1 dir={revision.dir}>{revision.title}</h1>
|
||||||
{!shareId && (
|
|
||||||
<DocumentMeta
|
<DocumentMeta
|
||||||
document={document}
|
document={document}
|
||||||
|
revision={revision}
|
||||||
to={documentPath(document)}
|
to={documentPath(document)}
|
||||||
rtl={revision.rtl}
|
rtl={revision.rtl}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
<EditorContainer
|
<EditorContainer
|
||||||
dangerouslySetInnerHTML={{ __html: revision.html }}
|
dangerouslySetInnerHTML={{ __html: revision.html }}
|
||||||
dir={revision.dir}
|
dir={revision.dir}
|
||||||
|
|||||||
@@ -47,6 +47,16 @@ export default class RevisionsStore extends BaseStore<Revision> {
|
|||||||
return revisions;
|
return revisions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the latest revision for the given document.
|
||||||
|
*
|
||||||
|
* @returns A promise that resolves to the latest revision for the given document
|
||||||
|
*/
|
||||||
|
fetchLatest = async (documentId: string) => {
|
||||||
|
const res = await client.post(`/revisions.info`, { documentId });
|
||||||
|
return this.add(res.data);
|
||||||
|
};
|
||||||
|
|
||||||
@action
|
@action
|
||||||
fetchPage = async (
|
fetchPage = async (
|
||||||
options: PaginationParams | undefined
|
options: PaginationParams | undefined
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { FindOptions, Op } from "sequelize";
|
import { Op, SaveOptions } from "sequelize";
|
||||||
import {
|
import {
|
||||||
DataType,
|
DataType,
|
||||||
BelongsTo,
|
BelongsTo,
|
||||||
@@ -74,12 +74,8 @@ class Revision extends IdModel {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
static createFromDocument(
|
static buildFromDocument(document: Document) {
|
||||||
document: Document,
|
return this.build({
|
||||||
options?: FindOptions<Revision>
|
|
||||||
) {
|
|
||||||
return this.create(
|
|
||||||
{
|
|
||||||
title: document.title,
|
title: document.title,
|
||||||
text: document.text,
|
text: document.text,
|
||||||
userId: document.lastModifiedById,
|
userId: document.lastModifiedById,
|
||||||
@@ -89,9 +85,15 @@ class Revision extends IdModel {
|
|||||||
// revision time is set to the last time document was touched as this
|
// revision time is set to the last time document was touched as this
|
||||||
// handler can be debounced in the case of an update
|
// handler can be debounced in the case of an update
|
||||||
createdAt: document.updatedAt,
|
createdAt: document.updatedAt,
|
||||||
},
|
});
|
||||||
options
|
}
|
||||||
);
|
|
||||||
|
static createFromDocument(
|
||||||
|
document: Document,
|
||||||
|
options?: SaveOptions<Revision>
|
||||||
|
) {
|
||||||
|
const revision = this.buildFromDocument(document);
|
||||||
|
return revision.save(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
// instance methods
|
// instance methods
|
||||||
|
|||||||
@@ -1,129 +0,0 @@
|
|||||||
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 { APIContext } from "@server/types";
|
|
||||||
import slugify from "@server/utils/slugify";
|
|
||||||
import { assertPresent, assertSort, assertUuid } from "@server/validation";
|
|
||||||
import pagination from "./middlewares/pagination";
|
|
||||||
|
|
||||||
const router = new Router();
|
|
||||||
|
|
||||||
router.post("revisions.info", auth(), async (ctx: APIContext) => {
|
|
||||||
const { id } = ctx.request.body;
|
|
||||||
assertUuid(id, "id is required");
|
|
||||||
const { user } = ctx.state.auth;
|
|
||||||
const revision = await Revision.findByPk(id, {
|
|
||||||
rejectOnEmpty: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const document = await Document.findByPk(revision.documentId, {
|
|
||||||
userId: user.id,
|
|
||||||
});
|
|
||||||
authorize(user, "read", document);
|
|
||||||
|
|
||||||
const before = await revision.previous();
|
|
||||||
|
|
||||||
ctx.body = {
|
|
||||||
data: await presentRevision(
|
|
||||||
revision,
|
|
||||||
await DocumentHelper.diff(before, revision, {
|
|
||||||
includeTitle: false,
|
|
||||||
includeStyles: false,
|
|
||||||
})
|
|
||||||
),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("revisions.diff", auth(), async (ctx: APIContext) => {
|
|
||||||
const { id, compareToId } = ctx.request.body;
|
|
||||||
assertUuid(id, "id is required");
|
|
||||||
|
|
||||||
const { user } = ctx.state.auth;
|
|
||||||
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 = await DocumentHelper.diff(before, revision);
|
|
||||||
|
|
||||||
if (accept?.includes("text/html")) {
|
|
||||||
ctx.set("Content-Type", "text/html");
|
|
||||||
ctx.set(
|
|
||||||
"Content-Disposition",
|
|
||||||
`attachment; filename="${slugify(document.titleWithDefault)}-${
|
|
||||||
revision.id
|
|
||||||
}.html"`
|
|
||||||
);
|
|
||||||
ctx.body = content;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.body = {
|
|
||||||
data: content,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("revisions.list", auth(), pagination(), async (ctx: APIContext) => {
|
|
||||||
let { direction } = ctx.request.body;
|
|
||||||
const { documentId, sort = "updatedAt" } = ctx.request.body;
|
|
||||||
if (direction !== "ASC") {
|
|
||||||
direction = "DESC";
|
|
||||||
}
|
|
||||||
assertSort(sort, Revision);
|
|
||||||
assertPresent(documentId, "documentId is required");
|
|
||||||
|
|
||||||
const { user } = ctx.state.auth;
|
|
||||||
const document = await Document.findByPk(documentId, {
|
|
||||||
userId: user.id,
|
|
||||||
});
|
|
||||||
authorize(user, "read", document);
|
|
||||||
|
|
||||||
const revisions = await Revision.findAll({
|
|
||||||
where: {
|
|
||||||
documentId: document.id,
|
|
||||||
},
|
|
||||||
order: [[sort, direction]],
|
|
||||||
offset: ctx.state.pagination.offset,
|
|
||||||
limit: ctx.state.pagination.limit,
|
|
||||||
});
|
|
||||||
const data = await Promise.all(
|
|
||||||
revisions.map((revision) => presentRevision(revision))
|
|
||||||
);
|
|
||||||
|
|
||||||
ctx.body = {
|
|
||||||
pagination: ctx.state.pagination,
|
|
||||||
data,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
1
server/routes/api/revisions/index.ts
Normal file
1
server/routes/api/revisions/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default } from "./revisions";
|
||||||
154
server/routes/api/revisions/revisions.ts
Normal file
154
server/routes/api/revisions/revisions.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import Router from "koa-router";
|
||||||
|
import { Op } from "sequelize";
|
||||||
|
import { RevisionHelper } from "@shared/utils/RevisionHelper";
|
||||||
|
import { ValidationError } from "@server/errors";
|
||||||
|
import auth from "@server/middlewares/authentication";
|
||||||
|
import validate from "@server/middlewares/validate";
|
||||||
|
import { Document, Revision } from "@server/models";
|
||||||
|
import DocumentHelper from "@server/models/helpers/DocumentHelper";
|
||||||
|
import { authorize } from "@server/policies";
|
||||||
|
import { presentRevision } from "@server/presenters";
|
||||||
|
import { APIContext } from "@server/types";
|
||||||
|
import slugify from "@server/utils/slugify";
|
||||||
|
import pagination from "../middlewares/pagination";
|
||||||
|
import * as T from "./schema";
|
||||||
|
|
||||||
|
const router = new Router();
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"revisions.info",
|
||||||
|
auth(),
|
||||||
|
validate(T.RevisionsInfoSchema),
|
||||||
|
async (ctx: APIContext<T.RevisionsInfoReq>) => {
|
||||||
|
const { id, documentId } = ctx.input.body;
|
||||||
|
const { user } = ctx.state.auth;
|
||||||
|
let before: Revision | null, after: Revision;
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
const revision = await Revision.findByPk(id, {
|
||||||
|
rejectOnEmpty: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const document = await Document.findByPk(revision.documentId, {
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
authorize(user, "read", document);
|
||||||
|
after = revision;
|
||||||
|
before = await revision.previous();
|
||||||
|
} else if (documentId) {
|
||||||
|
const document = await Document.findByPk(documentId, {
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
authorize(user, "read", document);
|
||||||
|
after = Revision.buildFromDocument(document);
|
||||||
|
after.id = RevisionHelper.latestId(document.id);
|
||||||
|
after.user = document.updatedBy;
|
||||||
|
|
||||||
|
before = await Revision.findLatest(documentId);
|
||||||
|
} else {
|
||||||
|
throw ValidationError("Either id or documentId must be provided");
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
data: await presentRevision(
|
||||||
|
after,
|
||||||
|
await DocumentHelper.diff(before, after, {
|
||||||
|
includeTitle: false,
|
||||||
|
includeStyles: false,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"revisions.diff",
|
||||||
|
auth(),
|
||||||
|
validate(T.RevisionsDiffSchema),
|
||||||
|
async (ctx: APIContext<T.RevisionsDiffReq>) => {
|
||||||
|
const { id, compareToId } = ctx.input.body;
|
||||||
|
const { user } = ctx.state.auth;
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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 = await DocumentHelper.diff(before, revision);
|
||||||
|
|
||||||
|
if (accept?.includes("text/html")) {
|
||||||
|
ctx.set("Content-Type", "text/html");
|
||||||
|
ctx.set(
|
||||||
|
"Content-Disposition",
|
||||||
|
`attachment; filename="${slugify(document.titleWithDefault)}-${
|
||||||
|
revision.id
|
||||||
|
}.html"`
|
||||||
|
);
|
||||||
|
ctx.body = content;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
data: content,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"revisions.list",
|
||||||
|
auth(),
|
||||||
|
pagination(),
|
||||||
|
validate(T.RevisionsListSchema),
|
||||||
|
async (ctx: APIContext<T.RevisionsListReq>) => {
|
||||||
|
const { direction, documentId, sort } = ctx.input.body;
|
||||||
|
const { user } = ctx.state.auth;
|
||||||
|
|
||||||
|
const document = await Document.findByPk(documentId, {
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
authorize(user, "read", document);
|
||||||
|
|
||||||
|
const revisions = await Revision.findAll({
|
||||||
|
where: {
|
||||||
|
documentId: document.id,
|
||||||
|
},
|
||||||
|
order: [[sort, direction]],
|
||||||
|
offset: ctx.state.pagination.offset,
|
||||||
|
limit: ctx.state.pagination.limit,
|
||||||
|
});
|
||||||
|
const data = await Promise.all(
|
||||||
|
revisions.map((revision) => presentRevision(revision))
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
pagination: ctx.state.pagination,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
46
server/routes/api/revisions/schema.ts
Normal file
46
server/routes/api/revisions/schema.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { isEmpty } from "lodash";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Revision } from "@server/models";
|
||||||
|
import BaseSchema from "@server/routes/api/BaseSchema";
|
||||||
|
|
||||||
|
export const RevisionsInfoSchema = BaseSchema.extend({
|
||||||
|
body: z
|
||||||
|
.object({
|
||||||
|
id: z.string().uuid().optional(),
|
||||||
|
documentId: z.string().uuid().optional(),
|
||||||
|
})
|
||||||
|
.refine((req) => !(isEmpty(req.id) && isEmpty(req.documentId)), {
|
||||||
|
message: "id or documentId is required",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RevisionsInfoReq = z.infer<typeof RevisionsInfoSchema>;
|
||||||
|
|
||||||
|
export const RevisionsDiffSchema = BaseSchema.extend({
|
||||||
|
body: z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
compareToId: z.string().uuid().optional(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RevisionsDiffReq = z.infer<typeof RevisionsDiffSchema>;
|
||||||
|
|
||||||
|
export const RevisionsListSchema = z.object({
|
||||||
|
body: z.object({
|
||||||
|
direction: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((val) => (val !== "ASC" ? "DESC" : val)),
|
||||||
|
|
||||||
|
sort: z
|
||||||
|
.string()
|
||||||
|
.refine((val) => Object.keys(Revision.getAttributes()).includes(val), {
|
||||||
|
message: "Invalid sort parameter",
|
||||||
|
})
|
||||||
|
.default("createdAt"),
|
||||||
|
|
||||||
|
documentId: z.string().uuid(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RevisionsListReq = z.infer<typeof RevisionsListSchema>;
|
||||||
@@ -127,6 +127,8 @@
|
|||||||
"Draft": "Draft",
|
"Draft": "Draft",
|
||||||
"Template": "Template",
|
"Template": "Template",
|
||||||
"New doc": "New doc",
|
"New doc": "New doc",
|
||||||
|
"You updated": "You updated",
|
||||||
|
"{{ userName }} updated": "{{ userName }} updated",
|
||||||
"You deleted": "You deleted",
|
"You deleted": "You deleted",
|
||||||
"{{ userName }} deleted": "{{ userName }} deleted",
|
"{{ userName }} deleted": "{{ userName }} deleted",
|
||||||
"You archived": "You archived",
|
"You archived": "You archived",
|
||||||
@@ -137,8 +139,6 @@
|
|||||||
"{{ userName }} published": "{{ userName }} published",
|
"{{ userName }} published": "{{ userName }} published",
|
||||||
"You saved": "You saved",
|
"You saved": "You saved",
|
||||||
"{{ userName }} saved": "{{ userName }} saved",
|
"{{ userName }} saved": "{{ userName }} saved",
|
||||||
"You updated": "You updated",
|
|
||||||
"{{ userName }} updated": "{{ userName }} updated",
|
|
||||||
"Never viewed": "Never viewed",
|
"Never viewed": "Never viewed",
|
||||||
"Viewed": "Viewed",
|
"Viewed": "Viewed",
|
||||||
"in": "in",
|
"in": "in",
|
||||||
@@ -164,8 +164,8 @@
|
|||||||
"our engineers have been notified": "our engineers have been notified",
|
"our engineers have been notified": "our engineers have been notified",
|
||||||
"Report a Bug": "Report a Bug",
|
"Report a Bug": "Report a Bug",
|
||||||
"Show Detail": "Show Detail",
|
"Show Detail": "Show Detail",
|
||||||
|
"Current version": "Current version",
|
||||||
"{{userName}} edited": "{{userName}} edited",
|
"{{userName}} edited": "{{userName}} edited",
|
||||||
"Latest": "Latest",
|
|
||||||
"{{userName}} archived": "{{userName}} archived",
|
"{{userName}} archived": "{{userName}} archived",
|
||||||
"{{userName}} restored": "{{userName}} restored",
|
"{{userName}} restored": "{{userName}} restored",
|
||||||
"{{userName}} deleted": "{{userName}} deleted",
|
"{{userName}} deleted": "{{userName}} deleted",
|
||||||
|
|||||||
11
shared/utils/RevisionHelper.ts
Normal file
11
shared/utils/RevisionHelper.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export class RevisionHelper {
|
||||||
|
/**
|
||||||
|
* Get a static id for the latest revision of a document.
|
||||||
|
*
|
||||||
|
* @param documentId The document to generate an ID for.
|
||||||
|
* @returns The ID of the latest revision of the document.
|
||||||
|
*/
|
||||||
|
static latestId(documentId?: string) {
|
||||||
|
return documentId ? `latest-${documentId}` : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user