From 63136bde12dedcebc8704763f7113a1edeee1cd9 Mon Sep 17 00:00:00 2001 From: D-VR <26770468+D-VR@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:32:21 +0200 Subject: [PATCH] Add configurable course prefix handling --- README.md | 19 +++++- config.json.example | 1 + syncmymoodle/__main__.py | 42 ++++++++++++- tests/test_course_prefix_handling.py | 89 ++++++++++++++++++++++++++++ 4 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 tests/test_course_prefix_handling.py diff --git a/README.md b/README.md index 2d390bc..4122d41 100644 --- a/README.md +++ b/README.md @@ -106,9 +106,11 @@ usage: python3 -m syncmymoodle [-h] [--secretservice] [--secretservicetotpsecret [--config CONFIG] [--cookiefile COOKIEFILE] [--courses COURSES] [--skipcourses SKIPCOURSES] [--semester SEMESTER] [--basedir BASEDIR] - [--nolinks] + [--courseprefix {keep,remove,suffix}] [--nolinks] [--excludefiletypes EXCLUDEFILETYPES] - [--updatefiles] [-v] + [--updatefiles] + [--updatefilesconflict {rename,keep,overwrite}] + [-v] Synchronization client for RWTH Moodle. All optional arguments override those in config.json. @@ -139,6 +141,9 @@ options: separated. Defaults to all semesters, if no additional restrictions e.g. courses are defined. --basedir BASEDIR specify the directory where all files will be synced + --courseprefix {keep,remove,suffix} + handle leading two-character course prefixes in local + folder names: 'keep' (default), 'remove', or 'suffix' --nolinks define whether various links in moodle pages should also be inspected e.g. youtube videos, wikipedia articles @@ -173,6 +178,7 @@ configuration does: "totp": "", // RWTH SSO TOTP "Serial Number", format: TOTP0000000A, see https://idm.rwth-aachen.de/selfservice/MFATokenManager "totpsecret": "", // The TOTP Secret for your TOTP generator (optional) "basedir": "./", // The base directory where all your files will be synced to + "course_prefix_handling": "suffix", // How to handle local course folders starting with a two-character prefix like "(VO) ": "keep" (backwards-compatible default), "remove", or "suffix" (recommended) "cookie_file": "./session", // The location of the session/cookie file, which can be used instead of a password. "use_secret_service": false, // Use the system keyring (see README), instead of a password. "secret_service_store_totp_secret": false, // Store the TOTP secret in the system keyring. @@ -199,6 +205,15 @@ configuration does: } ``` +`course_prefix_handling` controls local course folder names that start with a +prefix of exactly two characters in parentheses, followed by a space. For +example, `(VO) Analysis` stays unchanged with `keep`, becomes `Analysis` with +`remove`, and becomes `Analysis (VO)` with `suffix`. If not set, the default +is `keep` for backwards compatibility, however `suffix` is recommended. +`remove` can create folder-name conflicts when multiple course types share +the same title; syncMyMoodle resolves those by adding a stable suffix to the +conflicting folders. + `exclude_sections` skips complete Moodle course sections, i.e. top-level topic/week blocks such as `General`, `Week 1` or `Exercise Sheets`. Matching a section skips all modules, files and links inside it. diff --git a/config.json.example b/config.json.example index 1aac35a..26adc01 100644 --- a/config.json.example +++ b/config.json.example @@ -7,6 +7,7 @@ "totp": "", "totpsecret": "", "basedir": "./", + "course_prefix_handling": "suffix", "cookie_file": "./session", "use_secret_service": false, "secret_service_store_totp_secret": false, diff --git a/syncmymoodle/__main__.py b/syncmymoodle/__main__.py index a8dfa02..e62b4a6 100755 --- a/syncmymoodle/__main__.py +++ b/syncmymoodle/__main__.py @@ -58,6 +58,8 @@ "statuslabel_wartung", "statuslabel_warnung", } +COURSE_PREFIX_RE = re.compile(r"^\((?P[^()]{2})\) +(?P.+)$") +COURSE_PREFIX_HANDLING_OPTIONS = ("keep", "remove", "suffix") logger = logging.getLogger(__name__) @@ -230,7 +232,10 @@ def remove_children_nameclashes(self): self.children.remove(child) unclashed_children.append(child) siblings = [ - c for c in self.children if c.name == child.name and c.url != child.url + c + for c in self.children + if c.name == child.name + and (c.url != child.url or c.name_clash_id != child.name_clash_id) ] if len(siblings) > 0: # if a filename is still duplicate in its directory, we rename it by appending its id (urlsafe base64 so it also works for urls). @@ -646,6 +651,27 @@ def _configured_patterns(self, *keys, course_id=None): patterns.extend(self._as_list(value)) return [str(pattern) for pattern in patterns if pattern is not None] + def _format_course_name(self, course_name): + prefix_handling = self.config.get("course_prefix_handling", "keep") + if prefix_handling == "keep": + return course_name + if prefix_handling not in COURSE_PREFIX_HANDLING_OPTIONS: + logger.warning( + "Unsupported course_prefix_handling value %r; using keep", + prefix_handling, + ) + return course_name + + match = COURSE_PREFIX_RE.match(course_name) + if not match: + return course_name + + name = match.group("course_name") + prefix = match.group("prefix") + if prefix_handling == "remove": + return name + return f"{name} ({prefix})" + def _matches_any_pattern(self, values, patterns): for value in values: if value is None: @@ -1339,7 +1365,7 @@ def sync(self): # Syncing all courses for course in self.get_all_courses(): - course_name = course["shortname"] + course_name = self._format_course_name(course["shortname"]) course_id = course["id"] if ( @@ -2809,6 +2835,15 @@ def main(): default=None, help="specify the directory where all files will be synced", ) + parser.add_argument( + "--courseprefix", + choices=COURSE_PREFIX_HANDLING_OPTIONS, + default=None, + help=( + "handle leading two-character course prefixes in local folder names: " + "'keep' (default), 'remove', or 'suffix'" + ), + ) parser.add_argument( "--nolinks", action="store_true", @@ -2881,6 +2916,9 @@ def main(): else config.get("only_sync_semester", []) ) config["basedir"] = args.basedir or config.get("basedir", "./") + config["course_prefix_handling"] = args.courseprefix or config.get( + "course_prefix_handling", "keep" + ) config["use_secret_service"] = ( args.secretservice if keyring else None ) or config.get("use_secret_service") diff --git a/tests/test_course_prefix_handling.py b/tests/test_course_prefix_handling.py new file mode 100644 index 0000000..e909949 --- /dev/null +++ b/tests/test_course_prefix_handling.py @@ -0,0 +1,89 @@ +import unittest + +from syncmymoodle.__main__ import Node, SyncMyMoodle + + +class CoursePrefixHandlingTest(unittest.TestCase): + def format_course_name(self, handling, name): + smm = SyncMyMoodle({"course_prefix_handling": handling}) + return smm._format_course_name(name) + + def test_keep_preserves_course_name(self): + self.assertEqual( + self.format_course_name("keep", "(VO) Analysis"), + "(VO) Analysis", + ) + + def test_remove_strips_two_character_prefix(self): + self.assertEqual( + self.format_course_name("remove", "(VO) Analysis"), + "Analysis", + ) + + def test_suffix_moves_two_character_prefix_to_end(self): + self.assertEqual( + self.format_course_name("suffix", "(VU) Software Quality Assurance"), + "Software Quality Assurance (VU)", + ) + + def test_other_two_character_prefixes_are_supported(self): + self.assertEqual( + self.format_course_name("suffix", "(RE) Exercise Session"), + "Exercise Session (RE)", + ) + + def test_non_matching_names_are_preserved(self): + self.assertEqual(self.format_course_name("remove", "Analysis"), "Analysis") + self.assertEqual( + self.format_course_name("remove", "(VO)Analysis"), "(VO)Analysis" + ) + self.assertEqual( + self.format_course_name("remove", "(V) Analysis"), "(V) Analysis" + ) + self.assertEqual( + self.format_course_name("remove", "(ABC) Analysis"), + "(ABC) Analysis", + ) + + def test_invalid_mode_preserves_course_name(self): + with self.assertLogs("syncmymoodle.__main__", level="WARNING"): + self.assertEqual( + self.format_course_name("invalid", "(VO) Analysis"), + "(VO) Analysis", + ) + + +class CourseNameClashTest(unittest.TestCase): + def test_same_course_folder_name_without_url_gets_stable_suffixes(self): + root = Node("", -1, "Root", None) + semester = root.add_child("26ss", None, "Semester") + semester.add_child("Software Quality Assurance", 101, "Course") + semester.add_child("Software Quality Assurance", 102, "Course") + + root.remove_children_nameclashes() + + names = [course.name for course in semester.children] + self.assertEqual(len(names), 2) + self.assertEqual(len(set(names)), 2) + self.assertNotIn("Software Quality Assurance", names) + for name in names: + self.assertTrue(name.startswith("Software Quality Assurance_")) + + def test_same_name_with_different_urls_still_gets_stable_suffixes(self): + root = Node("", -1, "Root", None) + section = root.add_child("General", None, "Section") + section.add_child("Slides", 201, "URL", url="https://example.com/slides-a") + section.add_child("Slides", 202, "URL", url="https://example.com/slides-b") + + root.remove_children_nameclashes() + + names = [link.name for link in section.children] + self.assertEqual(len(names), 2) + self.assertEqual(len(set(names)), 2) + self.assertNotIn("Slides", names) + for name in names: + self.assertTrue(name.startswith("Slides_")) + + +if __name__ == "__main__": + unittest.main()