mirror of
https://github.com/outline/outline.git
synced 2026-08-03 13:27:25 +03:00
feat: Import file dialog (#13228)
* feat: Replace import document file picker with a modal Choosing "Import document" now opens a dialog that lists the supported file formats with an icon for each, and accepts files either dropped onto it or chosen with the system file picker. Also closes the gaps between the formats the dialog advertises and those the importer actually accepts: - `.tsv` was offered by the file picker but rejected by the server, it is now converted to a table like CSV - `.csv` and `.htm` are now recognised by extension, so they still import when the browser reports an unhelpful mime type - `.markdown` is now offered by the file picker, it was already supported Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dj1UnjnRX336CQcdzw3ott * Add intermediate dialog for file import * tweaks, polish * Update failure message * test --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
396ac980a8
commit
8b35f7bd17
@@ -22,7 +22,6 @@ import {
|
||||
UnsubscribeIcon,
|
||||
} from "outline-icons";
|
||||
import { toast } from "sonner";
|
||||
import { errToString } from "@shared/utils/error";
|
||||
import Collection from "~/models/Collection";
|
||||
import { CollectionEdit } from "~/components/Collection/CollectionEdit";
|
||||
import { CollectionNew } from "~/components/Collection/CollectionNew";
|
||||
@@ -31,6 +30,7 @@ import CollectionDuplicateDialog from "~/components/CollectionDuplicateDialog";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import { DialogTitle } from "~/components/DialogTitle";
|
||||
import DynamicCollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { ImportDocumentDialog } from "~/components/ImportDocumentDialog";
|
||||
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
|
||||
import {
|
||||
createAction,
|
||||
@@ -46,7 +46,6 @@ import {
|
||||
searchPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
import ExportDialog from "~/components/ExportDialog";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import { isMobile } from "@shared/utils/browser";
|
||||
import history from "~/utils/history";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
@@ -177,44 +176,27 @@ export const duplicateCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const importDocument = createAction({
|
||||
name: ({ t }) => t("Import document"),
|
||||
export const importDocument = dialogActionFactory({
|
||||
analyticsName: "Import document",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <ImportIcon />,
|
||||
name: (t) => `${t("Import documents")}…`,
|
||||
title: (t, { getActiveModel }) => (
|
||||
<DialogTitle
|
||||
title={t("Import documents")}
|
||||
model={getActiveModel(Collection)}
|
||||
/>
|
||||
),
|
||||
content: (onSubmit, { getActiveModel }) => {
|
||||
const collection = getActiveModel(Collection);
|
||||
return collection ? (
|
||||
<ImportDocumentDialog collectionId={collection.id} onSubmit={onSubmit} />
|
||||
) : null;
|
||||
},
|
||||
visible: ({ getActivePolicies }) =>
|
||||
getActivePolicies(Collection).some(
|
||||
(policy) => policy.abilities.createDocument
|
||||
),
|
||||
perform: ({ t, getActiveModel, stores }) => {
|
||||
const { documents } = stores;
|
||||
const collection = getActiveModel(Collection);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = documents.importFileTypesString;
|
||||
|
||||
input.onchange = async (ev) => {
|
||||
const files = getEventFiles(ev);
|
||||
const file = files[0];
|
||||
const toastId = toast.loading(`${t("Uploading")}…`);
|
||||
|
||||
try {
|
||||
const document = await documents.import(file, null, collection.id, {
|
||||
publish: true,
|
||||
});
|
||||
history.push(document.path);
|
||||
} catch (err) {
|
||||
toast.error(errToString(err));
|
||||
} finally {
|
||||
toast.dismiss(toastId);
|
||||
}
|
||||
};
|
||||
|
||||
input.click();
|
||||
},
|
||||
});
|
||||
|
||||
export const sortCollection = createActionWithChildren({
|
||||
|
||||
@@ -14,6 +14,7 @@ import { createAction } from "..";
|
||||
* @param content - renders the dialog, given a handler to close it and the
|
||||
* action context.
|
||||
* @param name - the menu item label, defaults to the title with an ellipsis.
|
||||
* Required when the title is not a plain string.
|
||||
* @param icon - optional icon for the menu item.
|
||||
* @param keywords - optional additional search terms for the command bar.
|
||||
* @param visible - optional visibility predicate.
|
||||
@@ -37,19 +38,33 @@ export const dialogActionFactory = ({
|
||||
}: {
|
||||
analyticsName: string;
|
||||
section: Action["section"];
|
||||
title: (t: TFunction, context: ActionContext) => string;
|
||||
content: (onSubmit: () => void, context: ActionContext) => React.ReactNode;
|
||||
name?: (t: TFunction) => string;
|
||||
icon?: React.ReactNode;
|
||||
keywords?: string;
|
||||
visible?: Action["visible"];
|
||||
dangerous?: boolean;
|
||||
width?: string | number;
|
||||
stopEvent?: boolean;
|
||||
}) =>
|
||||
} & (
|
||||
| {
|
||||
title: (t: TFunction, context: ActionContext) => string;
|
||||
name?: (t: TFunction) => string;
|
||||
}
|
||||
| {
|
||||
title: (t: TFunction, context: ActionContext) => React.ReactNode;
|
||||
name: (t: TFunction) => string;
|
||||
}
|
||||
)) =>
|
||||
createAction({
|
||||
name: (context) =>
|
||||
name ? name(context.t) : `${title(context.t, context)}…`,
|
||||
// The title is only used as a label when it's a plain string, the type
|
||||
// requires a name otherwise.
|
||||
name: (context) => {
|
||||
if (name) {
|
||||
return name(context.t);
|
||||
}
|
||||
const value = title(context.t, context);
|
||||
return typeof value === "string" ? `${value}…` : "";
|
||||
},
|
||||
analyticsName,
|
||||
section,
|
||||
icon,
|
||||
|
||||
@@ -37,12 +37,10 @@ import {
|
||||
SplitIcon,
|
||||
} from "outline-icons";
|
||||
import { toast } from "sonner";
|
||||
import { errToString } from "@shared/utils/error";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import type { NavigationNode } from "@shared/types";
|
||||
import { ExportContentType } from "@shared/types";
|
||||
import { isMobile } from "@shared/utils/browser";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import { Week } from "@shared/utils/time";
|
||||
import type UserMembership from "~/models/UserMembership";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
@@ -56,6 +54,7 @@ import { DialogTitle } from "~/components/DialogTitle";
|
||||
import DocumentCopy from "~/components/DocumentExplorer/DocumentCopy";
|
||||
import { DocumentDownload } from "~/components/DocumentDownload";
|
||||
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
|
||||
import { ImportDocumentDialog } from "~/components/ImportDocumentDialog";
|
||||
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
|
||||
import DocumentTemplatizeDialog from "~/components/TemplatizeDialog";
|
||||
import {
|
||||
@@ -64,6 +63,7 @@ import {
|
||||
createActionWithChildren,
|
||||
createInternalLinkAction,
|
||||
} from "~/actions";
|
||||
import { dialogActionFactory } from "~/actions/definitions/common";
|
||||
import {
|
||||
ActiveDocumentSection,
|
||||
DocumentSection,
|
||||
@@ -1125,12 +1125,44 @@ export const presentDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const importDocument = createAction({
|
||||
name: ({ t }) => t("Import document"),
|
||||
/**
|
||||
* Returns the document or collection that an import will be nested inside.
|
||||
*
|
||||
* @param context - the action context.
|
||||
* @returns the parent model, if it is loaded.
|
||||
*/
|
||||
function getImportParent({
|
||||
activeDocumentId,
|
||||
activeCollectionId,
|
||||
stores,
|
||||
}: ActionContext) {
|
||||
if (activeDocumentId) {
|
||||
return stores.documents.get(activeDocumentId);
|
||||
}
|
||||
return activeCollectionId
|
||||
? stores.collections.get(activeCollectionId)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export const importDocument = dialogActionFactory({
|
||||
analyticsName: "Import document",
|
||||
section: DocumentSection,
|
||||
icon: <ImportIcon />,
|
||||
keywords: "upload",
|
||||
name: (t) => `${t("Import documents")}…`,
|
||||
title: (t, context) => (
|
||||
<DialogTitle
|
||||
title={t("Import documents")}
|
||||
model={getImportParent(context)}
|
||||
/>
|
||||
),
|
||||
content: (onSubmit, { activeDocumentId, activeCollectionId }) => (
|
||||
<ImportDocumentDialog
|
||||
documentId={activeDocumentId}
|
||||
collectionId={activeCollectionId}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
),
|
||||
visible: ({ activeCollectionId, activeDocumentId, stores }) => {
|
||||
if (activeDocumentId) {
|
||||
return !!stores.policies.abilities(activeDocumentId).createChildDocument;
|
||||
@@ -1142,36 +1174,6 @@ export const importDocument = createAction({
|
||||
|
||||
return false;
|
||||
},
|
||||
perform: ({ t, activeDocumentId, activeCollectionId, stores }) => {
|
||||
const { documents } = stores;
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = documents.importFileTypesString;
|
||||
|
||||
input.onchange = async (ev) => {
|
||||
const files = getEventFiles(ev);
|
||||
const file = files[0];
|
||||
const toastId = toast.loading(`${t("Uploading")}…`);
|
||||
|
||||
try {
|
||||
const document = await documents.import(
|
||||
file,
|
||||
activeDocumentId,
|
||||
activeCollectionId,
|
||||
{
|
||||
publish: true,
|
||||
}
|
||||
);
|
||||
history.push(document.url);
|
||||
} catch (err) {
|
||||
toast.error(errToString(err));
|
||||
} finally {
|
||||
toast.dismiss(toastId);
|
||||
}
|
||||
};
|
||||
|
||||
input.click();
|
||||
},
|
||||
});
|
||||
|
||||
export const createTemplateFromDocument = createAction({
|
||||
|
||||
@@ -13,7 +13,7 @@ type Props = {
|
||||
/** The title of the dialog. */
|
||||
title: React.ReactNode;
|
||||
/** The document or collection that the dialog acts upon. */
|
||||
model: Document | Collection;
|
||||
model?: Document | Collection;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -27,6 +27,11 @@ export const DialogTitle = observer(function DialogTitle_({
|
||||
model,
|
||||
}: Props) {
|
||||
const { ui } = useStores();
|
||||
|
||||
if (!model) {
|
||||
return <>{title}</>;
|
||||
}
|
||||
|
||||
const isDocument = model instanceof Document;
|
||||
|
||||
if (
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import styled, { css } from "styled-components";
|
||||
import { hover, s } from "@shared/styles";
|
||||
|
||||
/**
|
||||
* A dashed drop target for choosing files, highlighted while a file is dragged
|
||||
* over it.
|
||||
*/
|
||||
export const DropzoneContainer = styled.div<{
|
||||
$isDragActive: boolean;
|
||||
$disabled?: boolean;
|
||||
}>`
|
||||
background: ${(props) =>
|
||||
props.$isDragActive
|
||||
? props.theme.backgroundSecondary
|
||||
: props.theme.background};
|
||||
border-radius: 8px;
|
||||
border: 1px dashed ${s("divider")};
|
||||
padding: 44px 24px;
|
||||
text-align: center;
|
||||
font-size: 15px;
|
||||
cursor: var(--pointer);
|
||||
opacity: ${(props) => (props.$disabled ? 0.5 : 1)};
|
||||
|
||||
&: ${hover} {
|
||||
background: ${s("backgroundSecondary")};
|
||||
}
|
||||
`;
|
||||
|
||||
/** Styles the icon shown inside a dropzone as a colored badge. */
|
||||
export const dropzoneIcon = css`
|
||||
padding: 4px;
|
||||
border-radius: 50%;
|
||||
background: ${(props) => props.theme.brand.blue};
|
||||
color: white;
|
||||
`;
|
||||
@@ -0,0 +1,61 @@
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = {
|
||||
/** A short label rendered inside the badge, four characters or less */
|
||||
label: string;
|
||||
/** The size of the icon, 24px is default to match standard icons */
|
||||
size?: number;
|
||||
/** The color of the icon, defaults to the current text color */
|
||||
color?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A generic file format icon, a badge outline with the format written inside.
|
||||
* It shares the badge outline of the Markdown icon so a group of format icons
|
||||
* reads as one set.
|
||||
*
|
||||
* @param props The label to display and optional size and color.
|
||||
* @returns an icon representing a file format.
|
||||
*/
|
||||
export function FileFormatIcon({
|
||||
label,
|
||||
size = 24,
|
||||
color = "currentColor",
|
||||
...rest
|
||||
}: Props) {
|
||||
return (
|
||||
<Svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
{...rest}
|
||||
>
|
||||
<path
|
||||
d="M19.2692 7H3.86538C3.38745 7 3 7.38476 3 7.85938V16.2812C3 16.7559 3.38745 17.1406 3.86538 17.1406H19.2692C19.7472 17.1406 20.1346 16.7559 20.1346 16.2812V7.85938C20.1346 7.38476 19.7472 7 19.2692 7Z"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<text
|
||||
x="11.57"
|
||||
y="12.07"
|
||||
fill={color}
|
||||
fontSize="7"
|
||||
fontWeight="700"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
textLength={label.length > 2 ? 13 : undefined}
|
||||
lengthAdjust="spacingAndGlyphs"
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
const Svg = styled.svg`
|
||||
user-select: none;
|
||||
`;
|
||||
@@ -0,0 +1,144 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { ImportIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import Dropzone from "react-dropzone";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import {
|
||||
DropzoneContainer,
|
||||
dropzoneIcon,
|
||||
} from "~/components/DropzoneContainer";
|
||||
import Flex from "~/components/Flex";
|
||||
import { FileFormatIcon } from "~/components/Icons/FileFormatIcon";
|
||||
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
|
||||
import Text from "~/components/Text";
|
||||
import useImportDocument from "~/hooks/useImportDocument";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
/** The collection to import the documents into. */
|
||||
collectionId?: string | null;
|
||||
/** The document to import the documents as children of. */
|
||||
documentId?: string;
|
||||
/** Called once files have been chosen and the import has started. */
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* A dialog that accepts documents to import, either dropped onto it or chosen
|
||||
* with the system file picker, and lists the file formats that are supported.
|
||||
*/
|
||||
export const ImportDocumentDialog = observer(function ImportDocumentDialog({
|
||||
collectionId,
|
||||
documentId,
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { documents } = useStores();
|
||||
const { handleFiles } = useImportDocument(collectionId, documentId);
|
||||
|
||||
const formats = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
name: t("Markdown"),
|
||||
extensions: ".md, .markdown",
|
||||
icon: <MarkdownIcon size={28} />,
|
||||
},
|
||||
{
|
||||
name: t("Word"),
|
||||
extensions: ".docx",
|
||||
icon: <FileFormatIcon label="DOC" size={28} />,
|
||||
},
|
||||
{
|
||||
name: "HTML",
|
||||
extensions: ".html, .htm",
|
||||
icon: <FileFormatIcon label="HTM" size={28} />,
|
||||
},
|
||||
{
|
||||
name: t("Plain text"),
|
||||
extensions: ".txt",
|
||||
icon: <FileFormatIcon label="TXT" size={28} />,
|
||||
},
|
||||
{
|
||||
name: "CSV",
|
||||
extensions: ".csv, .tsv",
|
||||
icon: <FileFormatIcon label="CSV" size={28} />,
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const handleDropAccepted = React.useCallback(
|
||||
(files: File[]) => {
|
||||
// Close the dialog before importing, progress is reported with a toast
|
||||
// and a successful import navigates to the new document.
|
||||
onSubmit();
|
||||
void handleFiles(files);
|
||||
},
|
||||
[handleFiles, onSubmit]
|
||||
);
|
||||
|
||||
const handleDropRejected = React.useCallback(() => {
|
||||
toast.error(t("This file type is not supported"));
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<Flex gap={20} column>
|
||||
<Dropzone
|
||||
accept={documents.importFileTypesString}
|
||||
onDropAccepted={handleDropAccepted}
|
||||
onDropRejected={handleDropRejected}
|
||||
multiple
|
||||
>
|
||||
{({ getRootProps, getInputProps, isDragActive }) => (
|
||||
<DropzoneContainer {...getRootProps()} $isDragActive={isDragActive}>
|
||||
<input {...getInputProps()} />
|
||||
<Flex align="center" justify="center" gap={8} column>
|
||||
<Icon size={32} color="#fff" />
|
||||
<Text type="secondary">
|
||||
{t(
|
||||
"Drag and drop files here, or click to choose from your computer"
|
||||
)}
|
||||
</Text>
|
||||
</Flex>
|
||||
</DropzoneContainer>
|
||||
)}
|
||||
</Dropzone>
|
||||
<Flex gap={8} column>
|
||||
<Text size="xsmall" weight="bold" type="tertiary">
|
||||
{t("Supported formats")}
|
||||
</Text>
|
||||
<Formats>
|
||||
{formats.map((format) => (
|
||||
<Format key={format.extensions} align="center" gap={8}>
|
||||
{format.icon}
|
||||
<Flex column>
|
||||
<Text size="small">{format.name}</Text>
|
||||
<Text size="xsmall" type="tertiary">
|
||||
{format.extensions}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Format>
|
||||
))}
|
||||
</Formats>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
const Icon = styled(ImportIcon)`
|
||||
${dropzoneIcon}
|
||||
`;
|
||||
|
||||
const Formats = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 12px;
|
||||
`;
|
||||
|
||||
const Format = styled(Flex)`
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
`;
|
||||
@@ -49,9 +49,7 @@ function DropToImport({ disabled, children, collectionId, documentId }: Props) {
|
||||
const canDocument = usePolicy(documentId);
|
||||
|
||||
const handleRejection = useCallback(() => {
|
||||
toast.error(
|
||||
t("Document not supported – try Markdown, Plain text, HTML, or Word")
|
||||
);
|
||||
toast.error(t("This file type is not supported"));
|
||||
}, [t]);
|
||||
|
||||
if (
|
||||
|
||||
@@ -25,9 +25,7 @@ const DropToImport: React.FC<Props> = ({
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleRejection = React.useCallback(() => {
|
||||
toast.error(
|
||||
t("Document not supported – try Markdown, Plain text, HTML, or Word")
|
||||
);
|
||||
toast.error(t("This file type is not supported"));
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { errToString } from "@shared/utils/error";
|
||||
import { s } from "@shared/styles";
|
||||
import {
|
||||
AttachmentPreset,
|
||||
CollectionPermission,
|
||||
@@ -14,6 +13,10 @@ import {
|
||||
} from "@shared/types";
|
||||
import { bytesToHumanReadable } from "@shared/utils/files";
|
||||
import Button from "~/components/Button";
|
||||
import {
|
||||
DropzoneContainer,
|
||||
dropzoneIcon,
|
||||
} from "~/components/DropzoneContainer";
|
||||
import Flex from "~/components/Flex";
|
||||
import { InputSelectPermission } from "~/components/InputSelectPermission";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
@@ -104,7 +107,7 @@ function DropToImport({ disabled, onSubmit, children, service }: Props) {
|
||||
disabled={isImporting}
|
||||
>
|
||||
{({ getRootProps, getInputProps, isDragActive }) => (
|
||||
<DropzoneContainer
|
||||
<Container
|
||||
{...getRootProps()}
|
||||
$disabled={isImporting}
|
||||
$isDragActive={isDragActive}
|
||||
@@ -117,7 +120,7 @@ function DropToImport({ disabled, onSubmit, children, service }: Props) {
|
||||
? t(`${file.name} (${bytesToHumanReadable(file.size)})`)
|
||||
: children}
|
||||
</Flex>
|
||||
</DropzoneContainer>
|
||||
</Container>
|
||||
)}
|
||||
</Dropzone>
|
||||
</Text>
|
||||
@@ -149,31 +152,11 @@ function DropToImport({ disabled, onSubmit, children, service }: Props) {
|
||||
}
|
||||
|
||||
const Icon = styled(NewDocumentIcon)`
|
||||
padding: 4px;
|
||||
border-radius: 50%;
|
||||
background: ${(props) => props.theme.brand.blue};
|
||||
color: white;
|
||||
${dropzoneIcon}
|
||||
`;
|
||||
|
||||
const DropzoneContainer = styled.div<{
|
||||
$disabled: boolean;
|
||||
$isDragActive: boolean;
|
||||
}>`
|
||||
background: ${(props) =>
|
||||
props.$isDragActive
|
||||
? props.theme.backgroundSecondary
|
||||
: props.theme.background};
|
||||
border-radius: 8px;
|
||||
border: 1px dashed ${s("divider")};
|
||||
const Container = styled(DropzoneContainer)`
|
||||
padding: 52px;
|
||||
text-align: center;
|
||||
font-size: 15px;
|
||||
cursor: var(--pointer);
|
||||
opacity: ${(props) => (props.$disabled ? 0.5 : 1)};
|
||||
|
||||
&:hover {
|
||||
background: ${s("backgroundSecondary")};
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(DropToImport);
|
||||
|
||||
@@ -58,10 +58,16 @@ export default class DocumentsStore extends Store<Document> {
|
||||
|
||||
importFileTypes: string[] = [
|
||||
".md",
|
||||
".markdown",
|
||||
".doc",
|
||||
".docx",
|
||||
".txt",
|
||||
".htm",
|
||||
".html",
|
||||
".csv",
|
||||
".tsv",
|
||||
"text/csv",
|
||||
"text/tab-separated-values",
|
||||
"text/markdown",
|
||||
"text/plain",
|
||||
"text/html",
|
||||
|
||||
@@ -85,9 +85,9 @@ describe("documentImporter", () => {
|
||||
expect(response.title).toEqual("images");
|
||||
});
|
||||
|
||||
it("should error when a file with application/octet-stream mimetype doesn't have .docx extension", async () => {
|
||||
it("should error when a file with application/octet-stream mimetype has an unsupported extension", async () => {
|
||||
const user = await buildUser();
|
||||
const fileName = "normal.docx.txt";
|
||||
const fileName = "corrupt.zip";
|
||||
const content = await fs.readFile(
|
||||
path.resolve(__dirname, "..", "test", "fixtures", fileName)
|
||||
);
|
||||
|
||||
@@ -57,7 +57,10 @@ async function documentImporter({
|
||||
"docx",
|
||||
"md",
|
||||
"markdown",
|
||||
"htm",
|
||||
"html",
|
||||
"csv",
|
||||
"tsv",
|
||||
...(mime.extensions[mimeType] ?? []),
|
||||
];
|
||||
const fileTitle = fileName.replace(
|
||||
|
||||
@@ -94,6 +94,55 @@ Jane,24,`;
|
||||
// Jane's row should have 3 columns (empty city preserved)
|
||||
expect(result.text).toMatch(/\| Jane \| 24\s*\|\s*\|/);
|
||||
});
|
||||
|
||||
it("should convert csv when the mime type is not recognized", async () => {
|
||||
const csv = `name,age
|
||||
John,25`;
|
||||
|
||||
const result = await DocumentConverter.convert(
|
||||
csv,
|
||||
"test.csv",
|
||||
"application/vnd.ms-excel"
|
||||
);
|
||||
|
||||
expect(result.text).toContain("| name | age |");
|
||||
expect(result.text).toContain("John");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tsv", () => {
|
||||
it("should convert tsv to markdown table", async () => {
|
||||
const tsv = "name\tage\nJohn\t25\nJane\t24";
|
||||
|
||||
const result = await DocumentConverter.convert(
|
||||
tsv,
|
||||
"test.tsv",
|
||||
"text/tab-separated-values"
|
||||
);
|
||||
|
||||
expect(result.text).toContain("| name | age |");
|
||||
expect(result.text).toContain("John");
|
||||
expect(result.text).toContain("Jane");
|
||||
});
|
||||
|
||||
it("should convert tsv when the mime type is not recognized", async () => {
|
||||
const tsv = "name\tage\nJohn\t25";
|
||||
|
||||
const result = await DocumentConverter.convert(tsv, "test.tsv", "");
|
||||
|
||||
expect(result.text).toContain("| name | age |");
|
||||
expect(result.text).toContain("John");
|
||||
});
|
||||
});
|
||||
|
||||
describe("txt", () => {
|
||||
it("should convert txt when the mime type is not recognized", async () => {
|
||||
const txt = "Plain text content";
|
||||
|
||||
const result = await DocumentConverter.convert(txt, "test.txt", "");
|
||||
|
||||
expect(result.text).toContain("Plain text content");
|
||||
});
|
||||
});
|
||||
|
||||
describe("html", () => {
|
||||
@@ -121,6 +170,14 @@ Jane,24,`;
|
||||
expect(result.icon).toEqual("🚀");
|
||||
expect(result.text).not.toMatch(/^🚀/);
|
||||
});
|
||||
|
||||
it("should convert htm when the mime type is not recognized", async () => {
|
||||
const html = "<h1>My Title</h1><p>Content here</p>";
|
||||
const result = await DocumentConverter.convert(html, "test.HTM", "");
|
||||
|
||||
expect(result.title).toEqual("My Title");
|
||||
expect(result.text).toContain("Content here");
|
||||
});
|
||||
});
|
||||
|
||||
describe("markdown", () => {
|
||||
|
||||
@@ -218,8 +218,9 @@ export class DocumentConverter {
|
||||
}
|
||||
|
||||
// Try to convert based on the file extension
|
||||
const extension = fileName.split(".").pop();
|
||||
const extension = fileName.split(".").pop()?.toLowerCase();
|
||||
switch (extension) {
|
||||
case "htm":
|
||||
case "html":
|
||||
return typeof content === "string" ? content : content.toString("utf8");
|
||||
case "docx":
|
||||
@@ -250,14 +251,19 @@ export class DocumentConverter {
|
||||
markdown = this.bufferToString(content);
|
||||
break;
|
||||
case "text/csv":
|
||||
case "text/tab-separated-values":
|
||||
return this.csvToMarkdown(content);
|
||||
default: {
|
||||
const extension = fileName.split(".").pop();
|
||||
const extension = fileName.split(".").pop()?.toLowerCase();
|
||||
switch (extension) {
|
||||
case "md":
|
||||
case "markdown":
|
||||
case "txt":
|
||||
markdown = this.bufferToString(content);
|
||||
break;
|
||||
case "csv":
|
||||
case "tsv":
|
||||
return this.csvToMarkdown(content);
|
||||
default:
|
||||
throw FileImportError(`File type ${mimeType} not supported`);
|
||||
}
|
||||
|
||||
@@ -16,8 +16,7 @@
|
||||
"Share this collection": "Share this collection",
|
||||
"Duplicate": "Duplicate",
|
||||
"Duplicate collection": "Duplicate collection",
|
||||
"Import document": "Import document",
|
||||
"Uploading": "Uploading",
|
||||
"Import documents": "Import documents",
|
||||
"Sort in sidebar": "Sort in sidebar",
|
||||
"A-Z sort": "A-Z sort",
|
||||
"Z-A sort": "Z-A sort",
|
||||
@@ -336,6 +335,7 @@
|
||||
"Please enter a name for the emoji": "Please enter a name for the emoji",
|
||||
"Please select an image file": "Please select an image file",
|
||||
"Emoji created successfully": "Emoji created successfully",
|
||||
"Uploading": "Uploading",
|
||||
"Add emoji": "Add emoji",
|
||||
"Square images with transparent backgrounds work best. If your image is too large, we'll try to resize it for you.": "Square images with transparent backgrounds work best. If your image is too large, we'll try to resize it for you.",
|
||||
"Upload an image": "Upload an image",
|
||||
@@ -396,6 +396,11 @@
|
||||
"Symbols": "Symbols",
|
||||
"Flags": "Flags",
|
||||
"Custom": "Custom",
|
||||
"Word": "Word",
|
||||
"Plain text": "Plain text",
|
||||
"This file type is not supported": "This file type is not supported",
|
||||
"Drag and drop files here, or click to choose from your computer": "Drag and drop files here, or click to choose from your computer",
|
||||
"Supported formats": "Supported formats",
|
||||
"View only": "View only",
|
||||
"Can edit": "Can edit",
|
||||
"No access": "No access",
|
||||
@@ -541,7 +546,6 @@
|
||||
"No collections": "No collections",
|
||||
"Collapse": "Collapse",
|
||||
"Expand": "Expand",
|
||||
"Document not supported – try Markdown, Plain text, HTML, or Word": "Document not supported – try Markdown, Plain text, HTML, or Word",
|
||||
"Import files": "Import files",
|
||||
"Recent": "Recent",
|
||||
"Go back": "Go back",
|
||||
|
||||
Reference in New Issue
Block a user