Skip to content

Commit a5311b9

Browse files
emersionclarani
andcommitted
✨(app) add progress bar for Notion import
Co-authored-by: Clara Ni <[email protected]>
1 parent 09f99ee commit a5311b9

File tree

7 files changed

+179
-78
lines changed

7 files changed

+179
-78
lines changed

src/backend/core/api/viewsets.py

Lines changed: 83 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,17 @@
3939
from core.services.ai_services import AIService
4040
from core.services.collaboration_services import CollaborationService
4141
from core.services.converter_services import YdocConverter
42-
from core.services.notion_import import import_notion
42+
from core.services.notion_import import (
43+
ImportedDocument,
44+
build_notion_session,
45+
fetch_all_pages,
46+
import_page,
47+
link_child_page_to_parent,
48+
)
4349
from core.tasks.mail import send_ask_for_access_mail
4450
from core.utils import extract_attachments, filter_descendants
4551

52+
from ..notion_schemas.notion_page import NotionPage
4653
from . import permissions, serializers, utils
4754
from .filters import DocumentFilter, ListDocumentFilter
4855

@@ -2134,7 +2141,7 @@ def _import_notion_doc_content(imported_doc, obj, user):
21342141
obj.save()
21352142

21362143

2137-
def _import_notion_child_page(imported_doc, parent_doc, user, imported_docs_by_page_id):
2144+
def _import_notion_child_page(imported_doc, parent_doc, user, imported_ids):
21382145
obj = parent_doc.add_child(
21392146
creator=user,
21402147
title=imported_doc.page.get_title() or "J'aime les carottes",
@@ -2148,13 +2155,13 @@ def _import_notion_child_page(imported_doc, parent_doc, user, imported_docs_by_p
21482155

21492156
_import_notion_doc_content(imported_doc, obj, user)
21502157

2151-
imported_docs_by_page_id[imported_doc.page.id] = obj
2158+
imported_ids.append(imported_doc.page.id)
21522159

21532160
for child in imported_doc.children:
2154-
_import_notion_child_page(child, obj, user, imported_docs_by_page_id)
2161+
_import_notion_child_page(child, obj, user, imported_ids)
21552162

21562163

2157-
def _import_notion_root_page(imported_doc, user, imported_docs_by_page_id):
2164+
def _import_notion_root_page(imported_doc, user) -> list[str]:
21582165
obj = models.Document.add_root(
21592166
depth=1,
21602167
creator=user,
@@ -2168,23 +2175,82 @@ def _import_notion_root_page(imported_doc, user, imported_docs_by_page_id):
21682175
role=models.RoleChoices.OWNER,
21692176
)
21702177

2171-
_import_notion_doc_content(imported_doc, obj, user)
2178+
imported_ids = [imported_doc.page.id]
21722179

2173-
imported_docs_by_page_id[imported_doc.page.id] = obj
2180+
_import_notion_doc_content(imported_doc, obj, user)
21742181

21752182
for child in imported_doc.children:
2176-
_import_notion_child_page(child, obj, user, imported_docs_by_page_id)
2183+
_import_notion_child_page(child, obj, user, imported_ids)
21772184

2185+
return imported_ids
21782186

2179-
@drf.decorators.api_view(["GET", "POST"]) # TODO: drop GET (used for testing)
2180-
def notion_import_run(request):
2181-
if "notion_token" not in request.session:
2182-
raise drf.exceptions.PermissionDenied()
21832187

2184-
imported_docs = import_notion(request.session["notion_token"])
2188+
def _generate_notion_progress(
2189+
all_pages: list[NotionPage], page_statuses: dict[str, str]
2190+
) -> str:
2191+
raw = json.dumps(
2192+
[
2193+
{
2194+
"title": page.get_title(),
2195+
"status": page_statuses[page.id],
2196+
}
2197+
for page in all_pages
2198+
]
2199+
)
2200+
return f"data: {raw}\n\n"
2201+
21852202

2186-
imported_docs_by_page_id = {}
2187-
for imported_doc in imported_docs:
2188-
_import_notion_root_page(imported_doc, request.user, imported_docs_by_page_id)
2203+
def _notion_import_event_stream(request):
2204+
session = build_notion_session(request.session["notion_token"])
2205+
all_pages = fetch_all_pages(session)
21892206

2190-
return drf.response.Response({"sava": "oui et toi ?"})
2207+
page_statuses = {}
2208+
for page in all_pages:
2209+
page_statuses[page.id] = "pending"
2210+
2211+
yield _generate_notion_progress(all_pages, page_statuses)
2212+
2213+
docs_by_page_id: dict[str, ImportedDocument] = {}
2214+
child_page_blocs_ids_to_parent_page_ids: dict[str, str] = {}
2215+
2216+
for page in all_pages:
2217+
docs_by_page_id[page.id] = import_page(
2218+
session, page, child_page_blocs_ids_to_parent_page_ids
2219+
)
2220+
page_statuses[page.id] = "fetched"
2221+
yield _generate_notion_progress(all_pages, page_statuses)
2222+
2223+
for page in all_pages:
2224+
link_child_page_to_parent(
2225+
page, docs_by_page_id, child_page_blocs_ids_to_parent_page_ids
2226+
)
2227+
2228+
root_docs = [doc for doc in docs_by_page_id.values() if doc.page.is_root()]
2229+
2230+
for root_doc in root_docs:
2231+
imported_ids = _import_notion_root_page(root_doc, request.user)
2232+
for imported_id in imported_ids:
2233+
page_statuses[imported_id] = "imported"
2234+
2235+
yield _generate_notion_progress(all_pages, page_statuses)
2236+
2237+
2238+
class IgnoreClientContentNegotiation(drf.negotiation.BaseContentNegotiation):
2239+
def select_parser(self, request, parsers):
2240+
return parsers[0]
2241+
2242+
def select_renderer(self, request, renderers, format_suffix):
2243+
return (renderers[0], renderers[0].media_type)
2244+
2245+
2246+
class NotionImportRunView(drf.views.APIView):
2247+
content_negotiation_class = IgnoreClientContentNegotiation
2248+
2249+
def get(self, request, format=None):
2250+
if "notion_token" not in request.session:
2251+
raise drf.exceptions.PermissionDenied()
2252+
2253+
# return drf.response.Response({"sava": "oui et toi ?"})
2254+
return StreamingHttpResponse(
2255+
_notion_import_event_stream(request), content_type="text/event-stream"
2256+
)

src/backend/core/notion_schemas/notion_page.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,6 @@ def get_title(self) -> str | None:
5656
# This could be parsed using NotionRichText
5757
rich_text = title_property["title"][0]
5858
return rich_text["plain_text"]
59+
60+
def is_root(self):
61+
return isinstance(self.parent, NotionParentWorkspace)

src/backend/core/services/notion_import.py

Lines changed: 14 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -467,35 +467,18 @@ def import_page(
467467
)
468468

469469

470-
def import_notion(token: str) -> list[ImportedDocument]:
471-
"""Recursively imports all Notion pages and blocks accessible using the given token."""
472-
session = build_notion_session(token)
473-
all_pages = fetch_all_pages(session)
474-
docs_by_page_id: dict[str, ImportedDocument] = {}
475-
child_page_blocs_ids_to_parent_page_ids: dict[str, str] = {}
476-
for page in all_pages:
477-
docs_by_page_id[page.id] = import_page(
478-
session, page, child_page_blocs_ids_to_parent_page_ids
479-
)
480-
481-
root_pages = []
482-
for page in all_pages:
483-
if isinstance(page.parent, NotionParentPage):
484-
docs_by_page_id[page.parent.page_id].children.append(
485-
docs_by_page_id[page.id]
470+
def link_child_page_to_parent(
471+
page: NotionPage,
472+
docs_by_page_id: dict[str, ImportedDocument],
473+
child_page_blocs_ids_to_parent_page_ids: dict[str, str],
474+
):
475+
if isinstance(page.parent, NotionParentPage):
476+
docs_by_page_id[page.parent.page_id].children.append(docs_by_page_id[page.id])
477+
elif isinstance(page.parent, NotionParentBlock):
478+
parent_page_id = child_page_blocs_ids_to_parent_page_ids.get(page.id)
479+
if parent_page_id:
480+
docs_by_page_id[parent_page_id].children.append(docs_by_page_id[page.id])
481+
else:
482+
logger.warning(
483+
f"Page {page.id} has a parent block, but no parent page found."
486484
)
487-
elif isinstance(page.parent, NotionParentBlock):
488-
parent_page_id = child_page_blocs_ids_to_parent_page_ids.get(page.id)
489-
if parent_page_id:
490-
docs_by_page_id[parent_page_id].children.append(
491-
docs_by_page_id[page.id]
492-
)
493-
else:
494-
logger.warning(
495-
f"Page {page.id} has a parent block, but no parent page found."
496-
)
497-
elif isinstance(page.parent, NotionParentWorkspace):
498-
# This is a root page, not a child of another page
499-
root_pages.append(docs_by_page_id[page.id])
500-
501-
return root_pages

src/backend/core/urls.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@
6464
[
6565
path("redirect", viewsets.notion_import_redirect),
6666
path("callback", viewsets.notion_import_callback),
67-
path("run", viewsets.notion_import_run),
67+
path("run", viewsets.NotionImportRunView.as_view()),
6868
]
6969
),
7070
),
Lines changed: 63 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,74 @@
1-
import { useMutation, useQueryClient } from '@tanstack/react-query';
21
import { useRouter } from 'next/navigation';
2+
import { useEffect, useState } from 'react';
33

4-
import { APIError, errorCauses, fetchAPI } from '@/api';
4+
import { baseApiUrl } from '@/api';
55

6-
import { KEY_LIST_DOC } from './useDocs';
6+
type ImportState = {
7+
title: string;
8+
status: 'pending' | 'fetched' | 'imported';
9+
}[];
710

8-
export const importNotion = async (): Promise<void> => {
9-
const response = await fetchAPI('notion_import/run', {
10-
method: 'POST',
11-
});
11+
const computeSuccessPercentage = (importState?: ImportState) => {
12+
if (!importState) {
13+
return 0;
14+
}
15+
if (!importState.length) {
16+
return 100;
17+
}
1218

13-
if (!response.ok) {
14-
throw new APIError(
15-
'Failed to import the Notion',
16-
await errorCauses(response),
17-
);
19+
let fetchedFiles = 0;
20+
let importedFiles = 0;
21+
22+
for (const file of importState) {
23+
if (file.status === 'fetched') {
24+
fetchedFiles += 1;
25+
} else if (file.status === 'imported') {
26+
fetchedFiles += 1;
27+
importedFiles += 1;
28+
}
1829
}
30+
31+
const filesNb = importState.length;
32+
33+
return Math.round(((fetchedFiles + importedFiles) / (2 * filesNb)) * 100);
1934
};
2035

2136
export function useImportNotion() {
2237
const router = useRouter();
23-
const queryClient = useQueryClient();
24-
return useMutation<void, APIError, void>({
25-
mutationFn: importNotion,
26-
onSuccess: () => {
27-
void queryClient.resetQueries({
28-
queryKey: [KEY_LIST_DOC],
29-
});
30-
router.push('/');
31-
},
32-
});
38+
39+
const [importState, setImportState] = useState<ImportState>();
40+
41+
useEffect(() => {
42+
// send the request with an Event Source
43+
const eventSource = new EventSource(
44+
`${baseApiUrl('1.0')}notion_import/run`,
45+
{
46+
withCredentials: true,
47+
},
48+
);
49+
50+
eventSource.onmessage = (event) => {
51+
console.log('hello', event.data);
52+
const files = JSON.parse(event.data as string) as ImportState;
53+
54+
// si tous les fichiers sont chargés, rediriger vers la home page
55+
if (files.some((file) => file.status === 'imported')) {
56+
eventSource.close();
57+
router.push('/');
58+
}
59+
60+
// mettre à jour le state d'import
61+
setImportState(files);
62+
};
63+
64+
return () => {
65+
eventSource.close();
66+
};
67+
// eslint-disable-next-line react-hooks/exhaustive-deps
68+
}, []);
69+
70+
return {
71+
importState,
72+
percentageValue: computeSuccessPercentage(importState),
73+
};
3374
}

src/frontend/apps/impress/src/i18n/translations.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -690,6 +690,9 @@
690690
"No text selected": "Aucun texte sélectionné",
691691
"No versions": "Aucune version",
692692
"Notion import in progress...": "Import Notion en cours...",
693+
"Notion import fetched": "🔄 Page Notion récupérée",
694+
"Notion import imported": "✅️ Importé",
695+
"Notion import pending": "⏸️ En attente",
693696
"OK": "OK",
694697
"Offline ?!": "Hors-ligne ?!",
695698
"Only invited people can access": "Seules les personnes invitées peuvent accéder",

src/frontend/apps/impress/src/pages/import-notion/index.tsx

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Loader } from '@openfun/cunningham-react';
2-
import { ReactElement, useEffect } from 'react';
2+
import { ReactElement } from 'react';
33
import { useTranslation } from 'react-i18next';
44

55
import { Box, Text } from '@/components';
@@ -10,12 +10,7 @@ import { NextPageWithLayout } from '@/types/next';
1010
const Page: NextPageWithLayout = () => {
1111
const { t } = useTranslation();
1212

13-
const { mutate: importNotion } = useImportNotion();
14-
15-
useEffect(() => {
16-
importNotion();
17-
// eslint-disable-next-line react-hooks/exhaustive-deps
18-
}, []);
13+
const { importState, percentageValue } = useImportNotion();
1914

2015
return (
2116
<Box
@@ -31,6 +26,16 @@ const Page: NextPageWithLayout = () => {
3126
{t('Please stay on this page and be patient')}
3227
</Text>
3328
<Loader />
29+
<Text as="p" $margin={{ top: '10px', bottom: '30px' }}>
30+
{percentageValue}%
31+
</Text>
32+
<Box>
33+
{importState?.map((page) => (
34+
<Text
35+
key={page.title}
36+
>{`${page.title} - ${t(`Notion import ${page.status}`)}`}</Text>
37+
))}
38+
</Box>
3439
</Box>
3540
);
3641
};

0 commit comments

Comments
 (0)