@@ -29,6 +29,7 @@ import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
|
||||
import DocumentTemplatizeDialog from "~/components/DocumentTemplatizeDialog";
|
||||
import { createAction } from "~/actions";
|
||||
import { DocumentSection } from "~/actions/sections";
|
||||
import env from "~/env";
|
||||
import history from "~/utils/history";
|
||||
import {
|
||||
documentInsightsUrl,
|
||||
@@ -206,6 +207,33 @@ export const downloadDocumentAsHTML = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocumentAsPDF = createAction({
|
||||
name: ({ t }) => t("PDF"),
|
||||
section: DocumentSection,
|
||||
keywords: "export",
|
||||
icon: <DownloadIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId &&
|
||||
stores.policies.abilities(activeDocumentId).download &&
|
||||
env.PDF_EXPORT_ENABLED,
|
||||
perform: ({ activeDocumentId, t, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = stores.toasts.showToast(`${t("Exporting")}…`, {
|
||||
type: "loading",
|
||||
timeout: 30 * 1000,
|
||||
});
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
document
|
||||
?.download("application/pdf")
|
||||
.finally(() => id && stores.toasts.hideToast(id));
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocumentAsMarkdown = createAction({
|
||||
name: ({ t }) => t("Markdown"),
|
||||
section: DocumentSection,
|
||||
@@ -230,7 +258,11 @@ export const downloadDocument = createAction({
|
||||
section: DocumentSection,
|
||||
icon: <DownloadIcon />,
|
||||
keywords: "export",
|
||||
children: [downloadDocumentAsHTML, downloadDocumentAsMarkdown],
|
||||
children: [
|
||||
downloadDocumentAsHTML,
|
||||
downloadDocumentAsPDF,
|
||||
downloadDocumentAsMarkdown,
|
||||
],
|
||||
});
|
||||
|
||||
export const duplicateDocument = createAction({
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
export default function Spinner(props: React.HTMLAttributes<HTMLOrSVGElement>) {
|
||||
type Props = React.HTMLAttributes<HTMLOrSVGElement> & {
|
||||
color?: string;
|
||||
};
|
||||
|
||||
export default function Spinner({ color, ...props }: Props) {
|
||||
return (
|
||||
<SVG
|
||||
width="16px"
|
||||
@@ -11,6 +15,7 @@ export default function Spinner(props: React.HTMLAttributes<HTMLOrSVGElement>) {
|
||||
{...props}
|
||||
>
|
||||
<Circle
|
||||
$color={color}
|
||||
fill="none"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
@@ -36,7 +41,7 @@ const SVG = styled.svg`
|
||||
margin: 4px;
|
||||
`;
|
||||
|
||||
const Circle = styled.circle`
|
||||
const Circle = styled.circle<{ $color?: string }>`
|
||||
@keyframes dash {
|
||||
0% {
|
||||
stroke-dashoffset: 47;
|
||||
@@ -51,7 +56,7 @@ const Circle = styled.circle`
|
||||
}
|
||||
}
|
||||
|
||||
stroke: ${(props) => props.theme.textSecondary};
|
||||
stroke: ${(props) => props.$color || props.theme.textSecondary};
|
||||
stroke-dasharray: 46;
|
||||
stroke-dashoffset: 0;
|
||||
transform-origin: center;
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as React from "react";
|
||||
import styled, { css } from "styled-components";
|
||||
import { fadeAndScaleIn, pulse } from "~/styles/animations";
|
||||
import { Toast as TToast } from "~/types";
|
||||
import Spinner from "./Spinner";
|
||||
|
||||
type Props = {
|
||||
onRequestClose: () => void;
|
||||
@@ -56,6 +57,7 @@ function Toast({ closeAfterMs = 3000, onRequestClose, toast }: Props) {
|
||||
onMouseLeave={handleResume}
|
||||
>
|
||||
<Container onClick={action ? undefined : onRequestClose}>
|
||||
{type === "loading" && <Spinner color="currentColor" />}
|
||||
{type === "info" && <InfoIcon color="currentColor" />}
|
||||
{type === "success" && <CheckboxIcon checked color="currentColor" />}
|
||||
{type === "warning" ||
|
||||
|
||||
@@ -419,7 +419,9 @@ export default class Document extends ParanoidModel {
|
||||
};
|
||||
}
|
||||
|
||||
download = async (contentType: "text/html" | "text/markdown") => {
|
||||
download = async (
|
||||
contentType: "text/html" | "text/markdown" | "application/pdf"
|
||||
) => {
|
||||
await client.post(
|
||||
`/documents.export`,
|
||||
{
|
||||
|
||||
@@ -124,7 +124,7 @@ export type Toast = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
message: string;
|
||||
type: "warning" | "error" | "info" | "success";
|
||||
type: "warning" | "error" | "info" | "success" | "loading";
|
||||
timeout?: number;
|
||||
reoccurring?: number;
|
||||
action?: {
|
||||
@@ -176,7 +176,7 @@ export type SearchResult = {
|
||||
};
|
||||
|
||||
export type ToastOptions = {
|
||||
type: "warning" | "error" | "info" | "success";
|
||||
type: "warning" | "error" | "info" | "success" | "loading";
|
||||
timeout?: number;
|
||||
action?: {
|
||||
text: string;
|
||||
|
||||
@@ -103,6 +103,14 @@ export function ValidationError(message = "Validation failed") {
|
||||
});
|
||||
}
|
||||
|
||||
export function IncorrectEditionError(
|
||||
message = "Functionality not available in this edition"
|
||||
) {
|
||||
return httpErrors(402, message, {
|
||||
id: "incorrect_edition",
|
||||
});
|
||||
}
|
||||
|
||||
export function EditorUpdateError(
|
||||
message = "The client editor is out of date and must be reloaded"
|
||||
) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import Revision from "@server/models/Revision";
|
||||
import DocumentHelper from "./DocumentHelper";
|
||||
|
||||
describe("toEmailDiff", () => {
|
||||
test("toEmailDiff", () => {
|
||||
test("toEmailDiff", async () => {
|
||||
const before = new Revision({
|
||||
title: "Title",
|
||||
text: `
|
||||
@@ -58,7 +58,7 @@ same on both sides
|
||||
same on both sides`,
|
||||
});
|
||||
|
||||
const html = DocumentHelper.toEmailDiff(before, after);
|
||||
const html = await DocumentHelper.toEmailDiff(before, after);
|
||||
|
||||
// marks breaks in diff
|
||||
expect(html).toContain("diff-context-break");
|
||||
|
||||
@@ -37,6 +37,8 @@ type HTMLOptions = {
|
||||
includeStyles?: boolean;
|
||||
/** Whether to include styles to center diff (defaults to true) */
|
||||
centered?: boolean;
|
||||
/** Whether to replace attachment urls with pre-signed versions (defaults to false) */
|
||||
signedUrls?: boolean;
|
||||
};
|
||||
|
||||
export default class DocumentHelper {
|
||||
@@ -81,7 +83,7 @@ export default class DocumentHelper {
|
||||
* @param options Options for the HTML output
|
||||
* @returns The document title and content as a HTML string
|
||||
*/
|
||||
static toHTML(document: Document | Revision, options?: HTMLOptions) {
|
||||
static async toHTML(document: Document | Revision, options?: HTMLOptions) {
|
||||
const node = DocumentHelper.toProsemirror(document);
|
||||
const sheet = new ServerStyleSheet();
|
||||
let html, styleTags;
|
||||
@@ -153,7 +155,16 @@ export default class DocumentHelper {
|
||||
target
|
||||
);
|
||||
|
||||
return dom.serialize();
|
||||
let output = dom.serialize();
|
||||
|
||||
if (options?.signedUrls && document instanceof Document) {
|
||||
output = await DocumentHelper.attachmentsToSignedUrls(
|
||||
output,
|
||||
document.teamId
|
||||
);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -164,17 +175,17 @@ export default class DocumentHelper {
|
||||
* @param options Options passed to HTML generation
|
||||
* @returns The diff as a HTML string
|
||||
*/
|
||||
static diff(
|
||||
static async diff(
|
||||
before: Document | Revision | null,
|
||||
after: Revision,
|
||||
options?: HTMLOptions
|
||||
) {
|
||||
if (!before) {
|
||||
return DocumentHelper.toHTML(after, options);
|
||||
return await DocumentHelper.toHTML(after, options);
|
||||
}
|
||||
|
||||
const beforeHTML = DocumentHelper.toHTML(before, options);
|
||||
const afterHTML = DocumentHelper.toHTML(after, options);
|
||||
const beforeHTML = await DocumentHelper.toHTML(before, options);
|
||||
const afterHTML = await DocumentHelper.toHTML(after, options);
|
||||
const beforeDOM = new JSDOM(beforeHTML);
|
||||
const afterDOM = new JSDOM(afterHTML);
|
||||
|
||||
@@ -205,7 +216,7 @@ export default class DocumentHelper {
|
||||
* @param options Options passed to HTML generation
|
||||
* @returns The diff as a HTML string
|
||||
*/
|
||||
static toEmailDiff(
|
||||
static async toEmailDiff(
|
||||
before: Document | Revision | null,
|
||||
after: Revision,
|
||||
options?: HTMLOptions
|
||||
@@ -214,7 +225,7 @@ export default class DocumentHelper {
|
||||
return "";
|
||||
}
|
||||
|
||||
const html = DocumentHelper.diff(before, after, options);
|
||||
const html = await DocumentHelper.diff(before, after, options);
|
||||
const dom = new JSDOM(html);
|
||||
const doc = dom.window.document;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import env from "@server/env";
|
||||
import { IncorrectEditionError } from "@server/errors";
|
||||
import { Team, User } from "@server/models";
|
||||
import { allow } from "./cancan";
|
||||
|
||||
@@ -13,7 +14,7 @@ allow(User, "share", Team, (user, team) => {
|
||||
|
||||
allow(User, "createTeam", Team, () => {
|
||||
if (env.DEPLOYMENT !== "hosted") {
|
||||
throw new Error("createTeam only available on cloud");
|
||||
throw IncorrectEditionError("createTeam only available on cloud");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ export default function present(env: Environment): PublicEnv {
|
||||
SLACK_APP_ID: env.SLACK_APP_ID,
|
||||
MAXIMUM_IMPORT_SIZE: env.MAXIMUM_IMPORT_SIZE,
|
||||
SUBDOMAINS_ENABLED: env.SUBDOMAINS_ENABLED,
|
||||
PDF_EXPORT_ENABLED: false,
|
||||
DEFAULT_LANGUAGE: env.DEFAULT_LANGUAGE,
|
||||
EMAIL_ENABLED: !!env.SMTP_HOST || env.ENVIRONMENT === "development",
|
||||
GOOGLE_ANALYTICS_ID: env.GOOGLE_ANALYTICS_ID,
|
||||
|
||||
@@ -122,7 +122,7 @@ export default class NotificationsProcessor extends BaseProcessor {
|
||||
|
||||
// generate the diff html for the email
|
||||
const before = await revision.previous();
|
||||
let content = DocumentHelper.toEmailDiff(before, revision, {
|
||||
let content = await DocumentHelper.toEmailDiff(before, revision, {
|
||||
includeTitle: false,
|
||||
centered: false,
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Op, ScopeOptions, WhereOptions } from "sequelize";
|
||||
import { TeamPreference } from "@shared/types";
|
||||
import { subtractDate } from "@shared/utils/date";
|
||||
import { bytesToHumanReadable } from "@shared/utils/files";
|
||||
import { RateLimiterStrategy } from "@server/RateLimiter";
|
||||
import documentCreator from "@server/commands/documentCreator";
|
||||
import documentImporter from "@server/commands/documentImporter";
|
||||
import documentLoader from "@server/commands/documentLoader";
|
||||
@@ -19,8 +20,10 @@ import {
|
||||
InvalidRequestError,
|
||||
AuthenticationError,
|
||||
ValidationError,
|
||||
IncorrectEditionError,
|
||||
} from "@server/errors";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { rateLimiter } from "@server/middlewares/rateLimiter";
|
||||
import validate from "@server/middlewares/validate";
|
||||
import {
|
||||
Backlink,
|
||||
@@ -433,6 +436,7 @@ router.post(
|
||||
|
||||
router.post(
|
||||
"documents.export",
|
||||
rateLimiter(RateLimiterStrategy.FivePerMinute),
|
||||
auth({
|
||||
optional: true,
|
||||
}),
|
||||
@@ -447,7 +451,7 @@ router.post(
|
||||
shareId,
|
||||
user,
|
||||
// We need the collaborative state to generate HTML.
|
||||
includeState: accept === "text/html",
|
||||
includeState: !accept?.includes("text/markdown"),
|
||||
});
|
||||
|
||||
let contentType;
|
||||
@@ -455,7 +459,11 @@ router.post(
|
||||
|
||||
if (accept?.includes("text/html")) {
|
||||
contentType = "text/html";
|
||||
content = DocumentHelper.toHTML(document);
|
||||
content = await DocumentHelper.toHTML(document);
|
||||
} else if (accept?.includes("application/pdf")) {
|
||||
throw IncorrectEditionError(
|
||||
"PDF export is not available in the community edition"
|
||||
);
|
||||
} else if (accept?.includes("text/markdown")) {
|
||||
contentType = "text/markdown";
|
||||
content = DocumentHelper.toMarkdown(document);
|
||||
|
||||
@@ -8,7 +8,8 @@ export default function apiWrapper() {
|
||||
|
||||
if (
|
||||
typeof ctx.body === "object" &&
|
||||
!(ctx.body instanceof stream.Readable)
|
||||
!(ctx.body instanceof stream.Readable) &&
|
||||
!(ctx.body instanceof Buffer)
|
||||
) {
|
||||
ctx.body = {
|
||||
...ctx.body,
|
||||
|
||||
@@ -30,7 +30,7 @@ router.post("revisions.info", auth(), async (ctx) => {
|
||||
ctx.body = {
|
||||
data: await presentRevision(
|
||||
revision,
|
||||
DocumentHelper.diff(before, revision, {
|
||||
await DocumentHelper.diff(before, revision, {
|
||||
includeTitle: false,
|
||||
includeStyles: false,
|
||||
})
|
||||
@@ -73,7 +73,7 @@ router.post("revisions.diff", auth(), async (ctx) => {
|
||||
}
|
||||
|
||||
const accept = ctx.request.headers["accept"];
|
||||
const content = DocumentHelper.diff(before, revision);
|
||||
const content = await DocumentHelper.diff(before, revision);
|
||||
|
||||
if (accept?.includes("text/html")) {
|
||||
ctx.set("Content-Type", "text/html");
|
||||
|
||||
@@ -32,7 +32,7 @@ describe("teams.create", () => {
|
||||
name: "new workspace",
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(500);
|
||||
expect(res.status).toEqual(402);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
"Unsubscribe": "Unsubscribe",
|
||||
"Unsubscribed from document notifications": "Unsubscribed from document notifications",
|
||||
"HTML": "HTML",
|
||||
"PDF": "PDF",
|
||||
"Exporting": "Exporting",
|
||||
"Markdown": "Markdown",
|
||||
"Download": "Download",
|
||||
"Download document": "Download document",
|
||||
@@ -371,7 +373,6 @@
|
||||
"Export started. If you have notifications enabled, you will receive an email when it's complete.": "Export started. If you have notifications enabled, you will receive an email when it's complete.",
|
||||
"Exporting the collection <em>{{collectionName}}</em> may take some time.": "Exporting the collection <em>{{collectionName}}</em> may take some time.",
|
||||
"Your documents will be a zip of folders with files in Markdown format. Please visit the Export section in Settings to get the zip.": "Your documents will be a zip of folders with files in Markdown format. Please visit the Export section in Settings to get the zip.",
|
||||
"Exporting": "Exporting",
|
||||
"Collections are for grouping your documents. They work best when organized around a topic or internal team — Product or Engineering for example.": "Collections are for grouping your documents. They work best when organized around a topic or internal team — Product or Engineering for example.",
|
||||
"This is the default level of access, you can give individual users or groups more access once the collection is created.": "This is the default level of access, you can give individual users or groups more access once the collection is created.",
|
||||
"Public document sharing": "Public document sharing",
|
||||
|
||||
@@ -22,6 +22,7 @@ export type PublicEnv = {
|
||||
MAXIMUM_IMPORT_SIZE: number;
|
||||
SUBDOMAINS_ENABLED: boolean;
|
||||
EMAIL_ENABLED: boolean;
|
||||
PDF_EXPORT_ENABLED: boolean;
|
||||
DEFAULT_LANGUAGE: string;
|
||||
GOOGLE_ANALYTICS_ID: string | undefined;
|
||||
RELEASE: string | undefined;
|
||||
|
||||
Reference in New Issue
Block a user