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 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<File | null>(null);
const [isImporting, setImporting] = React.useState(false);
const [permission, setPermission] =
React.useState<CollectionPermission | null>(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 (
<>
<Flex gap={8} column>
{isImporting && <LoadingIndicator />}
<Dropzone
accept="application/zip, application/x-zip-compressed"
onDropAccepted={handleFiles}
onDropRejected={handleRejection}
disabled={isImporting}
>
{({ getRootProps, getInputProps, isDragActive }) => (
<DropzoneContainer
{...getRootProps()}
$disabled={isImporting}
$isDragActive={isDragActive}
tabIndex={-1}
>
<input {...getInputProps()} />
<Flex align="center" gap={4} column>
<Icon size={32} color="#fff" />
{children}
</Flex>
</DropzoneContainer>
)}
</Dropzone>
</>
<Text as="p" type="secondary">
<Dropzone
accept="application/zip, application/x-zip-compressed"
onDropAccepted={handleFiles}
onDropRejected={handleRejection}
disabled={isImporting}
>
{({ getRootProps, getInputProps, isDragActive }) => (
<DropzoneContainer
{...getRootProps()}
$disabled={isImporting}
$isDragActive={isDragActive}
tabIndex={-1}
>
<input {...getInputProps()} />
<Flex align="center" gap={4} column>
<Icon size={32} color="#fff" />
{file
? t(`${file.name} (${bytesToHumanReadable(file.size)})`)
: children}
</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();
return (
<div>
<>
<Disclosure {...disclosure}>
{(props) => (
<Button
icon={<QuestionMarkIcon color={theme.text} />}
<StyledButton
icon={<QuestionMarkIcon color={theme.textSecondary} />}
neutral
aria-label={title}
borderOnHover
{...props}
>
{title}
</Button>
/>
)}
</Disclosure>
<HelpContent {...disclosure}>
<Text as="p" type="secondary">
<br />
{children}
</Text>
</HelpContent>
</div>
</>
);
};
const StyledButton = styled(Button)`
position: absolute;
top: 20px;
right: 50px;
`;
const HelpContent = styled(DisclosureContent)`
transition: opacity 250ms ease-in-out;
opacity: 0;

View File

@@ -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 (
<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>}>
<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>."
@@ -34,7 +21,16 @@ function ImportJSONDialog() {
}}
/>
</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 { 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 (
<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>}>
<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>."
@@ -33,7 +20,16 @@ function ImportMarkdownDialog() {
}}
/>
</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 { 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 (
<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>}>
<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."
@@ -33,7 +19,17 @@ function ImportNotionDialog() {
}}
/>
</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
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,
});
};

View File

@@ -53,9 +53,11 @@ async function collectionExporter({
url: null,
size: 0,
collectionId,
includeAttachments,
userId: user.id,
teamId: user.teamId,
options: {
includeAttachments,
},
},
{
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,
} 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.

View File

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

View File

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

View File

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

View File

@@ -59,7 +59,7 @@ export default abstract class ExportTask extends BaseTask<Props> {
);
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<Props> {
}
Logger.info("task", `ExportTask processing data for ${fileOperationId}`, {
includeAttachments: fileOperation.includeAttachments,
options: fileOperation.options,
});
await this.updateFileOperation(fileOperation, {

View File

@@ -391,7 +391,11 @@ export default abstract class ImportTask extends BaseTask<Props> {
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 }

View File

@@ -160,7 +160,7 @@ router.post(
transaction(),
async (ctx: APIContext<T.CollectionsImportReq>) => {
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,

View File

@@ -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)

View File

@@ -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 <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>.",
"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 <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",
"Guest": "Guest",
"Shared": "Shared",