feat: Import improvements (#3064)
* feat: Split and simplify import/export pages in prep for more options * minor fixes * File operations for imports * test * icons
This commit is contained in:
@@ -61,6 +61,7 @@ function DocumentViews({ document, isOpen }: Props) {
|
|||||||
subtitle={subtitle}
|
subtitle={subtitle}
|
||||||
image={<Avatar key={item.id} src={item.avatarUrl} size={32} />}
|
image={<Avatar key={item.id} src={item.avatarUrl} size={32} />}
|
||||||
border={false}
|
border={false}
|
||||||
|
compact
|
||||||
small
|
small
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type Props = {
|
|||||||
title: React.ReactNode;
|
title: React.ReactNode;
|
||||||
subtitle?: React.ReactNode;
|
subtitle?: React.ReactNode;
|
||||||
actions?: React.ReactNode;
|
actions?: React.ReactNode;
|
||||||
|
compact?: boolean;
|
||||||
border?: boolean;
|
border?: boolean;
|
||||||
small?: boolean;
|
small?: boolean;
|
||||||
};
|
};
|
||||||
@@ -49,6 +50,7 @@ const ListItem = (
|
|||||||
<Wrapper
|
<Wrapper
|
||||||
ref={ref}
|
ref={ref}
|
||||||
$border={border}
|
$border={border}
|
||||||
|
$compact={compact}
|
||||||
activeStyle={{
|
activeStyle={{
|
||||||
background: theme.primary,
|
background: theme.primary,
|
||||||
}}
|
}}
|
||||||
@@ -62,16 +64,16 @@ const ListItem = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Wrapper $border={border} {...rest}>
|
<Wrapper $compact={compact} $border={border} {...rest}>
|
||||||
{content(false)}
|
{content(false)}
|
||||||
</Wrapper>
|
</Wrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Wrapper = styled.div<{ $border?: boolean }>`
|
const Wrapper = styled.div<{ $compact?: boolean; $border?: boolean }>`
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: ${(props) => (props.$border === false ? 0 : "8px 0")};
|
margin: ${(props) => (props.$compact === false ? 0 : "8px 0")};
|
||||||
margin: ${(props) => (props.$border === false ? "8px 0" : 0)};
|
padding: ${(props) => (props.$compact === false ? "8px 0" : 0)};
|
||||||
border-bottom: 1px solid
|
border-bottom: 1px solid
|
||||||
${(props) =>
|
${(props) =>
|
||||||
props.$border === false ? "transparent" : props.theme.divider};
|
props.$border === false ? "transparent" : props.theme.divider};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import {
|
import {
|
||||||
DocumentIcon,
|
NewDocumentIcon,
|
||||||
EmailIcon,
|
EmailIcon,
|
||||||
ProfileIcon,
|
ProfileIcon,
|
||||||
PadlockIcon,
|
PadlockIcon,
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
TeamIcon,
|
TeamIcon,
|
||||||
ExpandedIcon,
|
ExpandedIcon,
|
||||||
BeakerIcon,
|
BeakerIcon,
|
||||||
|
DownloadIcon,
|
||||||
} from "outline-icons";
|
} from "outline-icons";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -118,11 +119,18 @@ function SettingsSidebar() {
|
|||||||
icon={<LinkIcon color="currentColor" />}
|
icon={<LinkIcon color="currentColor" />}
|
||||||
label={t("Share Links")}
|
label={t("Share Links")}
|
||||||
/>
|
/>
|
||||||
|
{can.manage && (
|
||||||
|
<SidebarLink
|
||||||
|
to="/settings/import"
|
||||||
|
icon={<NewDocumentIcon color="currentColor" />}
|
||||||
|
label={t("Import")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{can.export && (
|
{can.export && (
|
||||||
<SidebarLink
|
<SidebarLink
|
||||||
to="/settings/import-export"
|
to="/settings/export"
|
||||||
icon={<DocumentIcon color="currentColor" />}
|
icon={<DownloadIcon color="currentColor" />}
|
||||||
label={`${t("Import")} / ${t("Export")}`}
|
label={t("Export")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|||||||
@@ -328,15 +328,17 @@ class SocketProvider extends React.Component<Props> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.socket.on("fileOperations.create", async (event: any) => {
|
||||||
|
const user = auth.user;
|
||||||
|
if (user) {
|
||||||
|
fileOperations.add({ ...event, user });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.socket.on("fileOperations.update", async (event: any) => {
|
this.socket.on("fileOperations.update", async (event: any) => {
|
||||||
const user = auth.user;
|
const user = auth.user;
|
||||||
let collection = null;
|
|
||||||
if (event.collectionId) {
|
|
||||||
collection = await collections.fetch(event.collectionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
fileOperations.add({ ...event, user, collection });
|
fileOperations.add({ ...event, user });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
59
app/components/Spinner.tsx
Normal file
59
app/components/Spinner.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import styled from "styled-components";
|
||||||
|
|
||||||
|
export default function Spinner(props: React.HTMLAttributes<HTMLOrSVGElement>) {
|
||||||
|
return (
|
||||||
|
<SVG
|
||||||
|
width="16px"
|
||||||
|
height="16px"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Circle
|
||||||
|
fill="none"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
cx="8"
|
||||||
|
cy="8"
|
||||||
|
r="6"
|
||||||
|
></Circle>
|
||||||
|
</SVG>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SVG = styled.svg`
|
||||||
|
@keyframes rotator {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(270deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
animation: rotator 1.4s linear infinite;
|
||||||
|
margin: 4px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Circle = styled.circle`
|
||||||
|
@keyframes dash {
|
||||||
|
0% {
|
||||||
|
stroke-dashoffset: 47;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
stroke-dashoffset: 11;
|
||||||
|
transform: rotate(135deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
stroke-dashoffset: 47;
|
||||||
|
transform: rotate(450deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stroke: ${(props) => props.theme.textSecondary};
|
||||||
|
stroke-dasharray: 46;
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
transform-origin: center;
|
||||||
|
animation: dash 1.4s ease-in-out infinite;
|
||||||
|
`;
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { computed } from "mobx";
|
import { computed } from "mobx";
|
||||||
import BaseModal from "./BaseModel";
|
import BaseModal from "./BaseModel";
|
||||||
import Collection from "./Collection";
|
|
||||||
import User from "./User";
|
import User from "./User";
|
||||||
|
|
||||||
class FileOperation extends BaseModal {
|
class FileOperation extends BaseModal {
|
||||||
@@ -8,11 +7,15 @@ class FileOperation extends BaseModal {
|
|||||||
|
|
||||||
state: string;
|
state: string;
|
||||||
|
|
||||||
collection: Collection | null | undefined;
|
name: string;
|
||||||
|
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
collectionId: string | null;
|
||||||
|
|
||||||
size: number;
|
size: number;
|
||||||
|
|
||||||
type: string;
|
type: "import" | "export";
|
||||||
|
|
||||||
user: User;
|
user: User;
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Switch, Redirect } from "react-router-dom";
|
import { Switch, Redirect } from "react-router-dom";
|
||||||
import Details from "~/scenes/Settings/Details";
|
import Details from "~/scenes/Settings/Details";
|
||||||
|
import Export from "~/scenes/Settings/Export";
|
||||||
import Features from "~/scenes/Settings/Features";
|
import Features from "~/scenes/Settings/Features";
|
||||||
import Groups from "~/scenes/Settings/Groups";
|
import Groups from "~/scenes/Settings/Groups";
|
||||||
import ImportExport from "~/scenes/Settings/ImportExport";
|
import Import from "~/scenes/Settings/Import";
|
||||||
import Notifications from "~/scenes/Settings/Notifications";
|
import Notifications from "~/scenes/Settings/Notifications";
|
||||||
import People from "~/scenes/Settings/People";
|
import People from "~/scenes/Settings/People";
|
||||||
import Profile from "~/scenes/Settings/Profile";
|
import Profile from "~/scenes/Settings/Profile";
|
||||||
@@ -33,7 +34,11 @@ export default function SettingsRoutes() {
|
|||||||
{isHosted && (
|
{isHosted && (
|
||||||
<Route exact path="/settings/integrations/zapier" component={Zapier} />
|
<Route exact path="/settings/integrations/zapier" component={Zapier} />
|
||||||
)}
|
)}
|
||||||
<Route exact path="/settings/import-export" component={ImportExport} />
|
<Route exact path="/settings/import" component={Import} />
|
||||||
|
<Route exact path="/settings/export" component={Export} />
|
||||||
|
|
||||||
|
{/* old routes */}
|
||||||
|
<Redirect from="/settings/import-export" to="/settings/export" />
|
||||||
<Redirect from="/settings/people" to="/settings/members" />
|
<Redirect from="/settings/people" to="/settings/members" />
|
||||||
</Switch>
|
</Switch>
|
||||||
);
|
);
|
||||||
|
|||||||
105
app/scenes/Settings/Export.tsx
Normal file
105
app/scenes/Settings/Export.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { observer } from "mobx-react";
|
||||||
|
import { DownloadIcon } from "outline-icons";
|
||||||
|
import * as React from "react";
|
||||||
|
import { useTranslation, Trans } from "react-i18next";
|
||||||
|
import FileOperation from "~/models/FileOperation";
|
||||||
|
import Button from "~/components/Button";
|
||||||
|
import Heading from "~/components/Heading";
|
||||||
|
import HelpText from "~/components/HelpText";
|
||||||
|
import PaginatedList from "~/components/PaginatedList";
|
||||||
|
import Scene from "~/components/Scene";
|
||||||
|
import Subheading from "~/components/Subheading";
|
||||||
|
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||||
|
import useStores from "~/hooks/useStores";
|
||||||
|
import useToasts from "~/hooks/useToasts";
|
||||||
|
import FileOperationListItem from "./components/FileOperationListItem";
|
||||||
|
|
||||||
|
function Export() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const user = useCurrentUser();
|
||||||
|
const { fileOperations, collections } = useStores();
|
||||||
|
const { showToast } = useToasts();
|
||||||
|
const [isLoading, setLoading] = React.useState(false);
|
||||||
|
const [isExporting, setExporting] = React.useState(false);
|
||||||
|
|
||||||
|
const handleExport = React.useCallback(
|
||||||
|
async (ev: React.SyntheticEvent) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await collections.export();
|
||||||
|
setExporting(true);
|
||||||
|
showToast(t("Export in progress…"));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[t, collections, showToast]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDelete = React.useCallback(
|
||||||
|
async (fileOperation: FileOperation) => {
|
||||||
|
try {
|
||||||
|
await fileOperations.delete(fileOperation);
|
||||||
|
showToast(t("Export deleted"));
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message, {
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[fileOperations, showToast, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Scene title={t("Export")} icon={<DownloadIcon color="currentColor" />}>
|
||||||
|
<Heading>{t("Export")}</Heading>
|
||||||
|
<HelpText>
|
||||||
|
<Trans
|
||||||
|
defaults="A full export might take some time, consider exporting a single document or collection. The exported data is a zip of your documents in Markdown format. You may leave this page once the export has started – we will email a link to <em>{{ userEmail }}</em> when it’s complete."
|
||||||
|
values={{
|
||||||
|
userEmail: user.email,
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
em: <strong />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</HelpText>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={isLoading || isExporting}
|
||||||
|
primary
|
||||||
|
>
|
||||||
|
{isExporting
|
||||||
|
? t("Export Requested")
|
||||||
|
: isLoading
|
||||||
|
? `${t("Requesting Export")}…`
|
||||||
|
: t("Export Data")}
|
||||||
|
</Button>
|
||||||
|
<br />
|
||||||
|
<PaginatedList
|
||||||
|
items={fileOperations.exports}
|
||||||
|
fetch={fileOperations.fetchPage}
|
||||||
|
options={{
|
||||||
|
type: "export",
|
||||||
|
}}
|
||||||
|
heading={
|
||||||
|
<Subheading>
|
||||||
|
<Trans>Recent exports</Trans>
|
||||||
|
</Subheading>
|
||||||
|
}
|
||||||
|
renderItem={(item) => (
|
||||||
|
<FileOperationListItem
|
||||||
|
key={item.id}
|
||||||
|
fileOperation={item}
|
||||||
|
handleDelete={handleDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Scene>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(Export);
|
||||||
154
app/scenes/Settings/Import.tsx
Normal file
154
app/scenes/Settings/Import.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import invariant from "invariant";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
import { NewDocumentIcon } from "outline-icons";
|
||||||
|
import * as React from "react";
|
||||||
|
import { useTranslation, Trans } from "react-i18next";
|
||||||
|
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||||
|
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
|
||||||
|
import { cdnPath } from "@shared/utils/urls";
|
||||||
|
import Button from "~/components/Button";
|
||||||
|
import Heading from "~/components/Heading";
|
||||||
|
import HelpText from "~/components/HelpText";
|
||||||
|
import Item from "~/components/List/Item";
|
||||||
|
import OutlineLogo from "~/components/OutlineLogo";
|
||||||
|
import PaginatedList from "~/components/PaginatedList";
|
||||||
|
import Scene from "~/components/Scene";
|
||||||
|
import Subheading from "~/components/Subheading";
|
||||||
|
import useStores from "~/hooks/useStores";
|
||||||
|
import useToasts from "~/hooks/useToasts";
|
||||||
|
import { uploadFile } from "~/utils/uploadFile";
|
||||||
|
import FileOperationListItem from "./components/FileOperationListItem";
|
||||||
|
|
||||||
|
function Import() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const fileRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
const { collections, fileOperations } = useStores();
|
||||||
|
const { showToast } = useToasts();
|
||||||
|
const [isImporting, setImporting] = React.useState(false);
|
||||||
|
|
||||||
|
const handleFilePicked = React.useCallback(
|
||||||
|
async (ev) => {
|
||||||
|
const files = getDataTransferFiles(ev);
|
||||||
|
const file = files[0];
|
||||||
|
invariant(file, "File must exist to upload");
|
||||||
|
|
||||||
|
setImporting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const attachment = await uploadFile(file, {
|
||||||
|
name: file.name,
|
||||||
|
});
|
||||||
|
await collections.import(attachment.id);
|
||||||
|
showToast(
|
||||||
|
t("Your import is being processed, you can safely leave this page"),
|
||||||
|
{
|
||||||
|
type: "success",
|
||||||
|
timeout: 8000,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message);
|
||||||
|
} finally {
|
||||||
|
if (fileRef.current) {
|
||||||
|
fileRef.current.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
setImporting(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[t, collections, showToast]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePickFile = React.useCallback(
|
||||||
|
(ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
|
||||||
|
if (fileRef.current) {
|
||||||
|
fileRef.current.click();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[fileRef]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Scene title={t("Import")} icon={<NewDocumentIcon color="currentColor" />}>
|
||||||
|
<Heading>{t("Import")}</Heading>
|
||||||
|
<HelpText>
|
||||||
|
<Trans>
|
||||||
|
Quickly transfer your existing documents, pages, and files from other
|
||||||
|
tools and services into Outline. You can also drag and drop any HTML,
|
||||||
|
Markdown, and text documents directly into Collections in the app.
|
||||||
|
</Trans>
|
||||||
|
</HelpText>
|
||||||
|
<VisuallyHidden>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileRef}
|
||||||
|
onChange={handleFilePicked}
|
||||||
|
accept="application/zip"
|
||||||
|
/>
|
||||||
|
</VisuallyHidden>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Item
|
||||||
|
border={false}
|
||||||
|
image={<OutlineLogo size={28} fill="currentColor" />}
|
||||||
|
title="Outline"
|
||||||
|
subtitle={t(
|
||||||
|
"Import a backup file that was previously exported from Outline"
|
||||||
|
)}
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
onClick={handlePickFile}
|
||||||
|
disabled={isImporting}
|
||||||
|
neutral
|
||||||
|
>
|
||||||
|
{isImporting ? `${t("Uploading")}…` : t("Import")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Item
|
||||||
|
border={false}
|
||||||
|
image={<img src={cdnPath("/images/confluence.png")} width={28} />}
|
||||||
|
title="Confluence"
|
||||||
|
subtitle={t("Import pages from a Confluence instance")}
|
||||||
|
actions={
|
||||||
|
<Button type="submit" onClick={handlePickFile} disabled neutral>
|
||||||
|
{t("Coming soon")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Item
|
||||||
|
border={false}
|
||||||
|
image={<img src={cdnPath("/images/notion.png")} width={28} />}
|
||||||
|
title="Notion"
|
||||||
|
subtitle={t("Import documents from Notion")}
|
||||||
|
actions={
|
||||||
|
<Button type="submit" onClick={handlePickFile} disabled neutral>
|
||||||
|
{t("Coming soon")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<br />
|
||||||
|
<PaginatedList
|
||||||
|
items={fileOperations.imports}
|
||||||
|
fetch={fileOperations.fetchPage}
|
||||||
|
options={{
|
||||||
|
type: "import",
|
||||||
|
}}
|
||||||
|
heading={
|
||||||
|
<Subheading>
|
||||||
|
<Trans>Recent imports</Trans>
|
||||||
|
</Subheading>
|
||||||
|
}
|
||||||
|
renderItem={(item) => (
|
||||||
|
<FileOperationListItem key={item.id} fileOperation={item} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Scene>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(Import);
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
import invariant from "invariant";
|
|
||||||
import { observer } from "mobx-react";
|
|
||||||
import { CollectionIcon, DocumentIcon } from "outline-icons";
|
|
||||||
import * as React from "react";
|
|
||||||
import { useTranslation, Trans } from "react-i18next";
|
|
||||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
|
||||||
import styled from "styled-components";
|
|
||||||
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
|
|
||||||
import { parseOutlineExport, Item } from "@shared/utils/zip";
|
|
||||||
import FileOperation from "~/models/FileOperation";
|
|
||||||
import Button from "~/components/Button";
|
|
||||||
import Heading from "~/components/Heading";
|
|
||||||
import HelpText from "~/components/HelpText";
|
|
||||||
import Notice from "~/components/Notice";
|
|
||||||
import PaginatedList from "~/components/PaginatedList";
|
|
||||||
import Scene from "~/components/Scene";
|
|
||||||
import Subheading from "~/components/Subheading";
|
|
||||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
|
||||||
import useStores from "~/hooks/useStores";
|
|
||||||
import useToasts from "~/hooks/useToasts";
|
|
||||||
import { uploadFile } from "~/utils/uploadFile";
|
|
||||||
import FileOperationListItem from "./components/FileOperationListItem";
|
|
||||||
|
|
||||||
function ImportExport() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const user = useCurrentUser();
|
|
||||||
const fileRef = React.useRef<HTMLInputElement>(null);
|
|
||||||
const { fileOperations, collections } = useStores();
|
|
||||||
const { showToast } = useToasts();
|
|
||||||
const [isLoading, setLoading] = React.useState(false);
|
|
||||||
const [isImporting, setImporting] = React.useState(false);
|
|
||||||
const [isImported, setImported] = React.useState(false);
|
|
||||||
const [isExporting, setExporting] = React.useState(false);
|
|
||||||
const [file, setFile] = React.useState<File>();
|
|
||||||
const [importDetails, setImportDetails] = React.useState<
|
|
||||||
Item[] | undefined
|
|
||||||
>();
|
|
||||||
|
|
||||||
const handleImport = React.useCallback(async () => {
|
|
||||||
setImported(false);
|
|
||||||
setImporting(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
invariant(file, "File must exist to upload");
|
|
||||||
const attachment = await uploadFile(file, {
|
|
||||||
name: file.name,
|
|
||||||
});
|
|
||||||
await collections.import(attachment.id);
|
|
||||||
showToast(t("Import started"));
|
|
||||||
setImported(true);
|
|
||||||
} catch (err) {
|
|
||||||
showToast(err.message);
|
|
||||||
} finally {
|
|
||||||
if (fileRef.current) {
|
|
||||||
fileRef.current.value = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
setImporting(false);
|
|
||||||
setFile(undefined);
|
|
||||||
setImportDetails(undefined);
|
|
||||||
}
|
|
||||||
}, [t, file, collections, showToast]);
|
|
||||||
|
|
||||||
const handleFilePicked = React.useCallback(async (ev) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
const files = getDataTransferFiles(ev);
|
|
||||||
const file = files[0];
|
|
||||||
setFile(file);
|
|
||||||
|
|
||||||
try {
|
|
||||||
setImportDetails(await parseOutlineExport(file));
|
|
||||||
} catch (err) {
|
|
||||||
setImportDetails([]);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handlePickFile = React.useCallback(
|
|
||||||
(ev) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
|
|
||||||
if (fileRef.current) {
|
|
||||||
fileRef.current.click();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[fileRef]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleExport = React.useCallback(
|
|
||||||
async (ev: React.SyntheticEvent) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await collections.export();
|
|
||||||
setExporting(true);
|
|
||||||
showToast(t("Export in progress…"));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[t, collections, showToast]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDelete = React.useCallback(
|
|
||||||
async (fileOperation: FileOperation) => {
|
|
||||||
try {
|
|
||||||
await fileOperations.delete(fileOperation);
|
|
||||||
showToast(t("Export deleted"));
|
|
||||||
} catch (err) {
|
|
||||||
showToast(err.message, {
|
|
||||||
type: "error",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[fileOperations, showToast, t]
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasCollections = importDetails
|
|
||||||
? !!importDetails.filter((detail) => detail.type === "collection").length
|
|
||||||
: false;
|
|
||||||
const hasDocuments = importDetails
|
|
||||||
? !!importDetails.filter((detail) => detail.type === "document").length
|
|
||||||
: false;
|
|
||||||
const isImportable = hasCollections && hasDocuments;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Scene
|
|
||||||
title={`${t("Import")} / ${t("Export")}`}
|
|
||||||
icon={<DocumentIcon color="currentColor" />}
|
|
||||||
>
|
|
||||||
<Heading>{t("Import")}</Heading>
|
|
||||||
<HelpText>
|
|
||||||
<Trans>
|
|
||||||
It is possible to import a zip file of folders and Markdown files
|
|
||||||
previously exported from an Outline instance. Support will soon be
|
|
||||||
added for importing from other services.
|
|
||||||
</Trans>
|
|
||||||
</HelpText>
|
|
||||||
<VisuallyHidden>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
ref={fileRef}
|
|
||||||
onChange={handleFilePicked}
|
|
||||||
accept="application/zip"
|
|
||||||
/>
|
|
||||||
</VisuallyHidden>
|
|
||||||
{isImported && (
|
|
||||||
<Notice>
|
|
||||||
<Trans>
|
|
||||||
Your file has been uploaded and the import is currently being
|
|
||||||
processed, you can safely leave this page while it completes.
|
|
||||||
</Trans>
|
|
||||||
</Notice>
|
|
||||||
)}
|
|
||||||
{file && !isImportable && (
|
|
||||||
<ImportPreview>
|
|
||||||
<Trans
|
|
||||||
defaults="Sorry, the file <em>{{ fileName }}</em> is missing valid collections or documents."
|
|
||||||
values={{
|
|
||||||
fileName: file.name,
|
|
||||||
}}
|
|
||||||
components={{
|
|
||||||
em: <strong />,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ImportPreview>
|
|
||||||
)}
|
|
||||||
{file && importDetails && isImportable ? (
|
|
||||||
<>
|
|
||||||
<ImportPreview as="div">
|
|
||||||
<Trans
|
|
||||||
defaults="<em>{{ fileName }}</em> looks good, the following collections and their documents will be imported:"
|
|
||||||
values={{
|
|
||||||
fileName: file.name,
|
|
||||||
}}
|
|
||||||
components={{
|
|
||||||
em: <strong />,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<List>
|
|
||||||
{importDetails
|
|
||||||
.filter((detail) => detail.type === "collection")
|
|
||||||
.map((detail) => (
|
|
||||||
<ImportPreviewItem key={detail.path}>
|
|
||||||
<CollectionIcon />
|
|
||||||
<CollectionName>{detail.name}</CollectionName>
|
|
||||||
</ImportPreviewItem>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
</ImportPreview>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
onClick={handleImport}
|
|
||||||
disabled={isImporting}
|
|
||||||
primary
|
|
||||||
>
|
|
||||||
{isImporting ? `${t("Uploading")}…` : t("Confirm & Import")}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Button type="submit" onClick={handlePickFile} primary>
|
|
||||||
{t("Choose File")}…
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Heading>{t("Export")}</Heading>
|
|
||||||
<HelpText>
|
|
||||||
<Trans
|
|
||||||
defaults="A full export might take some time, consider exporting a single document or collection. The exported data is a zip of your documents in Markdown format. You may leave this page once the export has started – we will email a link to <em>{{ userEmail }}</em> when it’s complete."
|
|
||||||
values={{
|
|
||||||
userEmail: user.email,
|
|
||||||
}}
|
|
||||||
components={{
|
|
||||||
em: <strong />,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</HelpText>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
onClick={handleExport}
|
|
||||||
disabled={isLoading || isExporting}
|
|
||||||
primary
|
|
||||||
>
|
|
||||||
{isExporting
|
|
||||||
? t("Export Requested")
|
|
||||||
: isLoading
|
|
||||||
? `${t("Requesting Export")}…`
|
|
||||||
: t("Export Data")}
|
|
||||||
</Button>
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
<PaginatedList
|
|
||||||
items={fileOperations.orderedDataExports}
|
|
||||||
fetch={fileOperations.fetchPage}
|
|
||||||
options={{
|
|
||||||
type: "export",
|
|
||||||
}}
|
|
||||||
heading={
|
|
||||||
<Subheading>
|
|
||||||
<Trans>Recent exports</Trans>
|
|
||||||
</Subheading>
|
|
||||||
}
|
|
||||||
renderItem={(item) => (
|
|
||||||
<FileOperationListItem
|
|
||||||
key={item.id + item.state}
|
|
||||||
fileOperation={item}
|
|
||||||
handleDelete={handleDelete}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Scene>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const List = styled.ul`
|
|
||||||
padding: 0;
|
|
||||||
margin: 8px 0 0;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ImportPreview = styled(Notice)`
|
|
||||||
margin-bottom: 16px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ImportPreviewItem = styled.li`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
list-style: none;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const CollectionName = styled.span`
|
|
||||||
font-weight: 500;
|
|
||||||
margin-left: 4px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default observer(ImportExport);
|
|
||||||
@@ -1,38 +1,54 @@
|
|||||||
|
import { observer } from "mobx-react";
|
||||||
|
import { DoneIcon, WarningIcon } from "outline-icons";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useTheme } from "styled-components";
|
||||||
import FileOperation from "~/models/FileOperation";
|
import FileOperation from "~/models/FileOperation";
|
||||||
import { Action } from "~/components/Actions";
|
import { Action } from "~/components/Actions";
|
||||||
import ListItem from "~/components/List/Item";
|
import ListItem from "~/components/List/Item";
|
||||||
|
import Spinner from "~/components/Spinner";
|
||||||
import Time from "~/components/Time";
|
import Time from "~/components/Time";
|
||||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||||
import FileOperationMenu from "~/menus/FileOperationMenu";
|
import FileOperationMenu from "~/menus/FileOperationMenu";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
fileOperation: FileOperation;
|
fileOperation: FileOperation;
|
||||||
handleDelete: (arg0: FileOperation) => Promise<void>;
|
handleDelete?: (arg0: FileOperation) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const FileOperationListItem = ({ fileOperation, handleDelete }: Props) => {
|
const FileOperationListItem = ({ fileOperation, handleDelete }: Props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const user = useCurrentUser();
|
const user = useCurrentUser();
|
||||||
|
const theme = useTheme();
|
||||||
const stateMapping = {
|
const stateMapping = {
|
||||||
creating: t("Processing"),
|
creating: t("Processing"),
|
||||||
expired: t("Expired"),
|
expired: t("Expired"),
|
||||||
uploading: t("Processing"),
|
uploading: t("Processing"),
|
||||||
error: t("Error"),
|
error: t("Failed"),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const iconMapping = {
|
||||||
|
creating: <Spinner />,
|
||||||
|
uploading: <Spinner />,
|
||||||
|
complete: <DoneIcon color={theme.primary} />,
|
||||||
|
error: <WarningIcon color={theme.danger} />,
|
||||||
|
};
|
||||||
|
|
||||||
|
const title =
|
||||||
|
fileOperation.type === "import" || fileOperation.collectionId
|
||||||
|
? fileOperation.name
|
||||||
|
: t("All collections");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListItem
|
<ListItem
|
||||||
title={
|
title={title}
|
||||||
fileOperation.collection
|
image={iconMapping[fileOperation.state]}
|
||||||
? fileOperation.collection.name
|
|
||||||
: t("All collections")
|
|
||||||
}
|
|
||||||
subtitle={
|
subtitle={
|
||||||
<>
|
<>
|
||||||
{fileOperation.state !== "complete" && (
|
{fileOperation.state !== "complete" && (
|
||||||
<>{stateMapping[fileOperation.state]} • </>
|
<>{stateMapping[fileOperation.state]} • </>
|
||||||
)}
|
)}
|
||||||
|
{fileOperation.error && <>{fileOperation.error} • </>}
|
||||||
{t(`{{userName}} requested`, {
|
{t(`{{userName}} requested`, {
|
||||||
userName:
|
userName:
|
||||||
user.id === fileOperation.user.id
|
user.id === fileOperation.user.id
|
||||||
@@ -45,7 +61,7 @@ const FileOperationListItem = ({ fileOperation, handleDelete }: Props) => {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
fileOperation.state === "complete" ? (
|
fileOperation.state === "complete" && handleDelete ? (
|
||||||
<Action>
|
<Action>
|
||||||
<FileOperationMenu
|
<FileOperationMenu
|
||||||
id={fileOperation.id}
|
id={fileOperation.id}
|
||||||
@@ -61,4 +77,4 @@ const FileOperationListItem = ({ fileOperation, handleDelete }: Props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default FileOperationListItem;
|
export default observer(FileOperationListItem);
|
||||||
|
|||||||
@@ -12,15 +12,26 @@ export default class FileOperationsStore extends BaseStore<FileOperation> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@computed
|
@computed
|
||||||
get exports(): FileOperation[] {
|
get imports(): FileOperation[] {
|
||||||
return Array.from(this.data.values()).reduce(
|
return orderBy(
|
||||||
(acc, fileOp) => (fileOp.type === "export" ? [...acc, fileOp] : acc),
|
Array.from(this.data.values()).reduce(
|
||||||
[]
|
(acc, fileOp) => (fileOp.type === "import" ? [...acc, fileOp] : acc),
|
||||||
|
[]
|
||||||
|
),
|
||||||
|
"createdAt",
|
||||||
|
"desc"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@computed
|
@computed
|
||||||
get orderedDataExports(): FileOperation[] {
|
get exports(): FileOperation[] {
|
||||||
return orderBy(this.exports, "createdAt", "desc");
|
return orderBy(
|
||||||
|
Array.from(this.data.values()).reduce(
|
||||||
|
(acc, fileOp) => (fileOp.type === "export" ? [...acc, fileOp] : acc),
|
||||||
|
[]
|
||||||
|
),
|
||||||
|
"createdAt",
|
||||||
|
"desc"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/images/confluence.png
Normal file
BIN
public/images/confluence.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
BIN
public/images/notion.png
Normal file
BIN
public/images/notion.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.0 KiB |
@@ -5,9 +5,9 @@ import File from "formidable/lib/file";
|
|||||||
import invariant from "invariant";
|
import invariant from "invariant";
|
||||||
import { values, keys } from "lodash";
|
import { values, keys } from "lodash";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import { parseOutlineExport } from "@shared/utils/zip";
|
|
||||||
import Logger from "@server/logging/logger";
|
import Logger from "@server/logging/logger";
|
||||||
import { Attachment, Event, Document, Collection, User } from "@server/models";
|
import { Attachment, Event, Document, Collection, User } from "@server/models";
|
||||||
|
import { parseOutlineExport, Item } from "@server/utils/zip";
|
||||||
import { FileImportError } from "../errors";
|
import { FileImportError } from "../errors";
|
||||||
import attachmentCreator from "./attachmentCreator";
|
import attachmentCreator from "./attachmentCreator";
|
||||||
import documentCreator from "./documentCreator";
|
import documentCreator from "./documentCreator";
|
||||||
@@ -30,10 +30,10 @@ export default async function collectionImporter({
|
|||||||
}) {
|
}) {
|
||||||
// load the zip structure into memory
|
// load the zip structure into memory
|
||||||
const zipData = await fs.promises.readFile(file.path);
|
const zipData = await fs.promises.readFile(file.path);
|
||||||
let items;
|
let items: Item[];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
items = await await parseOutlineExport(zipData);
|
items = await parseOutlineExport(zipData);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw FileImportError(err.message);
|
throw FileImportError(err.message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ import { sequelize } from "@server/database/sequelize";
|
|||||||
import { FileOperation, Event, User } from "@server/models";
|
import { FileOperation, Event, User } from "@server/models";
|
||||||
|
|
||||||
export default async function fileOperationDeleter(
|
export default async function fileOperationDeleter(
|
||||||
fileOp: FileOperation,
|
fileOperation: FileOperation,
|
||||||
user: User,
|
user: User,
|
||||||
ip: string
|
ip: string
|
||||||
) {
|
) {
|
||||||
const transaction = await sequelize.transaction();
|
const transaction = await sequelize.transaction();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fileOp.destroy({
|
await fileOperation.destroy({
|
||||||
transaction,
|
transaction,
|
||||||
});
|
});
|
||||||
await Event.create(
|
await Event.create(
|
||||||
@@ -17,8 +17,7 @@ export default async function fileOperationDeleter(
|
|||||||
name: "fileOperations.delete",
|
name: "fileOperations.delete",
|
||||||
teamId: user.teamId,
|
teamId: user.teamId,
|
||||||
actorId: user.id,
|
actorId: user.id,
|
||||||
// @ts-expect-error dataValues does exist
|
modelId: fileOperation.id,
|
||||||
data: fileOp.dataValues,
|
|
||||||
ip,
|
ip,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
up: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.addColumn("file_operations", "error", {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
down: async (queryInterface) => {
|
||||||
|
await queryInterface.removeColumn("file_operations", "error");
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -45,6 +45,9 @@ class FileOperation extends BaseModel {
|
|||||||
@Column
|
@Column
|
||||||
url: string;
|
url: string;
|
||||||
|
|
||||||
|
@Column
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
@Column(DataType.BIGINT)
|
@Column(DataType.BIGINT)
|
||||||
size: number;
|
size: number;
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
|
import path from "path";
|
||||||
import { FileOperation } from "@server/models";
|
import { FileOperation } from "@server/models";
|
||||||
import { presentCollection, presentUser } from ".";
|
import { presentUser } from ".";
|
||||||
|
|
||||||
export default function present(data: FileOperation) {
|
export default function present(data: FileOperation) {
|
||||||
return {
|
return {
|
||||||
id: data.id,
|
id: data.id,
|
||||||
type: data.type,
|
type: data.type,
|
||||||
|
name: data.collection?.name || path.basename(data.key || ""),
|
||||||
state: data.state,
|
state: data.state,
|
||||||
collection: data.collection ? presentCollection(data.collection) : null,
|
error: data.error,
|
||||||
size: data.size,
|
size: data.size,
|
||||||
|
collectionId: data.collectionId,
|
||||||
user: presentUser(data.user),
|
user: presentUser(data.user),
|
||||||
createdAt: data.createdAt,
|
createdAt: data.createdAt,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,60 +19,62 @@ export default class ExportsProcessor {
|
|||||||
const user = await User.findByPk(actorId);
|
const user = await User.findByPk(actorId);
|
||||||
invariant(user, "user operation not found");
|
invariant(user, "user operation not found");
|
||||||
|
|
||||||
const exportData = await FileOperation.findByPk(event.modelId);
|
const fileOperation = await FileOperation.findByPk(event.modelId);
|
||||||
invariant(exportData, "exportData not found");
|
invariant(fileOperation, "fileOperation not found");
|
||||||
|
|
||||||
const collectionIds =
|
const collectionIds =
|
||||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'collectionId' does not exist on type 'Co... Remove this comment to see the full error message
|
"collectionId" in event
|
||||||
event.collectionId || (await user.collectionIds());
|
? event.collectionId
|
||||||
|
: await user.collectionIds();
|
||||||
const collections = await Collection.findAll({
|
const collections = await Collection.findAll({
|
||||||
where: {
|
where: {
|
||||||
id: collectionIds,
|
id: collectionIds,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.updateFileOperation(exportData, actorId, teamId, {
|
this.updateFileOperation(fileOperation, actorId, teamId, {
|
||||||
state: "creating",
|
state: "creating",
|
||||||
});
|
});
|
||||||
// heavy lifting of creating the zip file
|
// heavy lifting of creating the zip file
|
||||||
Logger.info(
|
Logger.info(
|
||||||
"processor",
|
"processor",
|
||||||
`Archiving collections for file operation ${exportData.id}`
|
`Archiving collections for file operation ${fileOperation.id}`
|
||||||
);
|
);
|
||||||
const filePath = await archiveCollections(collections);
|
const filePath = await archiveCollections(collections);
|
||||||
let url, state;
|
let url;
|
||||||
|
let state: any = "creating";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
|
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
|
||||||
const readBuffer = await fs.promises.readFile(filePath);
|
const readBuffer = await fs.promises.readFile(filePath);
|
||||||
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
|
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
|
||||||
const stat = await fs.promises.stat(filePath);
|
const stat = await fs.promises.stat(filePath);
|
||||||
this.updateFileOperation(exportData, actorId, teamId, {
|
this.updateFileOperation(fileOperation, actorId, teamId, {
|
||||||
state: "uploading",
|
state: "uploading",
|
||||||
size: stat.size,
|
size: stat.size,
|
||||||
});
|
});
|
||||||
Logger.info(
|
Logger.info(
|
||||||
"processor",
|
"processor",
|
||||||
`Uploading archive for file operation ${exportData.id}`
|
`Uploading archive for file operation ${fileOperation.id}`
|
||||||
);
|
);
|
||||||
url = await uploadToS3FromBuffer(
|
url = await uploadToS3FromBuffer(
|
||||||
readBuffer,
|
readBuffer,
|
||||||
"application/zip",
|
"application/zip",
|
||||||
exportData.key,
|
fileOperation.key,
|
||||||
"private"
|
"private"
|
||||||
);
|
);
|
||||||
Logger.info(
|
Logger.info(
|
||||||
"processor",
|
"processor",
|
||||||
`Upload complete for file operation ${exportData.id}`
|
`Upload complete for file operation ${fileOperation.id}`
|
||||||
);
|
);
|
||||||
state = "complete";
|
state = "complete";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error("Error exporting collection data", error, {
|
Logger.error("Error exporting collection data", error, {
|
||||||
fileOperationId: exportData.id,
|
fileOperationId: fileOperation.id,
|
||||||
});
|
});
|
||||||
state = "error";
|
state = "error";
|
||||||
url = null;
|
url = undefined;
|
||||||
} finally {
|
} finally {
|
||||||
this.updateFileOperation(exportData, actorId, teamId, {
|
this.updateFileOperation(fileOperation, actorId, teamId, {
|
||||||
state,
|
state,
|
||||||
url,
|
url,
|
||||||
});
|
});
|
||||||
@@ -85,7 +87,7 @@ export default class ExportsProcessor {
|
|||||||
} else {
|
} else {
|
||||||
mailer.sendTemplate("exportSuccess", {
|
mailer.sendTemplate("exportSuccess", {
|
||||||
to: user.email,
|
to: user.email,
|
||||||
id: exportData.id,
|
id: fileOperation.id,
|
||||||
teamUrl: team.url,
|
teamUrl: team.url,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -101,15 +103,14 @@ export default class ExportsProcessor {
|
|||||||
fileOperation: FileOperation,
|
fileOperation: FileOperation,
|
||||||
actorId: string,
|
actorId: string,
|
||||||
teamId: string,
|
teamId: string,
|
||||||
data: Record<string, any>
|
data: Partial<FileOperation>
|
||||||
) {
|
) {
|
||||||
await fileOperation.update(data);
|
await fileOperation.update(data);
|
||||||
await Event.add({
|
await Event.add({
|
||||||
name: "fileOperations.update",
|
name: "fileOperations.update",
|
||||||
teamId,
|
teamId,
|
||||||
actorId,
|
actorId,
|
||||||
// @ts-expect-error dataValues exists
|
modelId: fileOperation.id,
|
||||||
data: fileOperation.dataValues,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ import os from "os";
|
|||||||
import File from "formidable/lib/file";
|
import File from "formidable/lib/file";
|
||||||
import invariant from "invariant";
|
import invariant from "invariant";
|
||||||
import collectionImporter from "@server/commands/collectionImporter";
|
import collectionImporter from "@server/commands/collectionImporter";
|
||||||
import { Attachment, User } from "@server/models";
|
import { Event, FileOperation, Attachment, User } from "@server/models";
|
||||||
import { Event } from "../../types";
|
import { Event as TEvent } from "../../types";
|
||||||
|
|
||||||
export default class ImportsProcessor {
|
export default class ImportsProcessor {
|
||||||
async on(event: Event) {
|
async on(event: TEvent) {
|
||||||
switch (event.name) {
|
switch (event.name) {
|
||||||
case "collections.import": {
|
case "collections.import": {
|
||||||
|
let state, error;
|
||||||
const { type } = event.data;
|
const { type } = event.data;
|
||||||
const attachment = await Attachment.findByPk(event.modelId);
|
const attachment = await Attachment.findByPk(event.modelId);
|
||||||
invariant(attachment, "attachment not found");
|
invariant(attachment, "attachment not found");
|
||||||
@@ -17,22 +18,55 @@ export default class ImportsProcessor {
|
|||||||
const user = await User.findByPk(event.actorId);
|
const user = await User.findByPk(event.actorId);
|
||||||
invariant(user, "user not found");
|
invariant(user, "user not found");
|
||||||
|
|
||||||
const buffer: any = await attachment.buffer;
|
const fileOperation = await FileOperation.create({
|
||||||
const tmpDir = os.tmpdir();
|
type: "import",
|
||||||
const tmpFilePath = `${tmpDir}/upload-${event.modelId}`;
|
state: "creating",
|
||||||
await fs.promises.writeFile(tmpFilePath, buffer);
|
size: attachment.size,
|
||||||
const file = new File({
|
key: attachment.key,
|
||||||
name: attachment.name,
|
userId: user.id,
|
||||||
type: attachment.contentType,
|
teamId: user.teamId,
|
||||||
path: tmpFilePath,
|
|
||||||
});
|
});
|
||||||
await collectionImporter({
|
|
||||||
file,
|
await Event.add({
|
||||||
user,
|
name: "fileOperations.create",
|
||||||
type,
|
modelId: fileOperation.id,
|
||||||
ip: event.ip,
|
teamId: user.teamId,
|
||||||
|
actorId: user.id,
|
||||||
});
|
});
|
||||||
await attachment.destroy();
|
|
||||||
|
try {
|
||||||
|
const buffer = await attachment.buffer;
|
||||||
|
const tmpDir = os.tmpdir();
|
||||||
|
const tmpFilePath = `${tmpDir}/upload-${event.modelId}`;
|
||||||
|
await fs.promises.writeFile(tmpFilePath, buffer as Uint8Array);
|
||||||
|
const file = new File({
|
||||||
|
name: attachment.name,
|
||||||
|
type: attachment.contentType,
|
||||||
|
path: tmpFilePath,
|
||||||
|
});
|
||||||
|
|
||||||
|
await collectionImporter({
|
||||||
|
file,
|
||||||
|
user,
|
||||||
|
type,
|
||||||
|
ip: event.ip,
|
||||||
|
});
|
||||||
|
await attachment.destroy();
|
||||||
|
|
||||||
|
state = "complete";
|
||||||
|
} catch (err) {
|
||||||
|
state = "error";
|
||||||
|
error = err.message;
|
||||||
|
} finally {
|
||||||
|
await fileOperation.update({ state, error });
|
||||||
|
await Event.add({
|
||||||
|
name: "fileOperations.update",
|
||||||
|
modelId: fileOperation.id,
|
||||||
|
teamId: user.teamId,
|
||||||
|
actorId: user.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,18 @@ import { Op } from "sequelize";
|
|||||||
import {
|
import {
|
||||||
Document,
|
Document,
|
||||||
Collection,
|
Collection,
|
||||||
|
FileOperation,
|
||||||
Group,
|
Group,
|
||||||
CollectionGroup,
|
CollectionGroup,
|
||||||
GroupUser,
|
GroupUser,
|
||||||
Pin,
|
Pin,
|
||||||
Star,
|
Star,
|
||||||
} from "@server/models";
|
} from "@server/models";
|
||||||
import { presentPin, presentStar } from "@server/presenters";
|
import {
|
||||||
|
presentFileOperation,
|
||||||
|
presentPin,
|
||||||
|
presentStar,
|
||||||
|
} from "@server/presenters";
|
||||||
import { Event } from "../../types";
|
import { Event } from "../../types";
|
||||||
|
|
||||||
export default class WebsocketsProcessor {
|
export default class WebsocketsProcessor {
|
||||||
@@ -354,10 +359,14 @@ export default class WebsocketsProcessor {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "fileOperations.create":
|
||||||
case "fileOperations.update": {
|
case "fileOperations.update": {
|
||||||
return socketio
|
const fileOperation = await FileOperation.findByPk(event.modelId);
|
||||||
.to(`user-${event.actorId}`)
|
if (!fileOperation) {
|
||||||
.emit("fileOperations.update", event.data);
|
return;
|
||||||
|
}
|
||||||
|
const data = await presentFileOperation(fileOperation);
|
||||||
|
return socketio.to(`user-${event.actorId}`).emit(event.name, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
case "pins.create":
|
case "pins.create":
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ describe("#fileOperations.list", () => {
|
|||||||
expect(data.id).toBe(exportData.id);
|
expect(data.id).toBe(exportData.id);
|
||||||
expect(data.key).toBe(undefined);
|
expect(data.key).toBe(undefined);
|
||||||
expect(data.state).toBe(exportData.state);
|
expect(data.state).toBe(exportData.state);
|
||||||
expect(data.collection.id).toBe(collection.id);
|
expect(data.collectionId).toBe(collection.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return exports with collection data even if collection is deleted", async () => {
|
it("should return exports with collection data even if collection is deleted", async () => {
|
||||||
@@ -152,7 +152,7 @@ describe("#fileOperations.list", () => {
|
|||||||
expect(data.id).toBe(exportData.id);
|
expect(data.id).toBe(exportData.id);
|
||||||
expect(data.key).toBe(undefined);
|
expect(data.key).toBe(undefined);
|
||||||
expect(data.state).toBe(exportData.state);
|
expect(data.state).toBe(exportData.state);
|
||||||
expect(data.collection.id).toBe(collection.id);
|
expect(data.collectionId).toBe(collection.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return exports with user data even if user is deleted", async () => {
|
it("should return exports with user data even if user is deleted", async () => {
|
||||||
|
|||||||
@@ -130,9 +130,13 @@ export type CollectionExportAllEvent = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type FileOperationEvent = {
|
export type FileOperationEvent = {
|
||||||
name: "fileOperations.update" | "fileOperation.delete";
|
name:
|
||||||
|
| "fileOperations.create"
|
||||||
|
| "fileOperations.update"
|
||||||
|
| "fileOperation.delete";
|
||||||
teamId: string;
|
teamId: string;
|
||||||
actorId: string;
|
actorId: string;
|
||||||
|
modelId: string;
|
||||||
data: {
|
data: {
|
||||||
type: string;
|
type: string;
|
||||||
state: string;
|
state: string;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import JSZip from "jszip";
|
import path from "path";
|
||||||
|
import JSZip, { JSZipObject } from "jszip";
|
||||||
import tmp from "tmp";
|
import tmp from "tmp";
|
||||||
import Logger from "@server/logging/logger";
|
import Logger from "@server/logging/logger";
|
||||||
import Attachment from "@server/models/Attachment";
|
import Attachment from "@server/models/Attachment";
|
||||||
@@ -10,6 +11,18 @@ import { serializeFilename } from "./fs";
|
|||||||
import parseAttachmentIds from "./parseAttachmentIds";
|
import parseAttachmentIds from "./parseAttachmentIds";
|
||||||
import { getFileByKey } from "./s3";
|
import { getFileByKey } from "./s3";
|
||||||
|
|
||||||
|
type ItemType = "collection" | "document" | "attachment";
|
||||||
|
|
||||||
|
export type Item = {
|
||||||
|
path: string;
|
||||||
|
dir: string;
|
||||||
|
name: string;
|
||||||
|
depth: number;
|
||||||
|
metadata: Record<string, any>;
|
||||||
|
type: ItemType;
|
||||||
|
item: JSZipObject;
|
||||||
|
};
|
||||||
|
|
||||||
async function addToArchive(zip: JSZip, documents: NavigationNode[]) {
|
async function addToArchive(zip: JSZip, documents: NavigationNode[]) {
|
||||||
for (const doc of documents) {
|
for (const doc of documents) {
|
||||||
const document = await Document.findByPk(doc.id);
|
const document = await Document.findByPk(doc.id);
|
||||||
@@ -104,3 +117,78 @@ export async function archiveCollections(collections: Collection[]) {
|
|||||||
|
|
||||||
return archiveToPath(zip);
|
return archiveToPath(zip);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function parseOutlineExport(
|
||||||
|
input: File | Buffer
|
||||||
|
): Promise<Item[]> {
|
||||||
|
const zip = await JSZip.loadAsync(input);
|
||||||
|
// this is so we can use async / await a little easier
|
||||||
|
const items: Item[] = [];
|
||||||
|
|
||||||
|
for (const rawPath in zip.files) {
|
||||||
|
const item = zip.files[rawPath];
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
throw new Error(
|
||||||
|
`No item at ${rawPath} in zip file. This zip file might be corrupt.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemPath = rawPath.replace(/\/$/, "");
|
||||||
|
const dir = path.dirname(itemPath);
|
||||||
|
const name = path.basename(item.name);
|
||||||
|
const depth = itemPath.split("/").length - 1;
|
||||||
|
|
||||||
|
// known skippable items
|
||||||
|
if (itemPath.startsWith("__MACOSX") || itemPath.endsWith(".DS_Store")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// attempt to parse extra metadata from zip comment
|
||||||
|
let metadata = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
metadata = item.comment ? JSON.parse(item.comment) : {};
|
||||||
|
} catch (err) {
|
||||||
|
console.log(
|
||||||
|
`ZIP comment found for ${item.name}, but could not be parsed as metadata: ${item.comment}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (depth === 0 && !item.dir) {
|
||||||
|
throw new Error(
|
||||||
|
"Root of zip file must only contain folders representing collections"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let type: ItemType | undefined;
|
||||||
|
|
||||||
|
if (depth === 0 && item.dir && name) {
|
||||||
|
type = "collection";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (depth > 0 && !item.dir && item.name.endsWith(".md")) {
|
||||||
|
type = "document";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (depth > 0 && !item.dir && itemPath.includes("uploads")) {
|
||||||
|
type = "attachment";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!type) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
path: itemPath,
|
||||||
|
dir,
|
||||||
|
name,
|
||||||
|
depth,
|
||||||
|
type,
|
||||||
|
metadata,
|
||||||
|
item,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|||||||
@@ -514,7 +514,7 @@
|
|||||||
"No documents found for your search filters.": "No documents found for your search filters.",
|
"No documents found for your search filters.": "No documents found for your search filters.",
|
||||||
"Processing": "Processing",
|
"Processing": "Processing",
|
||||||
"Expired": "Expired",
|
"Expired": "Expired",
|
||||||
"Error": "Error",
|
"Failed": "Failed",
|
||||||
"All collections": "All collections",
|
"All collections": "All collections",
|
||||||
"{{userName}} requested": "{{userName}} requested",
|
"{{userName}} requested": "{{userName}} requested",
|
||||||
"Upload": "Upload",
|
"Upload": "Upload",
|
||||||
@@ -541,6 +541,13 @@
|
|||||||
"Logo": "Logo",
|
"Logo": "Logo",
|
||||||
"Subdomain": "Subdomain",
|
"Subdomain": "Subdomain",
|
||||||
"Your knowledge base will be accessible at": "Your knowledge base will be accessible at",
|
"Your knowledge base will be accessible at": "Your knowledge base will be accessible at",
|
||||||
|
"Export in progress…": "Export in progress…",
|
||||||
|
"Export deleted": "Export deleted",
|
||||||
|
"A full export might take some time, consider exporting a single document or collection. The exported data is a zip of your documents in Markdown format. You may leave this page once the export has started – we will email a link to <em>{{ userEmail }}</em> when it’s complete.": "A full export might take some time, consider exporting a single document or collection. The exported data is a zip of your documents in Markdown format. You may leave this page once the export has started – we will email a link to <em>{{ userEmail }}</em> when it’s complete.",
|
||||||
|
"Export Requested": "Export Requested",
|
||||||
|
"Requesting Export": "Requesting Export",
|
||||||
|
"Export Data": "Export Data",
|
||||||
|
"Recent exports": "Recent exports",
|
||||||
"Manage optional and beta features. Changing these settings will affect the experience for all team members.": "Manage optional and beta features. Changing these settings will affect the experience for all team members.",
|
"Manage optional and beta features. Changing these settings will affect the experience for all team members.": "Manage optional and beta features. Changing these settings will affect the experience for all team members.",
|
||||||
"Collaborative editing": "Collaborative editing",
|
"Collaborative editing": "Collaborative editing",
|
||||||
"When enabled multiple people can edit documents at the same time with shared presence and live cursors.": "When enabled multiple people can edit documents at the same time with shared presence and live cursors.",
|
"When enabled multiple people can edit documents at the same time with shared presence and live cursors.": "When enabled multiple people can edit documents at the same time with shared presence and live cursors.",
|
||||||
@@ -548,21 +555,14 @@
|
|||||||
"Groups can be used to organize and manage the people on your team.": "Groups can be used to organize and manage the people on your team.",
|
"Groups can be used to organize and manage the people on your team.": "Groups can be used to organize and manage the people on your team.",
|
||||||
"All groups": "All groups",
|
"All groups": "All groups",
|
||||||
"No groups have been created yet": "No groups have been created yet",
|
"No groups have been created yet": "No groups have been created yet",
|
||||||
"Import started": "Import started",
|
"Your import is being processed, you can safely leave this page": "Your import is being processed, you can safely leave this page",
|
||||||
"Export in progress…": "Export in progress…",
|
"Quickly transfer your existing documents, pages, and files from other tools and services into Outline. You can also drag and drop any HTML, Markdown, and text documents directly into Collections in the app.": "Quickly transfer your existing documents, pages, and files from other tools and services into Outline. You can also drag and drop any HTML, Markdown, and text documents directly into Collections in the app.",
|
||||||
"Export deleted": "Export deleted",
|
"Import a backup file that was previously exported from Outline": "Import a backup file that was previously exported from Outline",
|
||||||
"It is possible to import a zip file of folders and Markdown files previously exported from an Outline instance. Support will soon be added for importing from other services.": "It is possible to import a zip file of folders and Markdown files previously exported from an Outline instance. Support will soon be added for importing from other services.",
|
|
||||||
"Your file has been uploaded and the import is currently being processed, you can safely leave this page while it completes.": "Your file has been uploaded and the import is currently being processed, you can safely leave this page while it completes.",
|
|
||||||
"Sorry, the file <em>{{ fileName }}</em> is missing valid collections or documents.": "Sorry, the file <em>{{ fileName }}</em> is missing valid collections or documents.",
|
|
||||||
"<em>{{ fileName }}</em> looks good, the following collections and their documents will be imported:": "<em>{{ fileName }}</em> looks good, the following collections and their documents will be imported:",
|
|
||||||
"Uploading": "Uploading",
|
"Uploading": "Uploading",
|
||||||
"Confirm & Import": "Confirm & Import",
|
"Import pages from a Confluence instance": "Import pages from a Confluence instance",
|
||||||
"Choose File": "Choose File",
|
"Coming soon": "Coming soon",
|
||||||
"A full export might take some time, consider exporting a single document or collection. The exported data is a zip of your documents in Markdown format. You may leave this page once the export has started – we will email a link to <em>{{ userEmail }}</em> when it’s complete.": "A full export might take some time, consider exporting a single document or collection. The exported data is a zip of your documents in Markdown format. You may leave this page once the export has started – we will email a link to <em>{{ userEmail }}</em> when it’s complete.",
|
"Import documents from Notion": "Import documents from Notion",
|
||||||
"Export Requested": "Export Requested",
|
"Recent imports": "Recent imports",
|
||||||
"Requesting Export": "Requesting Export",
|
|
||||||
"Export Data": "Export Data",
|
|
||||||
"Recent exports": "Recent exports",
|
|
||||||
"Receive a notification whenever a new document is published": "Receive a notification whenever a new document is published",
|
"Receive a notification whenever a new document is published": "Receive a notification whenever a new document is published",
|
||||||
"Receive a notification when a document you created is edited": "Receive a notification when a document you created is edited",
|
"Receive a notification when a document you created is edited": "Receive a notification when a document you created is edited",
|
||||||
"Collection created": "Collection created",
|
"Collection created": "Collection created",
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
import path from "path";
|
|
||||||
import JSZip, { JSZipObject } from "jszip";
|
|
||||||
|
|
||||||
type ItemType = "collection" | "document" | "attachment";
|
|
||||||
|
|
||||||
export type Item = {
|
|
||||||
path: string;
|
|
||||||
dir: string;
|
|
||||||
name: string;
|
|
||||||
depth: number;
|
|
||||||
metadata: Record<string, any>;
|
|
||||||
type: ItemType;
|
|
||||||
item: JSZipObject;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function parseOutlineExport(
|
|
||||||
input: File | Buffer
|
|
||||||
): Promise<Item[]> {
|
|
||||||
const zip = await JSZip.loadAsync(input);
|
|
||||||
// this is so we can use async / await a little easier
|
|
||||||
const items: Item[] = [];
|
|
||||||
zip.forEach(async function (rawPath, item) {
|
|
||||||
const itemPath = rawPath.replace(/\/$/, "");
|
|
||||||
const dir = path.dirname(itemPath);
|
|
||||||
const name = path.basename(item.name);
|
|
||||||
const depth = itemPath.split("/").length - 1;
|
|
||||||
|
|
||||||
// known skippable items
|
|
||||||
if (itemPath.startsWith("__MACOSX") || itemPath.endsWith(".DS_Store")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// attempt to parse extra metadata from zip comment
|
|
||||||
let metadata = {};
|
|
||||||
|
|
||||||
try {
|
|
||||||
metadata = item.comment ? JSON.parse(item.comment) : {};
|
|
||||||
} catch (err) {
|
|
||||||
console.log(
|
|
||||||
`ZIP comment found for ${item.name}, but could not be parsed as metadata: ${item.comment}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (depth === 0 && !item.dir) {
|
|
||||||
throw new Error(
|
|
||||||
"Root of zip file must only contain folders representing collections"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let type: ItemType | undefined;
|
|
||||||
|
|
||||||
if (depth === 0 && item.dir && name) {
|
|
||||||
type = "collection";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (depth > 0 && !item.dir && item.name.endsWith(".md")) {
|
|
||||||
type = "document";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (depth > 0 && !item.dir && itemPath.includes("uploads")) {
|
|
||||||
type = "attachment";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!type) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
items.push({
|
|
||||||
path: itemPath,
|
|
||||||
dir,
|
|
||||||
name,
|
|
||||||
depth,
|
|
||||||
type,
|
|
||||||
metadata,
|
|
||||||
item,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user