feat: support uploading entire folders

Adds a folder picker (webkitdirectory) and drag-and-drop directory
support (File System Entries API), preserving relative paths via
a new optional relative_path field on the upload endpoint.
This commit is contained in:
Tianyang 2026-08-01 16:30:29 +08:00
parent 4c19fad561
commit 4a6fdaf994
7 changed files with 203 additions and 14 deletions

View File

@ -56,10 +56,12 @@ def delete_document(key: str, service: DocumentService = Depends(get_document_se
async def upload_document( async def upload_document(
file: UploadFile = File(...), file: UploadFile = File(...),
path: str = Form(...), path: str = Form(...),
relative_path: str | None = Form(None),
service: DocumentService = Depends(get_document_service), service: DocumentService = Depends(get_document_service),
): ):
data = await file.read() data = await file.read()
key = f"{path.rstrip('/')}/{file.filename}" if path else file.filename name = relative_path if relative_path else file.filename
key = f"{path.rstrip('/')}/{name}" if path else name
content_type = file.content_type or "application/octet-stream" content_type = file.content_type or "application/octet-stream"
try: try:
return service.upload_document(key, data, content_type) return service.upload_document(key, data, content_type)

View File

@ -114,6 +114,17 @@ def test_upload_document_returns_201_with_metadata(client):
assert body["size"] == len(b"# new content") assert body["size"] == len(b"# new content")
def test_upload_document_uses_relative_path_to_preserve_folder_structure(client):
response = client.post(
"/api/upload",
data={"path": "产品文档", "relative_path": "子目录/新文档.md"},
files={"file": ("新文档.md", b"# new content", "text/markdown")},
)
assert response.status_code == 201
body = response.json()
assert body["key"] == "产品文档/子目录/新文档.md"
def test_upload_document_returns_400_for_unsupported_file_type(client): def test_upload_document_returns_400_for_unsupported_file_type(client):
response = client.post( response = client.post(
"/api/upload", "/api/upload",

View File

@ -77,10 +77,17 @@ export interface UploadDocumentResponse {
size: number; size: number;
} }
export async function uploadDocument(file: File, path: string): Promise<UploadDocumentResponse> { export async function uploadDocument(
file: File,
path: string,
relativePath?: string,
): Promise<UploadDocumentResponse> {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
formData.append('path', path); formData.append('path', path);
if (relativePath) {
formData.append('relative_path', relativePath);
}
const response = await fetch(`${API_BASE_URL}/api/upload`, { const response = await fetch(`${API_BASE_URL}/api/upload`, {
method: 'POST', method: 'POST',
body: formData, body: formData,

View File

@ -1,5 +1,6 @@
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { uploadDocument, type UploadDocumentResponse } from '../api/client'; import { uploadDocument, type UploadDocumentResponse } from '../api/client';
import { readFileEntries, type FileWithRelativePath } from '../lib/fileSystemEntry';
interface UploadButtonProps { interface UploadButtonProps {
path: string; path: string;
@ -11,6 +12,14 @@ export default function UploadButton({ path, onUploaded }: UploadButtonProps) {
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null); const [uploadError, setUploadError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const folderInputRef = useRef<HTMLInputElement | null>(null);
function setFolderInputRef(node: HTMLInputElement | null) {
folderInputRef.current = node;
if (node) {
node.setAttribute('webkitdirectory', '');
}
}
function openModal() { function openModal() {
setIsOpen(true); setIsOpen(true);
@ -22,17 +31,22 @@ export default function UploadButton({ path, onUploaded }: UploadButtonProps) {
setUploadError(null); setUploadError(null);
} }
function uploadFiles(files: FileList | File[]) { function uploadOne(entry: FileWithRelativePath) {
Array.from(files).forEach((file) => {
setUploadError(null); setUploadError(null);
uploadDocument(file, path) const promise = entry.relativePath
? uploadDocument(entry.file, path, entry.relativePath)
: uploadDocument(entry.file, path);
promise
.then((result) => { .then((result) => {
onUploaded(result); onUploaded(result);
}) })
.catch((err) => { .catch((err) => {
setUploadError(err instanceof Error ? err.message : '上传失败'); setUploadError(err instanceof Error ? err.message : '上传失败');
}); });
}); }
function uploadFiles(files: FileList | File[]) {
Array.from(files).forEach((file) => uploadOne({ file }));
} }
function handleFileInputChange(event: React.ChangeEvent<HTMLInputElement>) { function handleFileInputChange(event: React.ChangeEvent<HTMLInputElement>) {
@ -42,9 +56,24 @@ export default function UploadButton({ path, onUploaded }: UploadButtonProps) {
event.target.value = ''; event.target.value = '';
} }
function handleDrop(event: React.DragEvent<HTMLDivElement>) { function handleFolderInputChange(event: React.ChangeEvent<HTMLInputElement>) {
if (event.target.files) {
Array.from(event.target.files).forEach((file) =>
uploadOne({ file, relativePath: file.webkitRelativePath || file.name }),
);
}
event.target.value = '';
}
async function handleDrop(event: React.DragEvent<HTMLDivElement>) {
event.preventDefault(); event.preventDefault();
setIsDragOver(false); setIsDragOver(false);
const items = event.dataTransfer.items;
if (items && items.length > 0 && typeof items[0]?.webkitGetAsEntry === 'function') {
const entries = await readFileEntries(items);
entries.forEach(uploadOne);
return;
}
if (event.dataTransfer.files) { if (event.dataTransfer.files) {
uploadFiles(event.dataTransfer.files); uploadFiles(event.dataTransfer.files);
} }
@ -95,7 +124,7 @@ export default function UploadButton({ path, onUploaded }: UploadButtonProps) {
<path d="M6 9l6-6 6 6" /> <path d="M6 9l6-6 6 6" />
<path d="M4 21h16" /> <path d="M4 21h16" />
</svg> </svg>
<div className="hint"> .md .html </div> <div className="hint"> .md .html </div>
</div> </div>
{uploadError && ( {uploadError && (
@ -111,8 +140,18 @@ export default function UploadButton({ path, onUploaded }: UploadButtonProps) {
style={{ display: 'none' }} style={{ display: 'none' }}
onChange={handleFileInputChange} onChange={handleFileInputChange}
/> />
<input
ref={setFolderInputRef}
type="file"
data-testid="upload-folder-input"
style={{ display: 'none' }}
onChange={handleFolderInputChange}
/>
</div> </div>
<div className="modal-footer"> <div className="modal-footer">
<button className="btn-secondary" onClick={() => folderInputRef.current?.click()}>
</button>
<button className="btn-secondary" onClick={closeModal}> <button className="btn-secondary" onClick={closeModal}>
</button> </button>

View File

@ -0,0 +1,52 @@
export interface FileWithRelativePath {
file: File;
relativePath?: string;
}
function readEntries(reader: FileSystemDirectoryReader): Promise<FileSystemEntry[]> {
return new Promise((resolve, reject) => {
reader.readEntries(resolve, reject);
});
}
async function collectAllEntries(reader: FileSystemDirectoryReader): Promise<FileSystemEntry[]> {
const all: FileSystemEntry[] = [];
let batch = await readEntries(reader);
while (batch.length > 0) {
all.push(...batch);
batch = await readEntries(reader);
}
return all;
}
function entryToFile(entry: FileSystemFileEntry): Promise<File> {
return new Promise((resolve, reject) => {
entry.file(resolve, reject);
});
}
async function walkEntry(entry: FileSystemEntry, results: FileWithRelativePath[]): Promise<void> {
if (entry.isFile) {
const file = await entryToFile(entry as FileSystemFileEntry);
results.push({ file, relativePath: entry.fullPath.replace(/^\//, '') });
return;
}
if (entry.isDirectory) {
const reader = (entry as FileSystemDirectoryEntry).createReader();
const children = await collectAllEntries(reader);
for (const child of children) {
await walkEntry(child, results);
}
}
}
export async function readFileEntries(items: DataTransferItemList): Promise<FileWithRelativePath[]> {
const results: FileWithRelativePath[] = [];
const entries = Array.from(items)
.map((item) => item.webkitGetAsEntry())
.filter((entry): entry is FileSystemEntry => entry !== null);
for (const entry of entries) {
await walkEntry(entry, results);
}
return results;
}

View File

@ -90,9 +90,25 @@ test('uploadDocument posts a multipart form with file and path fields', async ()
const body = init.body as FormData; const body = init.body as FormData;
expect(body.get('file')).toBe(file); expect(body.get('file')).toBe(file);
expect(body.get('path')).toBe('产品文档'); expect(body.get('path')).toBe('产品文档');
expect(body.get('relative_path')).toBeNull();
expect(result).toEqual(mockResponse); expect(result).toEqual(mockResponse);
}); });
test('uploadDocument includes relative_path when uploading a file from a folder', async () => {
const mockResponse = { key: '产品文档/子目录/新文档.md', name: '新文档.md', size: 42 };
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
}) as unknown as typeof fetch;
const file = new File(['# 新文档'], '新文档.md', { type: 'text/markdown' });
await uploadDocument(file, '产品文档', '子目录/新文档.md');
const [, init] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
const body = init.body as FormData;
expect(body.get('relative_path')).toBe('子目录/新文档.md');
});
test('search calls GET /api/search with the q query param and returns parsed JSON', async () => { test('search calls GET /api/search with the q query param and returns parsed JSON', async () => {
const mockResponse = { const mockResponse = {
results: [ results: [

View File

@ -58,6 +58,25 @@ test('a failed upload shows an error message and keeps the modal open for retry'
expect(onUploaded).not.toHaveBeenCalled(); expect(onUploaded).not.toHaveBeenCalled();
}); });
test('selecting a folder through the folder input uploads each file with its relative path', async () => {
vi.mocked(uploadDocument).mockResolvedValue({ key: '产品文档/子目录/新文档.md', name: '新文档.md', size: 8 });
const onUploaded = vi.fn();
const user = userEvent.setup();
render(<UploadButton path="产品文档" onUploaded={onUploaded} />);
await user.click(screen.getByRole('button', { name: '上传文档' }));
const file = new File(['content'], '新文档.md', { type: 'text/markdown' });
Object.defineProperty(file, 'webkitRelativePath', { value: '子目录/新文档.md', configurable: true });
const input = screen.getByTestId('upload-folder-input');
await user.upload(input, file);
expect(uploadDocument).toHaveBeenCalledWith(file, '产品文档', '子目录/新文档.md');
await waitFor(() => {
expect(onUploaded).toHaveBeenCalled();
});
});
test('dropping a file onto the dropzone uploads it', async () => { test('dropping a file onto the dropzone uploads it', async () => {
vi.mocked(uploadDocument).mockResolvedValue({ key: '产品文档/拖拽文档.md', name: '拖拽文档.md', size: 10 }); vi.mocked(uploadDocument).mockResolvedValue({ key: '产品文档/拖拽文档.md', name: '拖拽文档.md', size: 10 });
const onUploaded = vi.fn(); const onUploaded = vi.fn();
@ -78,3 +97,46 @@ test('dropping a file onto the dropzone uploads it', async () => {
expect(onUploaded).toHaveBeenCalledWith({ key: '产品文档/拖拽文档.md', name: '拖拽文档.md', size: 10 }); expect(onUploaded).toHaveBeenCalledWith({ key: '产品文档/拖拽文档.md', name: '拖拽文档.md', size: 10 });
}); });
}); });
test('dropping a folder onto the dropzone walks its entries and uploads each file with its relative path', async () => {
vi.mocked(uploadDocument).mockResolvedValue({ key: '产品文档/子目录/文件.md', name: '文件.md', size: 5 });
const onUploaded = vi.fn();
const user = userEvent.setup();
const file = new File(['# hi'], '文件.md', { type: 'text/markdown' });
const fileEntry = {
isFile: true,
isDirectory: false,
fullPath: '/子目录/文件.md',
file: (success: (f: File) => void) => success(file),
};
let readCount = 0;
const dirReader = {
readEntries: (success: (entries: unknown[]) => void) => {
readCount += 1;
success(readCount === 1 ? [fileEntry] : []);
},
};
const dirEntry = {
isFile: false,
isDirectory: true,
fullPath: '/子目录',
createReader: () => dirReader,
};
const item = { webkitGetAsEntry: () => dirEntry };
render(<UploadButton path="产品文档" onUploaded={onUploaded} />);
await user.click(screen.getByRole('button', { name: '上传文档' }));
const dropzone = screen.getByTestId('dropzone');
fireEvent.drop(dropzone, {
dataTransfer: { items: [item], files: [] },
});
await waitFor(() => {
expect(uploadDocument).toHaveBeenCalledWith(file, '产品文档', '子目录/文件.md');
});
await waitFor(() => {
expect(onUploaded).toHaveBeenCalled();
});
});