Allow setting permission of collections during import (#6799)

* Allow setting permission of collections during import

closes #6767

* Remove unused column
This commit is contained in:
Tom Moor
2024-04-15 20:13:12 -06:00
committed by GitHub
parent 3315db449f
commit 54c6abbba9
17 changed files with 186 additions and 122 deletions

View File

@@ -6,9 +6,13 @@ import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import styled from "styled-components"; import styled from "styled-components";
import { s } from "@shared/styles"; 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 Flex from "~/components/Flex";
import InputSelectPermission from "~/components/InputSelectPermission";
import LoadingIndicator from "~/components/LoadingIndicator"; import LoadingIndicator from "~/components/LoadingIndicator";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import { uploadFile } from "~/utils/files"; import { uploadFile } from "~/utils/files";
@@ -23,38 +27,43 @@ type Props = {
function DropToImport({ disabled, onSubmit, children, format }: Props) { function DropToImport({ disabled, onSubmit, children, format }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const { collections } = useStores(); const { collections } = useStores();
const [file, setFile] = React.useState<File | null>(null);
const [isImporting, setImporting] = React.useState(false); const [isImporting, setImporting] = React.useState(false);
const [permission, setPermission] =
React.useState<CollectionPermission | null>(CollectionPermission.ReadWrite);
const handleFiles = React.useCallback( const handleFiles = (files: File[]) => {
async (files) => { if (files.length > 1) {
if (files.length > 1) { toast.error(t("Please choose a single file to import"));
toast.error(t("Please choose a single file to import")); return;
return; }
} setFile(files[0]);
const file = files[0]; };
setImporting(true); const handleStartImport = async () => {
if (!file) {
return;
}
setImporting(true);
try { try {
const attachment = await uploadFile(file, { const attachment = await uploadFile(file, {
name: file.name, name: file.name,
preset: AttachmentPreset.WorkspaceImport, preset: AttachmentPreset.WorkspaceImport,
}); });
await collections.import(attachment.id, format); await collections.import(attachment.id, { format, permission });
onSubmit(); onSubmit();
toast.message(file.name, { toast.message(file.name, {
description: t( description: t(
"Your import is being processed, you can safely leave this page" "Your import is being processed, you can safely leave this page"
), ),
}); });
} catch (err) { } catch (err) {
toast.error(err.message); toast.error(err.message);
} finally { } finally {
setImporting(false); setImporting(false);
} }
}, };
[t, onSubmit, collections, format]
);
const handleRejection = React.useCallback(() => { const handleRejection = React.useCallback(() => {
toast.error(t("File not supported please upload a valid ZIP file")); toast.error(t("File not supported please upload a valid ZIP file"));
@@ -65,30 +74,53 @@ function DropToImport({ disabled, onSubmit, children, format }: Props) {
} }
return ( return (
<> <Flex gap={8} column>
{isImporting && <LoadingIndicator />} {isImporting && <LoadingIndicator />}
<Dropzone <Text as="p" type="secondary">
accept="application/zip, application/x-zip-compressed" <Dropzone
onDropAccepted={handleFiles} accept="application/zip, application/x-zip-compressed"
onDropRejected={handleRejection} onDropAccepted={handleFiles}
disabled={isImporting} onDropRejected={handleRejection}
> disabled={isImporting}
{({ getRootProps, getInputProps, isDragActive }) => ( >
<DropzoneContainer {({ getRootProps, getInputProps, isDragActive }) => (
{...getRootProps()} <DropzoneContainer
$disabled={isImporting} {...getRootProps()}
$isDragActive={isDragActive} $disabled={isImporting}
tabIndex={-1} $isDragActive={isDragActive}
> tabIndex={-1}
<input {...getInputProps()} /> >
<Flex align="center" gap={4} column> <input {...getInputProps()} />
<Icon size={32} color="#fff" /> <Flex align="center" gap={4} column>
{children} <Icon size={32} color="#fff" />
</Flex> {file
</DropzoneContainer> ? t(`${file.name} (${bytesToHumanReadable(file.size)})`)
)} : children}
</Dropzone> </Flex>
</> </DropzoneContainer>
)}
</Dropzone>
</Text>
<div>
<InputSelectPermission
value={permission}
onChange={(value: CollectionPermission) => {
setPermission(value);
}}
/>
<Text as="span" type="secondary">
{t(
"Set the default permission level for collections created from the import"
)}
.
</Text>
</div>
<Flex justify="flex-end">
<Button disabled={!file} onClick={handleStartImport}>
{t("Start import")}
</Button>
</Flex>
</Flex>
); );
} }

View File

@@ -19,29 +19,33 @@ const HelpDisclosure: React.FC<Props> = ({ title, children }: Props) => {
const theme = useTheme(); const theme = useTheme();
return ( return (
<div> <>
<Disclosure {...disclosure}> <Disclosure {...disclosure}>
{(props) => ( {(props) => (
<Button <StyledButton
icon={<QuestionMarkIcon color={theme.text} />} icon={<QuestionMarkIcon color={theme.textSecondary} />}
neutral neutral
aria-label={title}
borderOnHover borderOnHover
{...props} {...props}
> />
{title}
</Button>
)} )}
</Disclosure> </Disclosure>
<HelpContent {...disclosure}> <HelpContent {...disclosure}>
<Text as="p" type="secondary"> <Text as="p" type="secondary">
<br />
{children} {children}
</Text> </Text>
</HelpContent> </HelpContent>
</div> </>
); );
}; };
const StyledButton = styled(Button)`
position: absolute;
top: 20px;
right: 50px;
`;
const HelpContent = styled(DisclosureContent)` const HelpContent = styled(DisclosureContent)`
transition: opacity 250ms ease-in-out; transition: opacity 250ms ease-in-out;
opacity: 0; opacity: 0;

View File

@@ -1,8 +1,6 @@
import * as React from "react"; import * as React from "react";
import { Trans } from "react-i18next"; import { Trans } from "react-i18next";
import { FileOperationFormat } from "@shared/types"; import { FileOperationFormat } from "@shared/types";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
import env from "~/env"; import env from "~/env";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import DropToImport from "./DropToImport"; import DropToImport from "./DropToImport";
@@ -13,18 +11,7 @@ function ImportJSONDialog() {
const appName = env.APP_NAME; const appName = env.APP_NAME;
return ( return (
<Flex column> <>
<Text as="p" type="secondary">
<DropToImport
onSubmit={dialogs.closeAllModals}
format={FileOperationFormat.JSON}
>
<Trans>
Drag and drop the zip file from the JSON export option in{" "}
{{ appName }}, or click to upload
</Trans>
</DropToImport>
</Text>
<HelpDisclosure title={<Trans>How does this work?</Trans>}> <HelpDisclosure title={<Trans>How does this work?</Trans>}>
<Trans <Trans
defaults="You can import a zip file that was previously exported from the JSON option in another instance. In {{ appName }}, open <em>Export</em> in the Settings sidebar and click on <em>Export Data</em>." defaults="You can import a zip file that was previously exported from the JSON option in another instance. In {{ appName }}, open <em>Export</em> in the Settings sidebar and click on <em>Export Data</em>."
@@ -34,7 +21,16 @@ function ImportJSONDialog() {
}} }}
/> />
</HelpDisclosure> </HelpDisclosure>
</Flex> <DropToImport
onSubmit={dialogs.closeAllModals}
format={FileOperationFormat.JSON}
>
<Trans>
Drag and drop the zip file from the JSON export option in{" "}
{{ appName }}, or click to upload
</Trans>
</DropToImport>
</>
); );
} }

View File

@@ -1,8 +1,6 @@
import * as React from "react"; import * as React from "react";
import { Trans } from "react-i18next"; import { Trans } from "react-i18next";
import { FileOperationFormat } from "@shared/types"; import { FileOperationFormat } from "@shared/types";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
import env from "~/env"; import env from "~/env";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import DropToImport from "./DropToImport"; import DropToImport from "./DropToImport";
@@ -13,18 +11,7 @@ function ImportMarkdownDialog() {
const appName = env.APP_NAME; const appName = env.APP_NAME;
return ( return (
<Flex column> <>
<Text as="p" type="secondary">
<DropToImport
onSubmit={dialogs.closeAllModals}
format={FileOperationFormat.MarkdownZip}
>
<Trans>
Drag and drop the zip file from the Markdown export option in{" "}
{{ appName }}, or click to upload
</Trans>
</DropToImport>
</Text>
<HelpDisclosure title={<Trans>How does this work?</Trans>}> <HelpDisclosure title={<Trans>How does this work?</Trans>}>
<Trans <Trans
defaults="You can import a zip file that was previously exported from an Outline installation collections, documents, and images will be imported. In Outline, open <em>Export</em> in the Settings sidebar and click on <em>Export Data</em>." defaults="You can import a zip file that was previously exported from an Outline installation collections, documents, and images will be imported. In Outline, open <em>Export</em> in the Settings sidebar and click on <em>Export Data</em>."
@@ -33,7 +20,16 @@ function ImportMarkdownDialog() {
}} }}
/> />
</HelpDisclosure> </HelpDisclosure>
</Flex> <DropToImport
onSubmit={dialogs.closeAllModals}
format={FileOperationFormat.MarkdownZip}
>
<Trans>
Drag and drop the zip file from the Markdown export option in{" "}
{{ appName }}, or click to upload
</Trans>
</DropToImport>
</>
); );
} }

View File

@@ -1,8 +1,6 @@
import * as React from "react"; import * as React from "react";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { FileOperationFormat } from "@shared/types"; import { FileOperationFormat } from "@shared/types";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import DropToImport from "./DropToImport"; import DropToImport from "./DropToImport";
import HelpDisclosure from "./HelpDisclosure"; import HelpDisclosure from "./HelpDisclosure";
@@ -12,19 +10,7 @@ function ImportNotionDialog() {
const { dialogs } = useStores(); const { dialogs } = useStores();
return ( return (
<Flex column> <>
<Text as="p" type="secondary">
<DropToImport
onSubmit={dialogs.closeAllModals}
format={FileOperationFormat.Notion}
>
<>
{t(
`Drag and drop the zip file from Notion's HTML export option, or click to upload`
)}
</>
</DropToImport>
</Text>
<HelpDisclosure title={<Trans>Where do I find the file?</Trans>}> <HelpDisclosure title={<Trans>Where do I find the file?</Trans>}>
<Trans <Trans
defaults="In Notion, click <em>Settings & Members</em> in the left sidebar and open Settings. Look for the Export section, and click <em>Export all workspace content</em>. Choose <em>HTML</em> as the format for the best data compatability." defaults="In Notion, click <em>Settings & Members</em> in the left sidebar and open Settings. Look for the Export section, and click <em>Export all workspace content</em>. Choose <em>HTML</em> as the format for the best data compatability."
@@ -33,7 +19,17 @@ function ImportNotionDialog() {
}} }}
/> />
</HelpDisclosure> </HelpDisclosure>
</Flex> <DropToImport
onSubmit={dialogs.closeAllModals}
format={FileOperationFormat.Notion}
>
<>
{t(
`Drag and drop the zip file from Notion's HTML export option, or click to upload`
)}
</>
</DropToImport>
</>
); );
} }

View File

@@ -144,11 +144,13 @@ export default class CollectionsStore extends Store<Collection> {
} }
@action @action
import = async (attachmentId: string, format?: string) => { import = async (
attachmentId: string,
options: { format?: string; permission?: CollectionPermission | null }
) => {
await client.post("/collections.import", { await client.post("/collections.import", {
type: "outline",
format,
attachmentId, attachmentId,
...options,
}); });
}; };

View File

@@ -53,9 +53,11 @@ async function collectionExporter({
url: null, url: null,
size: 0, size: 0,
collectionId, collectionId,
includeAttachments,
userId: user.id, userId: user.id,
teamId: user.teamId, teamId: user.teamId,
options: {
includeAttachments,
},
}, },
{ {
transaction, transaction,

View File

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

View File

@@ -14,6 +14,7 @@ import {
DataType, DataType,
} from "sequelize-typescript"; } from "sequelize-typescript";
import { import {
CollectionPermission,
FileOperationFormat, FileOperationFormat,
FileOperationState, FileOperationState,
FileOperationType, FileOperationType,
@@ -25,6 +26,11 @@ import User from "./User";
import ParanoidModel from "./base/ParanoidModel"; import ParanoidModel from "./base/ParanoidModel";
import Fix from "./decorators/Fix"; import Fix from "./decorators/Fix";
export type FileOperationOptions = {
includeAttachments?: boolean;
permission?: CollectionPermission | null;
};
@DefaultScope(() => ({ @DefaultScope(() => ({
include: [ include: [
{ {
@@ -66,8 +72,11 @@ class FileOperation extends ParanoidModel<
@Column(DataType.BIGINT) @Column(DataType.BIGINT)
size: number; 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. * Mark the current file operation as expired and remove the file from storage.

View File

@@ -11,7 +11,7 @@ export default class ExportHTMLZipTask extends ExportDocumentTreeTask {
zip, zip,
collections, collections,
FileOperationFormat.HTMLZip, FileOperationFormat.HTMLZip,
fileOperation.includeAttachments fileOperation.options?.includeAttachments ?? true
); );
} }
} }

View File

@@ -28,7 +28,7 @@ export default class ExportJSONTask extends ExportTask {
await this.addCollectionToArchive( await this.addCollectionToArchive(
zip, zip,
collection, collection,
fileOperation.includeAttachments fileOperation.options?.includeAttachments ?? true
); );
} }

View File

@@ -11,7 +11,7 @@ export default class ExportMarkdownZipTask extends ExportDocumentTreeTask {
zip, zip,
collections, collections,
FileOperationFormat.MarkdownZip, FileOperationFormat.MarkdownZip,
fileOperation.includeAttachments fileOperation.options?.includeAttachments
); );
} }
} }

View File

@@ -59,7 +59,7 @@ export default abstract class ExportTask extends BaseTask<Props> {
); );
if ( if (
fileOperation.includeAttachments && fileOperation.options?.includeAttachments &&
env.MAXIMUM_EXPORT_SIZE && env.MAXIMUM_EXPORT_SIZE &&
totalAttachmentsSize > env.MAXIMUM_EXPORT_SIZE totalAttachmentsSize > env.MAXIMUM_EXPORT_SIZE
) { ) {
@@ -74,7 +74,7 @@ export default abstract class ExportTask extends BaseTask<Props> {
} }
Logger.info("task", `ExportTask processing data for ${fileOperationId}`, { Logger.info("task", `ExportTask processing data for ${fileOperationId}`, {
includeAttachments: fileOperation.includeAttachments, options: fileOperation.options,
}); });
await this.updateFileOperation(fileOperation, { await this.updateFileOperation(fileOperation, {

View File

@@ -391,7 +391,11 @@ export default abstract class ImportTask extends BaseTask<Props> {
teamId: fileOperation.teamId, teamId: fileOperation.teamId,
createdById: fileOperation.userId, createdById: fileOperation.userId,
name, name,
permission: item.permission ?? CollectionPermission.ReadWrite, permission:
item.permission ??
fileOperation.options?.permission !== undefined
? fileOperation.options?.permission
: CollectionPermission.ReadWrite,
importId: fileOperation.id, importId: fileOperation.id,
}, },
{ transaction } { transaction }

View File

@@ -160,7 +160,7 @@ router.post(
transaction(), transaction(),
async (ctx: APIContext<T.CollectionsImportReq>) => { async (ctx: APIContext<T.CollectionsImportReq>) => {
const { transaction } = ctx.state; const { transaction } = ctx.state;
const { attachmentId, format } = ctx.input.body; const { attachmentId, permission, format } = ctx.input.body;
const { user } = ctx.state.auth; const { user } = ctx.state.auth;
authorize(user, "importCollection", user.team); authorize(user, "importCollection", user.team);
@@ -179,6 +179,9 @@ router.post(
key: attachment.key, key: attachment.key,
userId: user.id, userId: user.id,
teamId: user.teamId, teamId: user.teamId,
options: {
permission,
},
}, },
{ {
transaction, transaction,

View File

@@ -68,6 +68,10 @@ export type CollectionsDocumentsReq = z.infer<
export const CollectionsImportSchema = BaseSchema.extend({ export const CollectionsImportSchema = BaseSchema.extend({
body: z.object({ body: z.object({
permission: z
.nativeEnum(CollectionPermission)
.nullish()
.transform((val) => (isUndefined(val) ? null : val)),
attachmentId: z.string().uuid(), attachmentId: z.string().uuid(),
format: z format: z
.nativeEnum(FileOperationFormat) .nativeEnum(FileOperationFormat)

View File

@@ -783,6 +783,8 @@
"Please choose a single file to import": "Please choose a single file to import", "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", "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", "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", "Processing": "Processing",
"Expired": "Expired", "Expired": "Expired",
"Completed": "Completed", "Completed": "Completed",
@@ -795,14 +797,14 @@
"Check server logs for more details.": "Check server logs for more details.", "Check server logs for more details.": "Check server logs for more details.",
"{{userName}} requested": "{{userName}} requested", "{{userName}} requested": "{{userName}} requested",
"Upload": "Upload", "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?", "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 <em>Export</em> in the Settings sidebar and click on <em>Export Data</em>.": "You can import a zip file that was previously exported from the JSON option in another instance. In {{ appName }}, open <em>Export</em> in the Settings sidebar and click on <em>Export Data</em>.", "You can import a zip file that was previously exported from the JSON option in another instance. In {{ appName }}, open <em>Export</em> in the Settings sidebar and click on <em>Export Data</em>.": "You can import a zip file that was previously exported from the JSON option in another instance. In {{ appName }}, open <em>Export</em> in the Settings sidebar and click on <em>Export Data</em>.",
"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 <em>Export</em> in the Settings sidebar and click on <em>Export Data</em>.": "You can import a zip file that was previously exported from an Outline installation collections, documents, and images will be imported. In Outline, open <em>Export</em> in the Settings sidebar and click on <em>Export Data</em>.", "You can import a zip file that was previously exported from an Outline installation collections, documents, and images will be imported. In Outline, open <em>Export</em> in the Settings sidebar and click on <em>Export Data</em>.": "You can import a zip file that was previously exported from an Outline installation collections, documents, and images will be imported. In Outline, open <em>Export</em> in the Settings sidebar and click on <em>Export Data</em>.",
"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?", "Where do I find the file?": "Where do I find the file?",
"In Notion, click <em>Settings & Members</em> in the left sidebar and open Settings. Look for the Export section, and click <em>Export all workspace content</em>. Choose <em>HTML</em> as the format for the best data compatability.": "In Notion, click <em>Settings & Members</em> in the left sidebar and open Settings. Look for the Export section, and click <em>Export all workspace content</em>. Choose <em>HTML</em> as the format for the best data compatability.", "In Notion, click <em>Settings & Members</em> in the left sidebar and open Settings. Look for the Export section, and click <em>Export all workspace content</em>. Choose <em>HTML</em> as the format for the best data compatability.": "In Notion, click <em>Settings & Members</em> in the left sidebar and open Settings. Look for the Export section, and click <em>Export all workspace content</em>. Choose <em>HTML</em> 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", "Last active": "Last active",
"Guest": "Guest", "Guest": "Guest",
"Shared": "Shared", "Shared": "Shared",