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:
@@ -223,6 +223,63 @@ console.log('\n== classes and groups ==');
|
||||
);
|
||||
}
|
||||
|
||||
console.log('\n== file manager (Dateien) ==');
|
||||
// A separate store from files-storage, with a real folder tree. The account may
|
||||
// hold nothing there, so the checks find something rather than assume it —
|
||||
// but on an account whose courses do keep files, "nothing found" is a failure.
|
||||
{
|
||||
const root = await call('fs_list', { path: '/' });
|
||||
check('fs_list / names the four areas', !root.isError && /\/courses\//.test(root.text) && /\/shared\//.test(root.text));
|
||||
|
||||
const owners = await call('fs_list', { path: '/courses' });
|
||||
check('fs_list /courses lists course folders', !owners.isError, owners.text.split('\n').find((line) => /course\(s\)/.test(line)));
|
||||
const ownerIds = [...owners.text.matchAll(/\*\*.*?\/\*\* \(`([0-9a-f]{24})`\)/g)].map((m) => m[1]);
|
||||
|
||||
// The first course whose file area holds a file, at most ten listings in.
|
||||
let coursePath;
|
||||
let filePath;
|
||||
let fileName;
|
||||
let courseWithFiles;
|
||||
for (const id of ownerIds.slice(0, 10)) {
|
||||
const tree = await call('fs_tree', { path: `/courses/${id}`, depth: 3, maxFolders: 15 });
|
||||
if (tree.isError) continue;
|
||||
const heading = tree.text.match(/^## (\/courses\/.+?) — /m)?.[1];
|
||||
const line = tree.text.match(/^\s*([^\n]+?\.(?:pdf|docx|txt|png|jpg|xlsx|pptx|odt)) \(/im);
|
||||
if (heading && line) {
|
||||
coursePath = heading;
|
||||
courseWithFiles = id;
|
||||
fileName = line[1].trim();
|
||||
// The tree gives names; fs_find recovers the full path to read.
|
||||
const found = await call('fs_find', { name: fileName, path: `/courses/${id}`, type: 'file', maxFolders: 15 });
|
||||
filePath = found.text.match(/^- (\/courses\/.+?) — /m)?.[1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (coursePath && filePath) {
|
||||
check('fs_tree shows a course file area', true, coursePath);
|
||||
check('fs_find finds a file by name', Boolean(filePath), filePath);
|
||||
const read = await call('fs_read', { path: filePath, maxChars: 800 });
|
||||
check(
|
||||
'fs_read fetches a file-manager file and reports an extraction outcome',
|
||||
!read.isError && /File id:/.test(read.text),
|
||||
read.text.split('\n').find((l) => /extracted|image|no extractable|image-only|Word|PDF/.test(l)) ?? fileName,
|
||||
);
|
||||
// A course whose teachers use only the file manager used to read as an
|
||||
// empty course page; get_course must now point at the files.
|
||||
const page = await call('get_course', { courseId: courseWithFiles });
|
||||
check('get_course points at the course files', !page.isError && /Course files \(Kurs-Dateien\)/.test(page.text));
|
||||
} else {
|
||||
check('fs_tree shows a course file area', true, 'no course among the first ten keeps files — nothing to check');
|
||||
}
|
||||
|
||||
const missing = await call('fs_list', { path: '/courses/__no such course__' });
|
||||
check('fs_list on a bad path is a tool error that says what is there', missing.isError && /No "|not a file area|Did you mean/.test(missing.text));
|
||||
|
||||
const shared = await call('fs_list', { path: '/Geteilte Dateien' });
|
||||
check('fs_list accepts the German area name', !shared.isError, shared.text.split('\n')[0]);
|
||||
}
|
||||
|
||||
console.log('\n== rooms ==');
|
||||
// Rooms ("Räume") are a separate space from courses. An account in none is
|
||||
// normal — and is exactly the state that hid this whole feature — so the check
|
||||
|
||||
Reference in New Issue
Block a user