Browse the file manager ("Dateien") as a filesystem
Many teachers never use topics or boards; their material sits in the course's file area, and the tools answered "0 files" for courses holding dozens of worksheets — 21 of 26 courses on the live account. Persönliche, Kurs-, Team- and Geteilte Dateien live in the legacy file store, not in files-storage, and its service is not in the public ingress. The only way in is the legacy client: HTML listings, and GET /files/signedurl for a pre-signed download. core/legacy-files.ts turns that into one path tree — /my, /courses/<course>, /teams/<team>, /shared — resolving names that contain "/", ids anywhere in a path, and wrong or ambiguous names with a message saying what is there. A listing that does not parse throws; it never reads as an empty folder. Some of the legacy client's GET routes write (GET /files/share/ mints a share token), so getFileManagerPage allows only the listing routes, by pattern. Signed URLs are fetched with no credentials and must be https. - MCP: fs_list, fs_tree, fs_find and fs_read; get_course lists course files. - CLI: schulcloud fs ls, tree, find and get, recursive and resumable. - API: /api/fs/list, tree, find and file. - Index: the crawl walks the file manager (INDEX_FILE_MANAGER, on by default), so search covers the text inside those files and sync mirrors them under <course>/Kurs-Dateien. The local instance gains a fixture for all four areas. It needed a loopback, so signed URLs open from the host, and a pre-created bucket, since MinIO does not implement PutBucketCors. 135 tests. Smoke 55/55 live; 57/57 and 55/55 on the local instance. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,39 @@ export interface Manifest {
|
||||
entries: ManifestEntry[];
|
||||
}
|
||||
|
||||
/** One entry of a file-manager tree or search, as /api/fs returns it. */
|
||||
export interface FsEntry {
|
||||
type: 'directory' | 'file';
|
||||
path: string;
|
||||
parentPath: string;
|
||||
depth: number;
|
||||
id: string;
|
||||
name: string;
|
||||
size?: number;
|
||||
mimeType?: string | null;
|
||||
blocked?: boolean;
|
||||
}
|
||||
|
||||
export interface FsListing {
|
||||
path: string;
|
||||
kind: 'directory' | 'file';
|
||||
area?: string | null;
|
||||
directories?: { id: string; name: string; path: string }[];
|
||||
files?: { id: string; name: string; path: string; size: number; mimeType?: string; blocked: boolean }[];
|
||||
file?: { id: string; name: string; size: number; mimeType?: string; blocked: boolean };
|
||||
}
|
||||
|
||||
export interface FsWalk {
|
||||
path: string;
|
||||
kind: 'directory' | 'file';
|
||||
entries?: FsEntry[];
|
||||
matches?: FsEntry[];
|
||||
file?: FsListing['file'];
|
||||
visited?: number;
|
||||
truncated?: boolean;
|
||||
failures?: { path: string; reason: string }[];
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
@@ -83,6 +116,28 @@ export class ApiClient {
|
||||
async file(fileId: string): Promise<Response> {
|
||||
return this.request(`/api/files/${encodeURIComponent(fileId)}`);
|
||||
}
|
||||
|
||||
// --- the file manager ------------------------------------------------------
|
||||
|
||||
async fsList(path: string): Promise<FsListing> {
|
||||
return (await (await this.request(`/api/fs/list?${new URLSearchParams({ path })}`)).json()) as FsListing;
|
||||
}
|
||||
|
||||
async fsTree(path: string, depth: number, maxFolders: number): Promise<FsWalk> {
|
||||
const query = new URLSearchParams({ path, depth: String(depth), maxFolders: String(maxFolders) });
|
||||
return (await (await this.request(`/api/fs/tree?${query}`)).json()) as FsWalk;
|
||||
}
|
||||
|
||||
async fsFind(name: string, path: string, type: string, maxFolders: number): Promise<FsWalk> {
|
||||
const query = new URLSearchParams({ name, path, type, maxFolders: String(maxFolders) });
|
||||
return (await (await this.request(`/api/fs/find?${query}`)).json()) as FsWalk;
|
||||
}
|
||||
|
||||
/** Streams one file-manager file's bytes, by path or by id. */
|
||||
async fsFile(target: { path: string } | { id: string; name: string }): Promise<Response> {
|
||||
const query = 'path' in target ? new URLSearchParams({ path: target.path }) : new URLSearchParams(target);
|
||||
return this.request(`/api/fs/file?${query}`);
|
||||
}
|
||||
}
|
||||
|
||||
function describe(status: number, detail: string, server: string): string {
|
||||
@@ -90,5 +145,7 @@ function describe(status: number, detail: string, server: string): string {
|
||||
if (status === 503) return 'The server is running without an index, so this command is unavailable. Set DATABASE_URL on the server.';
|
||||
if (status === 409) return detail || 'The sync cursor is unknown to the server. Run a full sync with --full.';
|
||||
if (status === 429) return detail || 'Refreshed too recently — wait a moment, or pass --force.';
|
||||
// The file manager's own errors already say what was not found and what is there.
|
||||
if ((status === 400 || status === 404 || status === 422) && detail) return detail;
|
||||
return detail ? `HTTP ${status}: ${detail}` : `HTTP ${status}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user