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:
@@ -7,6 +7,7 @@ import { pipeline } from 'node:stream/promises';
|
||||
import { ApiClient, ApiError } from '../cli/client.ts';
|
||||
import { defaultSyncDir, loadCliConfig, saveCliConfig, configPath } from '../cli/config.ts';
|
||||
import { formatBytes } from '../core/extract.ts';
|
||||
import { fsFind, fsGet, fsList, fsTree } from '../cli/fs.ts';
|
||||
import { sync, type SyncEvent } from '../cli/sync.ts';
|
||||
|
||||
/**
|
||||
@@ -26,6 +27,16 @@ const USAGE = `schulcloud — browse and mirror your Schulcloud files
|
||||
schulcloud sync [--dry-run] [--full] [--prune] [--dir <path>] [--jobs <n>]
|
||||
schulcloud refresh [--course <id>] [--force]
|
||||
|
||||
The file manager ("Dateien") — /my, /courses/<course>, /teams/<team>, /shared:
|
||||
|
||||
schulcloud fs ls [path] [--long]
|
||||
schulcloud fs tree [path] [--depth <n>] [--max-folders <n>]
|
||||
schulcloud fs find <name> [--path <path>] [--type file|folder] [--long]
|
||||
schulcloud fs get <path> [--out <path>] [--force] [--jobs <n>]
|
||||
|
||||
fs get downloads a file, or a folder with everything below it. Names may contain
|
||||
"/" and still resolve; any path segment can also be an id from "fs ls --long".
|
||||
|
||||
--course takes a course or a room id: rooms ("Räume") mirror alongside courses
|
||||
and their files sit under the room's name.
|
||||
|
||||
@@ -58,6 +69,8 @@ async function main(argv: string[]): Promise<number> {
|
||||
return runSync(flags);
|
||||
case 'refresh':
|
||||
return refresh(flags);
|
||||
case 'fs':
|
||||
return fileManager(flags);
|
||||
default:
|
||||
process.stderr.write(`Unknown command "${command}".\n\n${USAGE}`);
|
||||
return 2;
|
||||
@@ -154,6 +167,41 @@ async function get(flags: Flags): Promise<number> {
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function fileManager(flags: Flags): Promise<number> {
|
||||
const [sub, ...args] = flags._ as string[];
|
||||
const api = new ApiClient(await loadCliConfig());
|
||||
const out = (line: string) => process.stdout.write(`${line}\n`);
|
||||
const long = Boolean(flags.long);
|
||||
switch (sub) {
|
||||
case 'ls':
|
||||
return fsList(api, args[0] ?? '/', long, out);
|
||||
case 'tree':
|
||||
return fsTree(api, args[0] ?? '/', Number(flags.depth ?? 3), Number(flags['max-folders'] ?? 200), out);
|
||||
case 'find': {
|
||||
if (!args[0]) {
|
||||
process.stderr.write('fs find needs a name, e.g.: schulcloud fs find Erbrecht --path /courses\n');
|
||||
return 2;
|
||||
}
|
||||
const type = flags.type === 'folder' || flags.type === 'file' ? String(flags.type) : 'any';
|
||||
return fsFind(api, args[0], String(flags.path ?? '/'), type, Number(flags['max-folders'] ?? 400), long, out);
|
||||
}
|
||||
case 'get':
|
||||
if (!args[0]) {
|
||||
process.stderr.write('fs get needs a path, e.g.: schulcloud fs get "/courses/<course>/<folder>"\n');
|
||||
return 2;
|
||||
}
|
||||
return fsGet(
|
||||
api,
|
||||
args[0],
|
||||
{ out: flags.out ? String(flags.out) : undefined, force: Boolean(flags.force), jobs: Number(flags.jobs ?? 3) },
|
||||
out,
|
||||
);
|
||||
default:
|
||||
process.stderr.write(`Unknown fs command "${sub ?? ''}". Use ls, tree, find or get.\n\n${USAGE}`);
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
async function runSync(flags: Flags): Promise<number> {
|
||||
const config = await loadCliConfig();
|
||||
const root = flags.dir ? resolve(String(flags.dir)) : config.syncDir;
|
||||
|
||||
Reference in New Issue
Block a user