@@ -407,45 +407,148 @@ def ajax(self, methodname, args):
407407
408408 # -- Kurse --
409409
410- def list_courses (self ):
410+ def list_courses (self , search = "" , limit = 25 , offset = 0 ):
411411 try :
412+ limit = int (limit )
413+ offset = int (offset )
414+ except (TypeError , ValueError ) as exc :
415+ raise ValueError ("limit und offset muessen ganze Zahlen sein." ) from exc
416+ if not 1 <= limit <= 100 :
417+ raise ValueError ("limit muss zwischen 1 und 100 liegen." )
418+ if offset < 0 :
419+ raise ValueError ("offset darf nicht negativ sein." )
420+ search = str (search or "" ).strip ()
421+
422+ try :
423+ args = {
424+ "offset" : offset ,
425+ "limit" : limit ,
426+ "classification" : "all" ,
427+ "sort" : "fullname" ,
428+ }
429+ if search :
430+ args ["searchvalue" ] = search
412431 data = self .ajax (
413432 "core_course_get_enrolled_courses_by_timeline_classification" ,
414- { "offset" : 0 , "limit" : 0 , "classification" : "all" , "sort" : "fullname" } ,
433+ args ,
415434 )
416435 if isinstance (data , dict ):
417- out = [{"id" : c .get ("id" ), "fullname" : c .get ("fullname" ),
436+ raw_courses = data .get ("courses" , [])
437+ out = [
438+ {
439+ "id" : c .get ("id" ),
440+ "fullname" : c .get ("fullname" ),
418441 "shortname" : c .get ("shortname" ),
419442 "kategorie" : c .get ("coursecategory" ),
420- "sichtbar" : c .get ("visible" )}
421- for c in data .get ("courses" , [])]
422- if out :
423- return {"kurse" : out , "quelle" : "AJAX (Timeline)" }
443+ "sichtbar" : c .get ("visible" ),
444+ }
445+ for c in raw_courses
446+ if isinstance (c , dict )
447+ ]
448+ next_offset = data .get ("nextoffset" )
449+ if isinstance (next_offset , int ):
450+ next_offset = next_offset if next_offset > offset else None
451+ else :
452+ next_offset = (
453+ offset + len (out ) if len (out ) >= limit else None
454+ )
455+ result = {
456+ "kurse" : out ,
457+ "anzahl" : len (out ),
458+ "offset" : offset ,
459+ "limit" : limit ,
460+ "naechster_offset" : next_offset ,
461+ "weitere_vorhanden" : next_offset is not None ,
462+ "suche" : search or None ,
463+ "quelle" : "AJAX (Timeline, paginiert)" ,
464+ }
465+ if next_offset is not None :
466+ result ["hinweis" ] = (
467+ "Weitere Kurse sind vorhanden. moodle_list_courses "
468+ f"mit offset={ next_offset } aufrufen."
469+ )
470+ return result
424471 except Exception as e :
425472 log (f"Kursliste via AJAX fehlgeschlagen: { e } " )
426473
427- return {"kurse" : self ._courses_html (),
428- "quelle" : "HTML-Fallback (/course/index.php)" ,
429- "hinweis" : "Als Admin sind nicht eingeschriebene Kurse nur "
430- "hier sichtbar." }
474+ courses = self ._courses_html (search = search )
475+ page = courses [offset :offset + limit ]
476+ next_offset = offset + len (page ) if offset + len (page ) < len (courses ) else None
477+ return {
478+ "kurse" : page ,
479+ "anzahl" : len (page ),
480+ "offset" : offset ,
481+ "limit" : limit ,
482+ "naechster_offset" : next_offset ,
483+ "weitere_vorhanden" : next_offset is not None ,
484+ "suche" : search or None ,
485+ "quelle" : "HTML-Fallback (Meine Kurse/Dashboard)" ,
486+ "hinweis" : (
487+ "Der AJAX-Endpunkt war nicht verfuegbar. Der HTML-Fallback "
488+ "kann bei dynamisch geladenen Moodle-Dashboards unvollstaendig "
489+ "sein."
490+ ),
491+ }
431492
432- def _courses_html (self ):
493+ def _courses_html (self , search = "" ):
433494 found , seen = [], set ()
434- for path in ("/course/index.php" , "/course/management.php" ):
495+ search_folded = search .casefold ()
496+ pages = (
497+ (
498+ "/my/courses.php" ,
499+ (
500+ '[data-region="course-content"]' ,
501+ '[data-region="courses-view"]' ,
502+ ".block_myoverview" ,
503+ "main" ,
504+ ),
505+ ),
506+ ("/my/" , (".block_myoverview" , '[data-region="course-content"]' )),
507+ ("/course/index.php" , ("main" ,)),
508+ ("/course/management.php" , ("main" ,)),
509+ )
510+ for path , scope_selectors in pages :
435511 try :
436512 r = self .s .get (self .base + path , timeout = 30 )
513+ r .raise_for_status ()
437514 except Exception :
438515 continue
439516 soup = BeautifulSoup (r .text , "html.parser" )
440- for a in soup .select ('a[href*="/course/view.php?id="]' ):
441- m = re .search (r"id=(\d+)" , a .get ("href" , "" ))
442- name = a .get_text (strip = True )
443- if m and name and m .group (1 ) not in seen and m .group (1 ) != "1" :
517+ scopes = []
518+ for selector in scope_selectors :
519+ scopes .extend (soup .select (selector ))
520+ if not scopes and path == "/my/courses.php" :
521+ scopes = [soup ]
522+
523+ before = len (found )
524+ for scope in scopes :
525+ for a in scope .select ('a[href*="/course/view.php?id="]' ):
526+ m = re .search (r"[?&]id=(\d+)" , a .get ("href" , "" ))
527+ name = " " .join (a .get_text (" " , strip = True ).split ())
528+ if (
529+ not m
530+ or not name
531+ or m .group (1 ) in seen
532+ or m .group (1 ) == "1"
533+ or (search_folded and search_folded not in name .casefold ())
534+ ):
535+ continue
444536 seen .add (m .group (1 ))
445- found .append ({"id" : int (m .group (1 )), "fullname" : name ,
446- "shortname" : "" , "kategorie" : "" })
537+ found .append (
538+ {
539+ "id" : int (m .group (1 )),
540+ "fullname" : name ,
541+ "shortname" : "" ,
542+ "kategorie" : "" ,
543+ }
544+ )
447545 if found :
448- break
546+ # Dashboard pages contain enrolled courses. Do not mix them
547+ # with the site-wide public course catalogue.
548+ if path .startswith ("/my/" ) and len (found ) > before :
549+ break
550+ if path .startswith ("/course/" ):
551+ break
449552 return found
450553
451554 # -- Struktur --
@@ -1159,10 +1262,18 @@ def moodle_logout() -> dict:
11591262
11601263
11611264@mcp .tool ()
1162- def moodle_list_courses () -> dict :
1265+ def moodle_list_courses (search : str = "" , limit : int = 25 ,
1266+ offset : int = 0 ) -> dict :
11631267 """Listet die Kurse der Moodle-Instanz mit ID, Name, Kurzname und
1164- Kategorie. Die ID brauchst du fuer alle kursbezogenen Werkzeuge."""
1165- return with_retry (lambda s : s .list_courses ())
1268+ Kategorie. Die ID brauchst du fuer alle kursbezogenen Werkzeuge.
1269+
1270+ Die Ausgabe ist paginiert, damit auch Konten mit vielen Kursen stabil
1271+ funktionieren. Standardmaessig werden 25 Kurse geliefert. Falls
1272+ 'weitere_vorhanden' wahr ist, rufe das Werkzeug erneut mit dem Wert aus
1273+ 'naechster_offset' auf. Mit search kann serverseitig nach Kursnamen
1274+ gesucht werden; limit darf zwischen 1 und 100 liegen.
1275+ """
1276+ return with_retry (lambda s : s .list_courses (search , limit , offset ))
11661277
11671278
11681279@mcp .tool ()
@@ -1492,4 +1603,4 @@ def run_setup_cli():
14921603 else :
14931604 log (f"Starte MCP-Server ({ _MCP_FLAVOR } ), "
14941605 f"Keyring: { 'ja' if _HAS_KEYRING else 'nein' } " )
1495- mcp .run ()
1606+ mcp .run ()
0 commit comments