Skip to content

Commit

Permalink
✨(frontend) add docs import from outline
Browse files Browse the repository at this point in the history
  • Loading branch information
olaurendeau committed Jan 3, 2025
1 parent e70be6f commit 2f02dbd
Show file tree
Hide file tree
Showing 12 changed files with 913 additions and 9 deletions.
1 change: 1 addition & 0 deletions src/frontend/apps/impress/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"i18next": "24.2.0",
"i18next-browser-languagedetector": "8.0.2",
"idb": "8.0.1",
"jszip": "^3.10.1",
"lodash": "4.17.21",
"luxon": "3.5.0",
"next": "15.1.3",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useRouter } from 'next/router';
import React from 'react';
import { useTranslation } from 'react-i18next';

import { Box } from '@/components';
import { Box, StyledLink } from '@/components';
import { useCreateDoc, useTrans } from '@/features/docs/doc-management/';
import { useResponsiveStore } from '@/stores';

Expand All @@ -28,10 +28,17 @@ export const DocsGridContainer = () => {
return (
<Box $overflow="auto">
<Box
$direction="row"
$align="flex-end"
$justify="center"
$justify="flex-end"
$gap="10px"
$margin={isMobile ? 'small' : 'big'}
>
<StyledLink href="/import">
<Button color="secondary">
{t('Import documents')}
</Button>
</StyledLink>
<Button onClick={handleCreateDoc}>{t('Create a new document')}</Button>
</Box>
<DocsGrid />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { Button, Select } from "@openfun/cunningham-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { fetchAPI } from "@/api";
import { Box } from "@/components";
import styled from 'styled-components';
import { useRouter } from 'next/router';
import { DocToImport } from "../types";
import { OutlineImport } from "./OutlineImport";
import { PreviewDocsImport } from "./PreviewDocsImport";


const ImportContainer = styled.div`
width: 100%;
max-width: 1000px;
margin: 0 auto;
`;

type Source = 'outline';

type ImportState = 'idle' | 'importing' | 'completed';

export const DocsImport = () => {
const { t } = useTranslation();
const router = useRouter();
const [source, setSource] = useState<Source>('outline');
const [extractedDocs, setExtractedDocs] = useState<DocToImport[]>([]);
const [importState, setImportState] = useState<ImportState>('idle');

const updateDocState = (
docs: DocToImport[],
updatedDoc: DocToImport,
parentPath: number[] = []
): DocToImport[] => {
return docs.map((doc, index) => {
if (parentPath[0] === index) {
if (parentPath.length === 1) {
return updatedDoc;
}
return {
...doc,
children: updateDocState(doc.children || [], updatedDoc, parentPath.slice(1))
};
}
return doc;
});
};

const importDocument = async (
doc: DocToImport,
parentId?: string,
parentPath: number[] = []
): Promise<DocToImport> => {
setExtractedDocs(prev =>
updateDocState(
prev,
{ ...doc, state: 'importing' as const },
parentPath
)
);

try {
const response = await fetchAPI('documents/', {
method: 'POST',
body: JSON.stringify({
title: doc.doc.title,
parent: parentId
}),
});

if (!response.ok) {
throw new Error(await response.text());
}

const { id } = await response.json();

const successDoc = {
...doc,
state: 'success' as const,
doc: { ...doc.doc, id }
};

setExtractedDocs(prev =>
updateDocState(prev, successDoc, parentPath)
);

if (doc.children?.length) {
const processedChildren = [];
for (let i = 0; i < doc.children.length; i++) {
const childDoc = await importDocument(doc.children[i], id, [...parentPath, i]);
processedChildren.push(childDoc);
}
successDoc.children = processedChildren;
}

return successDoc;
} catch (error) {
const failedDoc = {
...doc,
state: 'error' as const,
error: error instanceof Error ? error.message : 'Unknown error',
children: doc.children
};

setExtractedDocs(prev =>
updateDocState(prev, failedDoc, parentPath)
);

return failedDoc;
}
};

const handleImport = async () => {
setImportState('importing');
try {
await Promise.all(
extractedDocs.map((doc, index) => importDocument(doc, undefined, [index]))
);
setImportState('completed');
} catch (error) {
console.error('Import failed:', error);
setImportState('idle');
}
};

const handleBackToDocs = () => {
router.push('/docs');
};

const handleReset = () => {
setExtractedDocs([]);
setImportState('idle');
};

return (
<ImportContainer>
<h1 className="text-2xl font-bold mb-6">{t('Import documents')}</h1>
<Box
$margin={{ bottom: 'small' }}
aria-label={t('Import documents from')}
$gap="1.5rem"
>
<Select
clearable={false}
label={t('Source')}
options={[{
label: 'Outline',
value: 'outline',
}]}
value={source}
onChange={(options) =>
setSource(options.target.value as Source)
}
text={t('Import documents from this source')}
/>
{ source === 'outline' && <OutlineImport setExtractedDocs={setExtractedDocs} onNewUpload={handleReset}/> }
<PreviewDocsImport extractedDocs={extractedDocs} />
<Box $display="flex" $gap="medium">
<Button
onClick={handleImport}
fullWidth={true}
disabled={importState !== 'idle'}
active={importState === 'importing'}
>
{t('Import documents')}
</Button>
{importState === 'completed' && (
<Button
onClick={handleBackToDocs}
fullWidth={true}
color="secondary"
>
{t('Back to documents')}
</Button>
)}
</Box>
</Box>
</ImportContainer>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import React from 'react';
import { useTranslation } from 'react-i18next';

import { Box } from '@/components';
import { useResponsiveStore } from '@/stores';
import { DocsImport } from './DocsImport';

export const DocsImportContainer = () => {
const { t } = useTranslation();
const { isMobile } = useResponsiveStore();

return (
<Box $overflow="auto" $padding={isMobile ? 'small' : 'big'}>
<DocsImport />
</Box>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { FileUploader, FieldState } from "@openfun/cunningham-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import JSZip from 'jszip';
import { LinkReach, LinkRole } from "../../doc-management/types";
import { Block } from "@blocknote/core";
import { DocToImport } from "../types";
import { BlockNoteView } from "@blocknote/mantine";
import { useCreateBlockNote } from "@blocknote/react";

const createDocToImport = (fileName: string, content: Block[], error: Error | undefined): DocToImport => ({
doc: {
id: '',
title: fileName.split('.').shift() ?? '',
content: '',
creator: '',
is_favorite: false,
link_reach: LinkReach.AUTHENTICATED,
link_role: LinkRole.EDITOR,
nb_accesses: 0,
created_at: '',
updated_at: '',
abilities: {
accesses_manage: false,
accesses_view: true,
attachment_upload: true,
destroy: false,
link_configuration: false,
partial_update: false,
retrieve: true,
update: false,
versions_destroy: false,
versions_list: false,
versions_retrieve: false,
},
},
content,
state: error ? 'error' : 'pending',
children: [],
error,
});

export const OutlineImport = ({ setExtractedDocs, onNewUpload }: { setExtractedDocs: (docsToImport: DocToImport[]) => void, onNewUpload: () => void }) => {
const { t } = useTranslation();
const editor = useCreateBlockNote();
const [state, setState] = useState<FieldState | "uploading" | undefined>(undefined);

const handleFileUpload = async (file: File) => {
onNewUpload();
try {
setState('uploading');

const zip = new JSZip();
const contents = await zip.loadAsync(file);

const docsMap = new Map<string, DocToImport>();

// Traiter tous les fichiers
await Promise.all(
Object.values(contents.files).map(async (zipEntry) => {
if (!zipEntry.dir && zipEntry.name.endsWith('.md')) {
let content: Block[] = [];
let error: Error | undefined = undefined;
try {
const text = await zipEntry.async('text');
content = await editor?.tryParseMarkdownToBlocks(text) || [];
} catch (e) {
error = e instanceof Error ? e : new Error('Unknown error');
}

const fileName = zipEntry.name.split('/').pop() ?? '';
const docToImport = createDocToImport(fileName, content, error);
docsMap.set(zipEntry.name, docToImport);
}
})
);

// Construire l'arborescence
const rootDocs: DocToImport[] = [];
const paths = Array.from(docsMap.keys()).sort();

paths.forEach((path) => {
const doc = docsMap.get(path)!;
const pathParts = path.split('/');

if (pathParts.length === 1) {
rootDocs.push(doc);
} else {
const parentPath = pathParts.slice(0, -1).join('/');
const parentDoc = docsMap.get(parentPath + '.md');

if (parentDoc) {
parentDoc.children = parentDoc.children || [];
parentDoc.children.push(doc);
} else {
rootDocs.push(doc);
}
}
});

setExtractedDocs(rootDocs);
setState('success');
} catch (error) {
setState('error');
}
};

return <>
<div style={{ display: 'none' }}>
<BlockNoteView editor={editor} editable={false} />
</div>
<FileUploader
data-testid="file-uploader"
bigText={t('Outline exported .zip file')}
state={state}
onChange={(event) => event.target.files?.[0] && handleFileUpload(event.target.files[0])}
/>
</>;
};
Loading

0 comments on commit 2f02dbd

Please sign in to comment.