mirror of
https://github.com/outline/outline.git
synced 2026-08-03 13:27:25 +03:00
* feat: Add TextPack (.textpack) import support Adds a single-document importer for TextPack, the zipped variant of TextBundle used by Bear, Ulysses, iA Writer and others. Bare .textbundle directories are not supported, as a directory cannot be delivered through a browser file input. The bundle's text entry may use any extension, per the spec, with info.json's type deciding whether it can be read as markdown. Assets are inlined as data URIs for the existing attachment pipeline to pick up, bounded by the attachment size limit and a memory ceiling, and only for media types markdown-it accepts as a link destination. Also fixes an existing bug where a file embedded in an HTML or email import as a data URI was stored in the document as base64 rather than being uploaded as an attachment. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Refactor to individual converters * refactor --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
157 lines
4.9 KiB
TypeScript
157 lines
4.9 KiB
TypeScript
import { observer } from "mobx-react";
|
|
import { ArchiveIcon, DoneIcon, WarningIcon } from "outline-icons";
|
|
import * as React from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { toast } from "sonner";
|
|
import { useTheme } from "styled-components";
|
|
import { errToString } from "@shared/utils/error";
|
|
import Spinner from "@shared/components/Spinner";
|
|
import {
|
|
FileOperationFormat,
|
|
FileOperationState,
|
|
FileOperationType,
|
|
} from "@shared/types";
|
|
import type FileOperation from "~/models/FileOperation";
|
|
import { Action } from "~/components/Actions";
|
|
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
|
import ListItem from "~/components/List/Item";
|
|
import Time from "~/components/Time";
|
|
import useCurrentUser from "~/hooks/useCurrentUser";
|
|
import useStores from "~/hooks/useStores";
|
|
import FileOperationMenu from "~/menus/FileOperationMenu";
|
|
import isCloudHosted from "~/utils/isCloudHosted";
|
|
|
|
type Props = {
|
|
fileOperation: FileOperation;
|
|
};
|
|
|
|
const TerminalStates = [
|
|
FileOperationState.Complete,
|
|
FileOperationState.Error,
|
|
FileOperationState.Expired,
|
|
];
|
|
|
|
const FileOperationListItem = ({ fileOperation }: Props) => {
|
|
const { t } = useTranslation();
|
|
const user = useCurrentUser();
|
|
const theme = useTheme();
|
|
const { dialogs, fileOperations } = useStores();
|
|
|
|
const stateMapping = {
|
|
[FileOperationState.Creating]: t("Processing"),
|
|
[FileOperationState.Uploading]: t("Processing"),
|
|
[FileOperationState.Expired]: t("Expired"),
|
|
[FileOperationState.Complete]: t("Completed"),
|
|
[FileOperationState.Error]: t("Failed"),
|
|
};
|
|
|
|
const iconMapping: Record<FileOperationState, React.JSX.Element> = {
|
|
[FileOperationState.Creating]: <Spinner />,
|
|
[FileOperationState.Uploading]: <Spinner />,
|
|
[FileOperationState.Expired]: <ArchiveIcon color={theme.textTertiary} />,
|
|
[FileOperationState.Complete]: <DoneIcon color={theme.accent} />,
|
|
[FileOperationState.Error]: <WarningIcon color={theme.danger} />,
|
|
};
|
|
|
|
const formatMapping: Record<FileOperationFormat, string> = {
|
|
[FileOperationFormat.JSON]: "JSON",
|
|
[FileOperationFormat.Notion]: "Notion",
|
|
[FileOperationFormat.MarkdownZip]: "Markdown",
|
|
[FileOperationFormat.HTMLZip]: "HTML",
|
|
[FileOperationFormat.TextBundleZip]: "TextBundle",
|
|
[FileOperationFormat.PDF]: "PDF",
|
|
};
|
|
|
|
const format = formatMapping[fileOperation.format];
|
|
const title =
|
|
fileOperation.type === FileOperationType.Import ||
|
|
fileOperation.collectionId ||
|
|
fileOperation.documentId
|
|
? fileOperation.name
|
|
: t("All collections");
|
|
|
|
const handleDelete = React.useCallback(async () => {
|
|
try {
|
|
await fileOperations.delete(fileOperation);
|
|
|
|
if (fileOperation.type === FileOperationType.Import) {
|
|
toast.success(t("Import deleted"));
|
|
} else {
|
|
toast.success(t("Export deleted"));
|
|
}
|
|
} catch (err) {
|
|
toast.error(errToString(err));
|
|
}
|
|
}, [fileOperation, fileOperations, t]);
|
|
|
|
const handleConfirmDelete = React.useCallback(async () => {
|
|
dialogs.openModal({
|
|
title: t("Are you sure you want to delete this import?"),
|
|
content: (
|
|
<ConfirmationDialog
|
|
onSubmit={handleDelete}
|
|
savingText={`${t("Deleting")}…`}
|
|
danger
|
|
>
|
|
{t(
|
|
"Deleting this import will also delete all collections and documents that were created from it. This cannot be undone."
|
|
)}
|
|
</ConfirmationDialog>
|
|
),
|
|
});
|
|
}, [dialogs, t, handleDelete]);
|
|
|
|
const showMenu =
|
|
(fileOperation.type === FileOperationType.Export &&
|
|
TerminalStates.includes(fileOperation.state)) ||
|
|
fileOperation.type === FileOperationType.Import;
|
|
|
|
const selfHostedHelp = isCloudHosted
|
|
? ""
|
|
: `. ${t("Check server logs for more details.")}`;
|
|
|
|
return (
|
|
<ListItem
|
|
title={title}
|
|
image={iconMapping[fileOperation.state]}
|
|
subtitle={
|
|
<>
|
|
{stateMapping[fileOperation.state]} •
|
|
{fileOperation.error && (
|
|
<>
|
|
{fileOperation.error}
|
|
{selfHostedHelp} •
|
|
</>
|
|
)}
|
|
{t(`{{userName}} requested`, {
|
|
userName:
|
|
user.id === fileOperation.user.id
|
|
? t("You")
|
|
: fileOperation.user.name,
|
|
})}
|
|
|
|
<Time dateTime={fileOperation.createdAt} addSuffix shorten />
|
|
{format ? <> • {format}</> : ""}
|
|
{fileOperation.size ? <> • {fileOperation.sizeInMB}</> : ""}
|
|
</>
|
|
}
|
|
actions={
|
|
showMenu && (
|
|
<Action>
|
|
<FileOperationMenu
|
|
fileOperation={fileOperation}
|
|
onDelete={
|
|
fileOperation.type === FileOperationType.Import
|
|
? handleConfirmDelete
|
|
: handleDelete
|
|
}
|
|
/>
|
|
</Action>
|
|
)
|
|
}
|
|
/>
|
|
);
|
|
};
|
|
|
|
export default observer(FileOperationListItem);
|