From 54c6abbba9298138a139896bd29a0d9fb097713e Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Mon, 15 Apr 2024 20:13:12 -0600 Subject: [PATCH] Allow setting permission of collections during import (#6799) * Allow setting permission of collections during import closes #6767 * Remove unused column --- .../Settings/components/DropToImport.tsx | 136 +++++++++++------- .../Settings/components/HelpDisclosure.tsx | 20 +-- .../Settings/components/ImportJSONDialog.tsx | 26 ++-- .../components/ImportMarkdownDialog.tsx | 26 ++-- .../components/ImportNotionDialog.tsx | 28 ++-- app/stores/CollectionsStore.ts | 8 +- server/commands/collectionExporter.ts | 4 +- ...413010743-add-options-to-file-operation.js | 14 ++ server/models/FileOperation.ts | 13 +- server/queues/tasks/ExportHTMLZipTask.ts | 2 +- server/queues/tasks/ExportJSONTask.ts | 2 +- server/queues/tasks/ExportMarkdownZipTask.ts | 2 +- server/queues/tasks/ExportTask.ts | 4 +- server/queues/tasks/ImportTask.ts | 6 +- server/routes/api/collections/collections.ts | 5 +- server/routes/api/collections/schema.ts | 4 + shared/i18n/locales/en_US/translation.json | 8 +- 17 files changed, 186 insertions(+), 122 deletions(-) create mode 100644 server/migrations/20240413010743-add-options-to-file-operation.js diff --git a/app/scenes/Settings/components/DropToImport.tsx b/app/scenes/Settings/components/DropToImport.tsx index b0663edb4..2b20b785c 100644 --- a/app/scenes/Settings/components/DropToImport.tsx +++ b/app/scenes/Settings/components/DropToImport.tsx @@ -6,9 +6,13 @@ import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import styled from "styled-components"; import { s } from "@shared/styles"; -import { AttachmentPreset } from "@shared/types"; +import { AttachmentPreset, CollectionPermission } from "@shared/types"; +import { bytesToHumanReadable } from "@shared/utils/files"; +import Button from "~/components/Button"; import Flex from "~/components/Flex"; +import InputSelectPermission from "~/components/InputSelectPermission"; import LoadingIndicator from "~/components/LoadingIndicator"; +import Text from "~/components/Text"; import useStores from "~/hooks/useStores"; import { uploadFile } from "~/utils/files"; @@ -23,38 +27,43 @@ type Props = { function DropToImport({ disabled, onSubmit, children, format }: Props) { const { t } = useTranslation(); const { collections } = useStores(); + const [file, setFile] = React.useState(null); const [isImporting, setImporting] = React.useState(false); + const [permission, setPermission] = + React.useState(CollectionPermission.ReadWrite); - const handleFiles = React.useCallback( - async (files) => { - if (files.length > 1) { - toast.error(t("Please choose a single file to import")); - return; - } - const file = files[0]; + const handleFiles = (files: File[]) => { + if (files.length > 1) { + toast.error(t("Please choose a single file to import")); + return; + } + setFile(files[0]); + }; - setImporting(true); + const handleStartImport = async () => { + if (!file) { + return; + } + setImporting(true); - try { - const attachment = await uploadFile(file, { - name: file.name, - preset: AttachmentPreset.WorkspaceImport, - }); - await collections.import(attachment.id, format); - onSubmit(); - toast.message(file.name, { - description: t( - "Your import is being processed, you can safely leave this page" - ), - }); - } catch (err) { - toast.error(err.message); - } finally { - setImporting(false); - } - }, - [t, onSubmit, collections, format] - ); + try { + const attachment = await uploadFile(file, { + name: file.name, + preset: AttachmentPreset.WorkspaceImport, + }); + await collections.import(attachment.id, { format, permission }); + onSubmit(); + toast.message(file.name, { + description: t( + "Your import is being processed, you can safely leave this page" + ), + }); + } catch (err) { + toast.error(err.message); + } finally { + setImporting(false); + } + }; const handleRejection = React.useCallback(() => { toast.error(t("File not supported – please upload a valid ZIP file")); @@ -65,30 +74,53 @@ function DropToImport({ disabled, onSubmit, children, format }: Props) { } return ( - <> + {isImporting && } - - {({ getRootProps, getInputProps, isDragActive }) => ( - - - - - {children} - - - )} - - + + + {({ getRootProps, getInputProps, isDragActive }) => ( + + + + + {file + ? t(`${file.name} (${bytesToHumanReadable(file.size)})`) + : children} + + + )} + + +
+ { + setPermission(value); + }} + /> + + {t( + "Set the default permission level for collections created from the import" + )} + . + +
+ + + +
); } diff --git a/app/scenes/Settings/components/HelpDisclosure.tsx b/app/scenes/Settings/components/HelpDisclosure.tsx index 7eabbcb87..c09ca02fa 100644 --- a/app/scenes/Settings/components/HelpDisclosure.tsx +++ b/app/scenes/Settings/components/HelpDisclosure.tsx @@ -19,29 +19,33 @@ const HelpDisclosure: React.FC = ({ title, children }: Props) => { const theme = useTheme(); return ( -
+ <> {(props) => ( - + /> )} -
{children}
-
+ ); }; +const StyledButton = styled(Button)` + position: absolute; + top: 20px; + right: 50px; +`; + const HelpContent = styled(DisclosureContent)` transition: opacity 250ms ease-in-out; opacity: 0; diff --git a/app/scenes/Settings/components/ImportJSONDialog.tsx b/app/scenes/Settings/components/ImportJSONDialog.tsx index 0974f95f1..9a13c3985 100644 --- a/app/scenes/Settings/components/ImportJSONDialog.tsx +++ b/app/scenes/Settings/components/ImportJSONDialog.tsx @@ -1,8 +1,6 @@ import * as React from "react"; import { Trans } from "react-i18next"; import { FileOperationFormat } from "@shared/types"; -import Flex from "~/components/Flex"; -import Text from "~/components/Text"; import env from "~/env"; import useStores from "~/hooks/useStores"; import DropToImport from "./DropToImport"; @@ -13,18 +11,7 @@ function ImportJSONDialog() { const appName = env.APP_NAME; return ( - - - - - Drag and drop the zip file from the JSON export option in{" "} - {{ appName }}, or click to upload - - - + <> How does this work?}> - + + + Drag and drop the zip file from the JSON export option in{" "} + {{ appName }}, or click to upload + + + ); } diff --git a/app/scenes/Settings/components/ImportMarkdownDialog.tsx b/app/scenes/Settings/components/ImportMarkdownDialog.tsx index a33d158c8..c23190a8c 100644 --- a/app/scenes/Settings/components/ImportMarkdownDialog.tsx +++ b/app/scenes/Settings/components/ImportMarkdownDialog.tsx @@ -1,8 +1,6 @@ import * as React from "react"; import { Trans } from "react-i18next"; import { FileOperationFormat } from "@shared/types"; -import Flex from "~/components/Flex"; -import Text from "~/components/Text"; import env from "~/env"; import useStores from "~/hooks/useStores"; import DropToImport from "./DropToImport"; @@ -13,18 +11,7 @@ function ImportMarkdownDialog() { const appName = env.APP_NAME; return ( - - - - - Drag and drop the zip file from the Markdown export option in{" "} - {{ appName }}, or click to upload - - - + <> How does this work?}> - + + + Drag and drop the zip file from the Markdown export option in{" "} + {{ appName }}, or click to upload + + + ); } diff --git a/app/scenes/Settings/components/ImportNotionDialog.tsx b/app/scenes/Settings/components/ImportNotionDialog.tsx index ea186ae99..b0bc119f6 100644 --- a/app/scenes/Settings/components/ImportNotionDialog.tsx +++ b/app/scenes/Settings/components/ImportNotionDialog.tsx @@ -1,8 +1,6 @@ import * as React from "react"; import { Trans, useTranslation } from "react-i18next"; import { FileOperationFormat } from "@shared/types"; -import Flex from "~/components/Flex"; -import Text from "~/components/Text"; import useStores from "~/hooks/useStores"; import DropToImport from "./DropToImport"; import HelpDisclosure from "./HelpDisclosure"; @@ -12,19 +10,7 @@ function ImportNotionDialog() { const { dialogs } = useStores(); return ( - - - - <> - {t( - `Drag and drop the zip file from Notion's HTML export option, or click to upload` - )} - - - + <> Where do I find the file?}> - + + <> + {t( + `Drag and drop the zip file from Notion's HTML export option, or click to upload` + )} + + + ); } diff --git a/app/stores/CollectionsStore.ts b/app/stores/CollectionsStore.ts index 813814806..b6297695a 100644 --- a/app/stores/CollectionsStore.ts +++ b/app/stores/CollectionsStore.ts @@ -144,11 +144,13 @@ export default class CollectionsStore extends Store { } @action - import = async (attachmentId: string, format?: string) => { + import = async ( + attachmentId: string, + options: { format?: string; permission?: CollectionPermission | null } + ) => { await client.post("/collections.import", { - type: "outline", - format, attachmentId, + ...options, }); }; diff --git a/server/commands/collectionExporter.ts b/server/commands/collectionExporter.ts index e709575c7..9e279bd00 100644 --- a/server/commands/collectionExporter.ts +++ b/server/commands/collectionExporter.ts @@ -53,9 +53,11 @@ async function collectionExporter({ url: null, size: 0, collectionId, - includeAttachments, userId: user.id, teamId: user.teamId, + options: { + includeAttachments, + }, }, { transaction, diff --git a/server/migrations/20240413010743-add-options-to-file-operation.js b/server/migrations/20240413010743-add-options-to-file-operation.js new file mode 100644 index 000000000..1849b3283 --- /dev/null +++ b/server/migrations/20240413010743-add-options-to-file-operation.js @@ -0,0 +1,14 @@ +"use strict"; + +module.exports = { + up: async (queryInterface, Sequelize) => { + return queryInterface.addColumn("file_operations", "options", { + type: Sequelize.JSONB, + allowNull: true, + }); + }, + + down: async (queryInterface) => { + return queryInterface.removeColumn("file_operations", "options"); + }, +}; diff --git a/server/models/FileOperation.ts b/server/models/FileOperation.ts index 32c821419..4c511b8c4 100644 --- a/server/models/FileOperation.ts +++ b/server/models/FileOperation.ts @@ -14,6 +14,7 @@ import { DataType, } from "sequelize-typescript"; import { + CollectionPermission, FileOperationFormat, FileOperationState, FileOperationType, @@ -25,6 +26,11 @@ import User from "./User"; import ParanoidModel from "./base/ParanoidModel"; import Fix from "./decorators/Fix"; +export type FileOperationOptions = { + includeAttachments?: boolean; + permission?: CollectionPermission | null; +}; + @DefaultScope(() => ({ include: [ { @@ -66,8 +72,11 @@ class FileOperation extends ParanoidModel< @Column(DataType.BIGINT) size: number; - @Column(DataType.BOOLEAN) - includeAttachments: boolean; + /** + * Additional configuration options for the file operation. + */ + @Column(DataType.JSON) + options: FileOperationOptions | null; /** * Mark the current file operation as expired and remove the file from storage. diff --git a/server/queues/tasks/ExportHTMLZipTask.ts b/server/queues/tasks/ExportHTMLZipTask.ts index 2d16cf597..733806fae 100644 --- a/server/queues/tasks/ExportHTMLZipTask.ts +++ b/server/queues/tasks/ExportHTMLZipTask.ts @@ -11,7 +11,7 @@ export default class ExportHTMLZipTask extends ExportDocumentTreeTask { zip, collections, FileOperationFormat.HTMLZip, - fileOperation.includeAttachments + fileOperation.options?.includeAttachments ?? true ); } } diff --git a/server/queues/tasks/ExportJSONTask.ts b/server/queues/tasks/ExportJSONTask.ts index 4b018c10d..91112dbc2 100644 --- a/server/queues/tasks/ExportJSONTask.ts +++ b/server/queues/tasks/ExportJSONTask.ts @@ -28,7 +28,7 @@ export default class ExportJSONTask extends ExportTask { await this.addCollectionToArchive( zip, collection, - fileOperation.includeAttachments + fileOperation.options?.includeAttachments ?? true ); } diff --git a/server/queues/tasks/ExportMarkdownZipTask.ts b/server/queues/tasks/ExportMarkdownZipTask.ts index 8da8b5530..b931f7faa 100644 --- a/server/queues/tasks/ExportMarkdownZipTask.ts +++ b/server/queues/tasks/ExportMarkdownZipTask.ts @@ -11,7 +11,7 @@ export default class ExportMarkdownZipTask extends ExportDocumentTreeTask { zip, collections, FileOperationFormat.MarkdownZip, - fileOperation.includeAttachments + fileOperation.options?.includeAttachments ); } } diff --git a/server/queues/tasks/ExportTask.ts b/server/queues/tasks/ExportTask.ts index 58290f221..08988b255 100644 --- a/server/queues/tasks/ExportTask.ts +++ b/server/queues/tasks/ExportTask.ts @@ -59,7 +59,7 @@ export default abstract class ExportTask extends BaseTask { ); if ( - fileOperation.includeAttachments && + fileOperation.options?.includeAttachments && env.MAXIMUM_EXPORT_SIZE && totalAttachmentsSize > env.MAXIMUM_EXPORT_SIZE ) { @@ -74,7 +74,7 @@ export default abstract class ExportTask extends BaseTask { } Logger.info("task", `ExportTask processing data for ${fileOperationId}`, { - includeAttachments: fileOperation.includeAttachments, + options: fileOperation.options, }); await this.updateFileOperation(fileOperation, { diff --git a/server/queues/tasks/ImportTask.ts b/server/queues/tasks/ImportTask.ts index 13b6d3b88..a3695c661 100644 --- a/server/queues/tasks/ImportTask.ts +++ b/server/queues/tasks/ImportTask.ts @@ -391,7 +391,11 @@ export default abstract class ImportTask extends BaseTask { teamId: fileOperation.teamId, createdById: fileOperation.userId, name, - permission: item.permission ?? CollectionPermission.ReadWrite, + permission: + item.permission ?? + fileOperation.options?.permission !== undefined + ? fileOperation.options?.permission + : CollectionPermission.ReadWrite, importId: fileOperation.id, }, { transaction } diff --git a/server/routes/api/collections/collections.ts b/server/routes/api/collections/collections.ts index 9da61f774..7cebcbed6 100644 --- a/server/routes/api/collections/collections.ts +++ b/server/routes/api/collections/collections.ts @@ -160,7 +160,7 @@ router.post( transaction(), async (ctx: APIContext) => { const { transaction } = ctx.state; - const { attachmentId, format } = ctx.input.body; + const { attachmentId, permission, format } = ctx.input.body; const { user } = ctx.state.auth; authorize(user, "importCollection", user.team); @@ -179,6 +179,9 @@ router.post( key: attachment.key, userId: user.id, teamId: user.teamId, + options: { + permission, + }, }, { transaction, diff --git a/server/routes/api/collections/schema.ts b/server/routes/api/collections/schema.ts index 1a8f64de3..861c50bb3 100644 --- a/server/routes/api/collections/schema.ts +++ b/server/routes/api/collections/schema.ts @@ -68,6 +68,10 @@ export type CollectionsDocumentsReq = z.infer< export const CollectionsImportSchema = BaseSchema.extend({ body: z.object({ + permission: z + .nativeEnum(CollectionPermission) + .nullish() + .transform((val) => (isUndefined(val) ? null : val)), attachmentId: z.string().uuid(), format: z .nativeEnum(FileOperationFormat) diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 4f5142dc5..270c19115 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -783,6 +783,8 @@ "Please choose a single file to import": "Please choose a single file to import", "Your import is being processed, you can safely leave this page": "Your import is being processed, you can safely leave this page", "File not supported – please upload a valid ZIP file": "File not supported – please upload a valid ZIP file", + "Set the default permission level for collections created from the import": "Set the default permission level for collections created from the import", + "Start import": "Start import", "Processing": "Processing", "Expired": "Expired", "Completed": "Completed", @@ -795,14 +797,14 @@ "Check server logs for more details.": "Check server logs for more details.", "{{userName}} requested": "{{userName}} requested", "Upload": "Upload", - "Drag and drop the zip file from the JSON export option in {{appName}}, or click to upload": "Drag and drop the zip file from the JSON export option in {{appName}}, or click to upload", "How does this work?": "How does this work?", "You can import a zip file that was previously exported from the JSON option in another instance. In {{ appName }}, open Export in the Settings sidebar and click on Export Data.": "You can import a zip file that was previously exported from the JSON option in another instance. In {{ appName }}, open Export in the Settings sidebar and click on Export Data.", - "Drag and drop the zip file from the Markdown export option in {{appName}}, or click to upload": "Drag and drop the zip file from the Markdown export option in {{appName}}, or click to upload", + "Drag and drop the zip file from the JSON export option in {{appName}}, or click to upload": "Drag and drop the zip file from the JSON export option in {{appName}}, or click to upload", "You can import a zip file that was previously exported from an Outline installation – collections, documents, and images will be imported. In Outline, open Export in the Settings sidebar and click on Export Data.": "You can import a zip file that was previously exported from an Outline installation – collections, documents, and images will be imported. In Outline, open Export in the Settings sidebar and click on Export Data.", - "Drag and drop the zip file from Notion's HTML export option, or click to upload": "Drag and drop the zip file from Notion's HTML export option, or click to upload", + "Drag and drop the zip file from the Markdown export option in {{appName}}, or click to upload": "Drag and drop the zip file from the Markdown export option in {{appName}}, or click to upload", "Where do I find the file?": "Where do I find the file?", "In Notion, click Settings & Members in the left sidebar and open Settings. Look for the Export section, and click Export all workspace content. Choose HTML as the format for the best data compatability.": "In Notion, click Settings & Members in the left sidebar and open Settings. Look for the Export section, and click Export all workspace content. Choose HTML as the format for the best data compatability.", + "Drag and drop the zip file from Notion's HTML export option, or click to upload": "Drag and drop the zip file from Notion's HTML export option, or click to upload", "Last active": "Last active", "Guest": "Guest", "Shared": "Shared",