fix: Add ability to permanently delete documents in trash (#2192)

* Align false conditions before true

* Update documents.delete endpoint for permanent delete

* Add permanent delete to events table and integrate with socket.io

* Add permanent delete to document menu

* Update parentDocumentId of direct child to null

* Add translation

* Add test for permanent delete

* Add space

* Update app/scenes/DocumentPermanentDelete.js

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* Update app/stores/DocumentsStore.js

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* Update server/commands/documentPermanentDeleter.js

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* Update app/scenes/DocumentPermanentDelete.js

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* Change socket room from team to collection

* Add translation

* Create log func for commands

* Move tests from utils to permanentDeleter command

* Add additional tests

* Set redirect to trash

* Return promise from beforeEach

* Add undeleted documents validation

* Include deleteAt attribute in db query

* Update server/commands/documentPermanentDeleter.js

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* tweak language

Co-authored-by: Tom Moor <tom.moor@gmail.com>
This commit is contained in:
Saumya Pandey
2021-06-26 04:44:40 +05:30
committed by GitHub
parent c69b4efc34
commit 9fccc280d7
15 changed files with 416 additions and 192 deletions

View File

@ -250,6 +250,10 @@ class SocketProvider extends React.Component<Props> {
documents.starredIds.set(event.documentId, false); documents.starredIds.set(event.documentId, false);
}); });
this.socket.on("documents.permanent_delete", (event) => {
documents.remove(event.documentId);
});
// received when a user is given access to a collection // received when a user is given access to a collection
// if the user is us then we go ahead and load the collection from API. // if the user is us then we go ahead and load the collection from API.
this.socket.on("collections.add_user", (event) => { this.socket.on("collections.add_user", (event) => {

View File

@ -9,6 +9,7 @@ import styled from "styled-components";
import Document from "models/Document"; import Document from "models/Document";
import DocumentDelete from "scenes/DocumentDelete"; import DocumentDelete from "scenes/DocumentDelete";
import DocumentMove from "scenes/DocumentMove"; import DocumentMove from "scenes/DocumentMove";
import DocumentPermanentDelete from "scenes/DocumentPermanentDelete";
import DocumentTemplatize from "scenes/DocumentTemplatize"; import DocumentTemplatize from "scenes/DocumentTemplatize";
import CollectionIcon from "components/CollectionIcon"; import CollectionIcon from "components/CollectionIcon";
import ContextMenu from "components/ContextMenu"; import ContextMenu from "components/ContextMenu";
@ -61,6 +62,10 @@ function DocumentMenu({
const { t } = useTranslation(); const { t } = useTranslation();
const [renderModals, setRenderModals] = React.useState(false); const [renderModals, setRenderModals] = React.useState(false);
const [showDeleteModal, setShowDeleteModal] = React.useState(false); const [showDeleteModal, setShowDeleteModal] = React.useState(false);
const [
showPermanentDeleteModal,
setShowPermanentDeleteModal,
] = React.useState(false);
const [showMoveModal, setShowMoveModal] = React.useState(false); const [showMoveModal, setShowMoveModal] = React.useState(false);
const [showTemplateModal, setShowTemplateModal] = React.useState(false); const [showTemplateModal, setShowTemplateModal] = React.useState(false);
const file = React.useRef<?HTMLInputElement>(); const file = React.useRef<?HTMLInputElement>();
@ -327,6 +332,11 @@ function DocumentMenu({
onClick: () => setShowDeleteModal(true), onClick: () => setShowDeleteModal(true),
visible: !!can.delete, visible: !!can.delete,
}, },
{
title: `${t("Permanently delete")}`,
onClick: () => setShowPermanentDeleteModal(true),
visible: can.permanentDelete,
},
{ {
title: `${t("Move")}`, title: `${t("Move")}`,
onClick: () => setShowMoveModal(true), onClick: () => setShowMoveModal(true),
@ -357,6 +367,7 @@ function DocumentMenu({
</ContextMenu> </ContextMenu>
{renderModals && ( {renderModals && (
<> <>
{can.move && (
<Modal <Modal
title={t("Move {{ documentName }}", { title={t("Move {{ documentName }}", {
documentName: document.noun, documentName: document.noun,
@ -369,6 +380,8 @@ function DocumentMenu({
onRequestClose={() => setShowMoveModal(false)} onRequestClose={() => setShowMoveModal(false)}
/> />
</Modal> </Modal>
)}
{can.delete && (
<Modal <Modal
title={t("Delete {{ documentName }}", { title={t("Delete {{ documentName }}", {
documentName: document.noun, documentName: document.noun,
@ -381,6 +394,22 @@ function DocumentMenu({
onSubmit={() => setShowDeleteModal(false)} onSubmit={() => setShowDeleteModal(false)}
/> />
</Modal> </Modal>
)}
{can.permanentDelete && (
<Modal
title={t("Permanently delete {{ documentName }}", {
documentName: document.noun,
})}
onRequestClose={() => setShowPermanentDeleteModal(false)}
isOpen={showPermanentDeleteModal}
>
<DocumentPermanentDelete
document={document}
onSubmit={() => setShowPermanentDeleteModal(false)}
/>
</Modal>
)}
{can.update && (
<Modal <Modal
title={t("Create template")} title={t("Create template")}
onRequestClose={() => setShowTemplateModal(false)} onRequestClose={() => setShowTemplateModal(false)}
@ -391,6 +420,7 @@ function DocumentMenu({
onSubmit={() => setShowTemplateModal(false)} onSubmit={() => setShowTemplateModal(false)}
/> />
</Modal> </Modal>
)}
</> </>
)} )}
</> </>

View File

@ -0,0 +1,60 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { useHistory } from "react-router-dom";
import Document from "models/Document.js";
import Button from "components/Button";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import useStores from "hooks/useStores";
type Props = {|
document: Document,
onSubmit: () => void,
|};
function DocumentPermanentDelete({ document, onSubmit }: Props) {
const [isDeleting, setIsDeleting] = React.useState(false);
const { t } = useTranslation();
const { ui, documents } = useStores();
const { showToast } = ui;
const history = useHistory();
const handleSubmit = React.useCallback(
async (ev: SyntheticEvent<>) => {
ev.preventDefault();
try {
setIsDeleting(true);
await documents.delete(document, { permanent: true });
showToast(t("Document permanently deleted"), { type: "success" });
onSubmit();
history.push("/trash");
} catch (err) {
showToast(err.message, { type: "error" });
} finally {
setIsDeleting(false);
}
},
[document, onSubmit, showToast, t, history, documents]
);
return (
<Flex column>
<form onSubmit={handleSubmit}>
<HelpText>
<Trans
defaults="Are you sure you want to permanently delete the <em>{{ documentTitle }}</em> document? This action is immediate and cannot be undone."
values={{ documentTitle: document.titleWithDefault }}
components={{ em: <strong /> }}
/>
</HelpText>
<Button type="submit" danger>
{isDeleting ? `${t("Deleting")}` : t("Im sure  Delete")}
</Button>
</form>
</Flex>
);
}
export default observer(DocumentPermanentDelete);

View File

@ -616,8 +616,8 @@ export default class DocumentsStore extends BaseStore<Document> {
} }
@action @action
async delete(document: Document) { async delete(document: Document, options?: {| permanent: boolean |}) {
await super.delete(document); await super.delete(document, options);
// check to see if we have any shares related to this document already // check to see if we have any shares related to this document already
// loaded in local state. If so we can go ahead and remove those too. // loaded in local state. If so we can go ahead and remove those too.

View File

@ -5,6 +5,7 @@ import { subtractDate } from "../../shared/utils/date";
import documentCreator from "../commands/documentCreator"; import documentCreator from "../commands/documentCreator";
import documentImporter from "../commands/documentImporter"; import documentImporter from "../commands/documentImporter";
import documentMover from "../commands/documentMover"; import documentMover from "../commands/documentMover";
import { documentPermanentDeleter } from "../commands/documentPermanentDeleter";
import env from "../env"; import env from "../env";
import { import {
NotFoundError, NotFoundError,
@ -1174,15 +1175,43 @@ router.post("documents.archive", auth(), async (ctx) => {
}); });
router.post("documents.delete", auth(), async (ctx) => { router.post("documents.delete", auth(), async (ctx) => {
const { id } = ctx.body; const { id, permanent } = ctx.body;
ctx.assertPresent(id, "id is required"); ctx.assertPresent(id, "id is required");
const user = ctx.state.user; const user = ctx.state.user;
if (permanent) {
const document = await Document.findByPk(id, {
userId: user.id,
paranoid: false,
});
authorize(user, "permanentDelete", document);
await Document.update(
{ parentDocumentId: null },
{
where: {
parentDocumentId: document.id,
},
paranoid: false,
}
);
await documentPermanentDeleter([document]);
await Event.create({
name: "documents.permanent_delete",
documentId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: user.id,
data: { title: document.title },
ip: ctx.request.ip,
});
} else {
const document = await Document.findByPk(id, { userId: user.id }); const document = await Document.findByPk(id, { userId: user.id });
authorize(user, "delete", document); authorize(user, "delete", document);
await document.delete(user.id); await document.delete(user.id);
await Event.create({ await Event.create({
name: "documents.delete", name: "documents.delete",
documentId: document.id, documentId: document.id,
@ -1192,6 +1221,7 @@ router.post("documents.delete", auth(), async (ctx) => {
data: { title: document.title }, data: { title: document.title },
ip: ctx.request.ip, ip: ctx.request.ip,
}); });
}
ctx.body = { ctx.body = {
success: true, success: true,

View File

@ -2201,6 +2201,26 @@ describe("#documents.delete", () => {
expect(body.success).toEqual(true); expect(body.success).toEqual(true);
}); });
it("should allow permanently deleting a document", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
await server.post("/api/documents.delete", {
body: { token: user.getJwtToken(), id: document.id },
});
const res = await server.post("/api/documents.delete", {
body: { token: user.getJwtToken(), id: document.id, permanent: true },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toEqual(true);
});
it("should allow deleting document without collection", async () => { it("should allow deleting document without collection", async () => {
const { user, document, collection } = await seed(); const { user, document, collection } = await seed();

View File

@ -2,10 +2,10 @@
import { subDays } from "date-fns"; import { subDays } from "date-fns";
import debug from "debug"; import debug from "debug";
import Router from "koa-router"; import Router from "koa-router";
import { documentPermanentDeleter } from "../commands/documentPermanentDeleter";
import { AuthenticationError } from "../errors"; import { AuthenticationError } from "../errors";
import { Document, Attachment } from "../models"; import { Document } from "../models";
import { Op, sequelize } from "../sequelize"; import { Op } from "../sequelize";
import parseAttachmentIds from "../utils/parseAttachmentIds";
const router = new Router(); const router = new Router();
const log = debug("utils"); const log = debug("utils");
@ -20,7 +20,7 @@ router.post("utils.gc", async (ctx) => {
log(`Permanently destroying upto ${limit} documents older than 30 days…`); log(`Permanently destroying upto ${limit} documents older than 30 days…`);
const documents = await Document.scope("withUnpublished").findAll({ const documents = await Document.scope("withUnpublished").findAll({
attributes: ["id", "teamId", "text"], attributes: ["id", "teamId", "text", "deletedAt"],
where: { where: {
deletedAt: { deletedAt: {
[Op.lt]: subDays(new Date(), 30), [Op.lt]: subDays(new Date(), 30),
@ -30,54 +30,9 @@ router.post("utils.gc", async (ctx) => {
limit, limit,
}); });
const query = ` const countDeletedDocument = await documentPermanentDeleter(documents);
SELECT COUNT(id)
FROM documents
WHERE "searchVector" @@ to_tsquery('english', :query) AND
"teamId" = :teamId AND
"id" != :documentId
`;
for (const document of documents) { log(`Destroyed ${countDeletedDocument} documents`);
const attachmentIds = parseAttachmentIds(document.text);
for (const attachmentId of attachmentIds) {
const [{ count }] = await sequelize.query(query, {
type: sequelize.QueryTypes.SELECT,
replacements: {
documentId: document.id,
teamId: document.teamId,
query: attachmentId,
},
});
if (parseInt(count) === 0) {
const attachment = await Attachment.findOne({
where: {
teamId: document.teamId,
id: attachmentId,
},
});
if (attachment) {
await attachment.destroy();
log(`Attachment ${attachmentId} deleted`);
} else {
log(`Unknown attachment ${attachmentId} ignored`);
}
}
}
}
await Document.scope("withUnpublished").destroy({
where: {
id: documents.map((document) => document.id),
},
force: true,
});
log(`Destroyed ${documents.length} documents`);
ctx.body = { ctx.body = {
success: true, success: true,

View File

@ -2,8 +2,8 @@
import { subDays } from "date-fns"; import { subDays } from "date-fns";
import TestServer from "fetch-test-server"; import TestServer from "fetch-test-server";
import app from "../app"; import app from "../app";
import { Attachment, Document } from "../models"; import { Document } from "../models";
import { buildAttachment, buildDocument } from "../test/factories"; import { buildDocument } from "../test/factories";
import { flushdb } from "../test/support"; import { flushdb } from "../test/support";
const server = new TestServer(app.callback()); const server = new TestServer(app.callback());
@ -67,94 +67,6 @@ describe("#utils.gc", () => {
expect(await Document.unscoped().count({ paranoid: false })).toEqual(0); expect(await Document.unscoped().count({ paranoid: false })).toEqual(0);
}); });
it("should destroy attachments no longer referenced", async () => {
const document = await buildDocument({
publishedAt: subDays(new Date(), 90),
deletedAt: subDays(new Date(), 60),
});
const attachment = await buildAttachment({
teamId: document.teamId,
documentId: document.id,
});
document.text = `![text](${attachment.redirectUrl})`;
await document.save();
const res = await server.post("/api/utils.gc", {
body: {
token: process.env.UTILS_SECRET,
},
});
expect(res.status).toEqual(200);
expect(await Attachment.count()).toEqual(0);
expect(await Document.unscoped().count({ paranoid: false })).toEqual(0);
});
it("should handle unknown attachment ids", async () => {
const document = await buildDocument({
publishedAt: subDays(new Date(), 90),
deletedAt: subDays(new Date(), 60),
});
const attachment = await buildAttachment({
teamId: document.teamId,
documentId: document.id,
});
document.text = `![text](${attachment.redirectUrl})`;
await document.save();
// remove attachment so it no longer exists in the database, this is also
// representative of a corrupt attachment id in the doc or the regex returning
// an incorrect string
await attachment.destroy({ force: true });
const res = await server.post("/api/utils.gc", {
body: {
token: process.env.UTILS_SECRET,
},
});
expect(res.status).toEqual(200);
expect(await Attachment.count()).toEqual(0);
expect(await Document.unscoped().count({ paranoid: false })).toEqual(0);
});
it("should not destroy attachments referenced in other documents", async () => {
const document1 = await buildDocument();
const document = await buildDocument({
teamId: document1.teamId,
publishedAt: subDays(new Date(), 90),
deletedAt: subDays(new Date(), 60),
});
const attachment = await buildAttachment({
teamId: document1.teamId,
documentId: document.id,
});
document1.text = `![text](${attachment.redirectUrl})`;
await document1.save();
document.text = `![text](${attachment.redirectUrl})`;
await document.save();
expect(await Attachment.count()).toEqual(1);
const res = await server.post("/api/utils.gc", {
body: {
token: process.env.UTILS_SECRET,
},
});
expect(res.status).toEqual(200);
expect(await Attachment.count()).toEqual(1);
expect(await Document.unscoped().count({ paranoid: false })).toEqual(1);
});
it("should destroy draft documents deleted more than 30 days ago", async () => { it("should destroy draft documents deleted more than 30 days ago", async () => {
await buildDocument({ await buildDocument({
publishedAt: undefined, publishedAt: undefined,

View File

@ -0,0 +1,64 @@
// @flow
import debug from "debug";
import { Document, Attachment } from "../models";
import { sequelize } from "../sequelize";
import parseAttachmentIds from "../utils/parseAttachmentIds";
const log = debug("commands");
export async function documentPermanentDeleter(documents: Document[]) {
const activeDocument = documents.find((doc) => !doc.deletedAt);
if (activeDocument) {
throw new Error(
`Cannot permanently delete ${activeDocument.id} document. Please delete it and try again.`
);
}
const query = `
SELECT COUNT(id)
FROM documents
WHERE "searchVector" @@ to_tsquery('english', :query) AND
"teamId" = :teamId AND
"id" != :documentId
`;
for (const document of documents) {
const attachmentIds = parseAttachmentIds(document.text);
for (const attachmentId of attachmentIds) {
const [{ count }] = await sequelize.query(query, {
type: sequelize.QueryTypes.SELECT,
replacements: {
documentId: document.id,
teamId: document.teamId,
query: attachmentId,
},
});
if (parseInt(count) === 0) {
const attachment = await Attachment.findOne({
where: {
teamId: document.teamId,
id: attachmentId,
},
});
if (attachment) {
await attachment.destroy();
log(`Attachment ${attachmentId} deleted`);
} else {
log(`Unknown attachment ${attachmentId} ignored`);
}
}
}
}
return Document.scope("withUnpublished").destroy({
where: {
id: documents.map((document) => document.id),
},
force: true,
});
}

View File

@ -0,0 +1,123 @@
// @flow
import { subDays } from "date-fns";
import { Attachment, Document } from "../models";
import { buildAttachment, buildDocument } from "../test/factories";
import { flushdb } from "../test/support";
import { documentPermanentDeleter } from "./documentPermanentDeleter";
jest.mock("aws-sdk", () => {
const mS3 = { deleteObject: jest.fn().mockReturnThis(), promise: jest.fn() };
return {
S3: jest.fn(() => mS3),
Endpoint: jest.fn(),
};
});
beforeEach(() => flushdb());
describe("documentPermanentDeleter", () => {
it("should destroy documents", async () => {
const document = await buildDocument({
publishedAt: subDays(new Date(), 90),
deletedAt: new Date(),
});
const countDeletedDoc = await documentPermanentDeleter([document]);
expect(countDeletedDoc).toEqual(1);
expect(await Document.unscoped().count({ paranoid: false })).toEqual(0);
});
it("should error when trying to destroy undeleted documents", async () => {
const document = await buildDocument({
publishedAt: new Date(),
});
let error;
try {
await documentPermanentDeleter([document]);
} catch (err) {
error = err.message;
}
expect(error).toEqual(
`Cannot permanently delete ${document.id} document. Please delete it and try again.`
);
});
it("should destroy attachments no longer referenced", async () => {
const document = await buildDocument({
publishedAt: subDays(new Date(), 90),
deletedAt: new Date(),
});
const attachment = await buildAttachment({
teamId: document.teamId,
documentId: document.id,
});
document.text = `![text](${attachment.redirectUrl})`;
await document.save();
const countDeletedDoc = await documentPermanentDeleter([document]);
expect(countDeletedDoc).toEqual(1);
expect(await Attachment.count()).toEqual(0);
expect(await Document.unscoped().count({ paranoid: false })).toEqual(0);
});
it("should handle unknown attachment ids", async () => {
const document = await buildDocument({
publishedAt: subDays(new Date(), 90),
deletedAt: new Date(),
});
const attachment = await buildAttachment({
teamId: document.teamId,
documentId: document.id,
});
document.text = `![text](${attachment.redirectUrl})`;
await document.save();
// remove attachment so it no longer exists in the database, this is also
// representative of a corrupt attachment id in the doc or the regex returning
// an incorrect string
await attachment.destroy({ force: true });
const countDeletedDoc = await documentPermanentDeleter([document]);
expect(countDeletedDoc).toEqual(1);
expect(await Attachment.count()).toEqual(0);
expect(await Document.unscoped().count({ paranoid: false })).toEqual(0);
});
it("should not destroy attachments referenced in other documents", async () => {
const document1 = await buildDocument();
const document = await buildDocument({
teamId: document1.teamId,
publishedAt: subDays(new Date(), 90),
deletedAt: subDays(new Date(), 60),
});
const attachment = await buildAttachment({
teamId: document1.teamId,
documentId: document.id,
});
document1.text = `![text](${attachment.redirectUrl})`;
await document1.save();
document.text = `![text](${attachment.redirectUrl})`;
await document.save();
expect(await Attachment.count()).toEqual(1);
const countDeletedDoc = await documentPermanentDeleter([document]);
expect(countDeletedDoc).toEqual(1);
expect(await Attachment.count()).toEqual(1);
expect(await Document.unscoped().count({ paranoid: false })).toEqual(1);
});
});

View File

@ -35,6 +35,7 @@ export type DocumentEvent =
name: | "documents.create" // eslint-disable-line name: | "documents.create" // eslint-disable-line
| "documents.publish" | "documents.publish"
| "documents.delete" | "documents.delete"
| "documents.permanent_delete"
| "documents.pin" | "documents.pin"
| "documents.unpin" | "documents.unpin"
| "documents.archive" | "documents.archive"

View File

@ -65,6 +65,7 @@ Event.ACTIVITY_EVENTS = [
"documents.unpin", "documents.unpin",
"documents.move", "documents.move",
"documents.delete", "documents.delete",
"documents.permanent_delete",
"documents.restore", "documents.restore",
"users.create", "users.create",
]; ];
@ -90,6 +91,7 @@ Event.AUDIT_EVENTS = [
"documents.unpin", "documents.unpin",
"documents.move", "documents.move",
"documents.delete", "documents.delete",
"documents.permanent_delete",
"documents.restore", "documents.restore",
"groups.create", "groups.create",
"groups.update", "groups.update",

View File

@ -101,8 +101,15 @@ allow(User, ["pin", "unpin"], Document, (user, document) => {
}); });
allow(User, "delete", Document, (user, document) => { allow(User, "delete", Document, (user, document) => {
// unpublished drafts can always be deleted
if (user.isViewer) return false; if (user.isViewer) return false;
if (document.deletedAt) return false;
// allow deleting document without a collection
if (document.collection && cannot(user, "update", document.collection)) {
return false;
}
// unpublished drafts can always be deleted
if ( if (
!document.deletedAt && !document.deletedAt &&
!document.publishedAt && !document.publishedAt &&
@ -111,13 +118,18 @@ allow(User, "delete", Document, (user, document) => {
return true; return true;
} }
return user.teamId === document.teamId;
});
allow(User, "permanentDelete", Document, (user, document) => {
if (user.isViewer) return false;
if (!document.deletedAt) return false;
// allow deleting document without a collection // allow deleting document without a collection
if (document.collection && cannot(user, "update", document.collection)) { if (document.collection && cannot(user, "update", document.collection)) {
return false; return false;
} }
if (document.deletedAt) return false;
return user.teamId === document.teamId; return user.teamId === document.teamId;
}); });

View File

@ -79,6 +79,13 @@ export default class Websockets {
], ],
}); });
} }
case "documents.permanent_delete": {
return socketio
.to(`collection-${event.collectionId}`)
.emit(event.name, {
documentId: event.documentId,
});
}
case "documents.pin": case "documents.pin":
case "documents.unpin": case "documents.unpin":
case "documents.update": { case "documents.update": {

View File

@ -181,12 +181,14 @@
"Create template": "Create template", "Create template": "Create template",
"Duplicate": "Duplicate", "Duplicate": "Duplicate",
"Unpublish": "Unpublish", "Unpublish": "Unpublish",
"Permanently delete": "Permanently delete",
"Move": "Move", "Move": "Move",
"History": "History", "History": "History",
"Download": "Download", "Download": "Download",
"Print": "Print", "Print": "Print",
"Move {{ documentName }}": "Move {{ documentName }}", "Move {{ documentName }}": "Move {{ documentName }}",
"Delete {{ documentName }}": "Delete {{ documentName }}", "Delete {{ documentName }}": "Delete {{ documentName }}",
"Permanently delete {{ documentName }}": "Permanently delete {{ documentName }}",
"Edit group": "Edit group", "Edit group": "Edit group",
"Delete group": "Delete group", "Delete group": "Delete group",
"Group options": "Group options", "Group options": "Group options",
@ -312,6 +314,8 @@
"Im sure  Delete": "Im sure  Delete", "Im sure  Delete": "Im sure  Delete",
"Archiving": "Archiving", "Archiving": "Archiving",
"Couldnt create the document, try again?": "Couldnt create the document, try again?", "Couldnt create the document, try again?": "Couldnt create the document, try again?",
"Document permanently deleted": "Document permanently deleted",
"Are you sure you want to permanently delete the <em>{{ documentTitle }}</em> document? This action is immediate and cannot be undone.": "Are you sure you want to permanently delete the <em>{{ documentTitle }}</em> document? This action is immediate and cannot be undone.",
"Search documents": "Search documents", "Search documents": "Search documents",
"No documents found for your filters.": "No documents found for your filters.", "No documents found for your filters.": "No documents found for your filters.",
"Youve not got any drafts at the moment.": "Youve not got any drafts at the moment.", "Youve not got any drafts at the moment.": "Youve not got any drafts at the moment.",