Skip to content

Commit d823f39

Browse files
committed
✨ Import of documents
Import of (markdown) documents. See #1567 #1569.
1 parent 3ab01c9 commit d823f39

File tree

7 files changed

+115
-5
lines changed

7 files changed

+115
-5
lines changed

Makefile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ logs: ## display app-dev logs (follow mode)
213213
.PHONY: logs
214214

215215
run-backend: ## Start only the backend application and all needed services
216+
@$(COMPOSE) up --force-recreate -d docspec
216217
@$(COMPOSE) up --force-recreate -d celery-dev
217218
@$(COMPOSE) up --force-recreate -d y-provider-development
218219
@$(COMPOSE) up --force-recreate -d nginx

compose.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,3 +217,8 @@ services:
217217
kc_postgresql:
218218
condition: service_healthy
219219
restart: true
220+
221+
docspec:
222+
image: ghcr.io/docspecio/api:0.2.1
223+
ports:
224+
- "4000:4000"

src/backend/core/api/serializers.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ class DocumentSerializer(ListDocumentSerializer):
187187

188188
content = serializers.CharField(required=False)
189189
websocket = serializers.BooleanField(required=False, write_only=True)
190+
file = serializers.FileField(required=False, write_only=True, allow_null=True)
190191

191192
class Meta:
192193
model = models.Document
@@ -203,6 +204,7 @@ class Meta:
203204
"deleted_at",
204205
"depth",
205206
"excerpt",
207+
"file",
206208
"is_favorite",
207209
"link_role",
208210
"link_reach",

src/backend/core/api/viewsets.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,9 @@
3939
from core.services.ai_services import AIService
4040
from core.services.collaboration_services import CollaborationService
4141
from core.services.converter_services import (
42+
ConversionError,
4243
ServiceUnavailableError as YProviderServiceUnavailableError,
43-
)
44-
from core.services.converter_services import (
4544
ValidationError as YProviderValidationError,
46-
)
47-
from core.services.converter_services import (
4845
YdocConverter,
4946
)
5047
from core.tasks.mail import send_ask_for_access_mail
@@ -503,6 +500,30 @@ def perform_create(self, serializer):
503500
"IN SHARE ROW EXCLUSIVE MODE;"
504501
)
505502

503+
# Remove file from validated_data as it's not a model field
504+
# Process it if present
505+
uploaded_file = serializer.validated_data.pop("file", None)
506+
507+
# If a file is uploaded, convert it to Yjs format and set as content
508+
if uploaded_file:
509+
try:
510+
# Read file content
511+
file_content = uploaded_file.read()
512+
if isinstance(file_content, bytes):
513+
file_content = file_content.decode("utf-8")
514+
515+
516+
# Convert to Yjs format using the file's content type
517+
converter = YdocConverter()
518+
converted_content = converter.convert(
519+
file_content, content_type=uploaded_file.content_type
520+
)
521+
serializer.validated_data["content"] = converted_content
522+
except (ConversionError, UnicodeDecodeError) as err:
523+
raise drf.exceptions.ValidationError(
524+
{"file": ["Could not convert file content"]}
525+
) from err
526+
506527
obj = models.Document.add_root(
507528
creator=self.request.user,
508529
**serializer.validated_data,
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { useMutation, useQueryClient } from '@tanstack/react-query';
2+
3+
import { APIError, errorCauses, fetchAPI } from '@/api';
4+
5+
import { Doc, KEY_LIST_DOC } from '../../doc-management';
6+
7+
export const importDoc = async (file: File): Promise<Doc> => {
8+
const form = new FormData();
9+
form.append('file', file);
10+
11+
const response = await fetchAPI(`documents/`, {
12+
method: 'POST',
13+
body: form,
14+
withoutContentType: true,
15+
});
16+
17+
if (!response.ok) {
18+
throw new APIError('Failed to import the doc', await errorCauses(response));
19+
}
20+
21+
return response.json() as Promise<Doc>;
22+
};
23+
24+
interface ImportDocProps {
25+
onSuccess?: (data: Doc) => void;
26+
onError?: (error: APIError) => void;
27+
}
28+
29+
export function useImportDoc({ onSuccess, onError }: ImportDocProps) {
30+
const queryClient = useQueryClient();
31+
return useMutation<Doc, APIError, File>({
32+
mutationFn: importDoc,
33+
onSuccess: (data) => {
34+
void queryClient.resetQueries({
35+
queryKey: [KEY_LIST_DOC],
36+
});
37+
onSuccess?.(data);
38+
},
39+
onError: (error) => {
40+
onError?.(error);
41+
},
42+
});
43+
}

src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGrid.tsx

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Button } from '@openfun/cunningham-react';
2-
import { useMemo } from 'react';
2+
import { useMemo, useRef } from 'react';
33
import { useTranslation } from 'react-i18next';
44
import { InView } from 'react-intersection-observer';
55
import { css } from 'styled-components';
@@ -9,6 +9,7 @@ import { DocDefaultFilter, useInfiniteDocs } from '@/docs/doc-management';
99
import { useResponsiveStore } from '@/stores';
1010

1111
import { useInfiniteDocsTrashbin } from '../api';
12+
import { useImportDoc } from '../api/useImportDoc';
1213
import { useResponsiveDocGrid } from '../hooks/useResponsiveDocGrid';
1314

1415
import {
@@ -27,6 +28,7 @@ export const DocsGrid = ({
2728

2829
const { isDesktop } = useResponsiveStore();
2930
const { flexLeft, flexRight } = useResponsiveDocGrid();
31+
const importInputRef = useRef<HTMLInputElement>(null);
3032

3133
const {
3234
data,
@@ -75,6 +77,33 @@ export const DocsGrid = ({
7577
title = t('All docs');
7678
}
7779

80+
const resetImportInput = () => {
81+
if (!importInputRef.current) {
82+
return;
83+
}
84+
85+
importInputRef.current.value = '';
86+
};
87+
88+
const { mutate: importDoc } = useImportDoc({
89+
onSuccess: resetImportInput,
90+
onError: resetImportInput,
91+
});
92+
93+
const handleImport = (event: React.ChangeEvent<HTMLInputElement>) => {
94+
console.log(event);
95+
96+
if (!event.target.files || event.target.files.length === 0) {
97+
return;
98+
}
99+
100+
const file = event.target.files[0];
101+
102+
console.log(file);
103+
104+
importDoc(file);
105+
};
106+
78107
return (
79108
<Box
80109
$position="relative"
@@ -107,6 +136,14 @@ export const DocsGrid = ({
107136
{title}
108137
</Text>
109138

139+
<input
140+
type="file"
141+
name="doc"
142+
accept=".md"
143+
onChange={handleImport}
144+
ref={importInputRef}
145+
></input>
146+
110147
{!hasDocs && !loading && (
111148
<Box $padding={{ vertical: 'sm' }} $align="center" $justify="center">
112149
<Text $size="sm" $variation="600" $weight="700">

src/frontend/servers/y-provider/src/handlers/convertHandler.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export const convertHandler = async (
4848
// application/x-www-form-urlencoded is interpreted as Markdown for backward compatibility
4949
if (
5050
contentType === 'text/markdown' ||
51+
contentType === 'text/x-markdown' ||
5152
contentType === 'application/x-www-form-urlencoded'
5253
) {
5354
blocks = await editor.tryParseMarkdownToBlocks(req.body.toString());

0 commit comments

Comments
 (0)