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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -53,9 +53,11 @@ async function collectionExporter({
|
||||
url: null,
|
||||
size: 0,
|
||||
collectionId,
|
||||
includeAttachments,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
options: {
|
||||
includeAttachments,
|
||||
},
|
||||
},
|
||||
{
|
||||
transaction,
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
@@ -11,7 +11,7 @@ export default class ExportHTMLZipTask extends ExportDocumentTreeTask {
|
||||
zip,
|
||||
collections,
|
||||
FileOperationFormat.HTMLZip,
|
||||
fileOperation.includeAttachments
|
||||
fileOperation.options?.includeAttachments ?? true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export default class ExportJSONTask extends ExportTask {
|
||||
await this.addCollectionToArchive(
|
||||
zip,
|
||||
collection,
|
||||
fileOperation.includeAttachments
|
||||
fileOperation.options?.includeAttachments ?? true
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ export default class ExportMarkdownZipTask extends ExportDocumentTreeTask {
|
||||
zip,
|
||||
collections,
|
||||
FileOperationFormat.MarkdownZip,
|
||||
fileOperation.includeAttachments
|
||||
fileOperation.options?.includeAttachments
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user