Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down
1 change: 1 addition & 0 deletions config.json.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"totp": "",
"totpsecret": "",
"basedir": "./",
"course_prefix_handling": "suffix",
"cookie_file": "./session",
"use_secret_service": false,
"secret_service_store_totp_secret": false,
Expand Down
42 changes: 40 additions & 2 deletions syncmymoodle/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@
"statuslabel_wartung",
"statuslabel_warnung",
}
COURSE_PREFIX_RE = re.compile(r"^\((?P<prefix>[^()]{2})\) +(?P<course_name>.+)$")
COURSE_PREFIX_HANDLING_OPTIONS = ("keep", "remove", "suffix")

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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")
Expand Down
89 changes: 89 additions & 0 deletions tests/test_course_prefix_handling.py
Original file line number Diff line number Diff line change
@@ -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()