diff --git a/.gitignore b/.gitignore index 52d42b6..7bd32c3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,228 @@ -*.egg-info +# ** .gitignore for mu ** + TODO /dist /examples/olx.tar.gz +/examples_output +muenv +.DS_Store + +# ** .gitignore for python ** + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +# Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +# poetry.lock +# poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +# pdm.lock +# pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +# pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +# .idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml \ No newline at end of file diff --git a/README.md b/README.md index f303061..ca74767 100644 --- a/README.md +++ b/README.md @@ -4,16 +4,45 @@ Mu is for authors of online courses. It allows you to cross-compile courses from Supported formats: -- [Markdown](https://daringfireball.net/projects/markdown/): with [Pandoc-flavoured](https://garrettgman.github.io/rmarkdown/authoring_pandoc_markdown.html) header attributes. +- [Markdown](https://daringfireball.net/projects/markdown/): single file with [Pandoc-flavoured](https://garrettgman.github.io/rmarkdown/authoring_pandoc_markdown.html) header attributes. +- [Folder Markdown](#folder-markdown): organized folder structure with multiple Markdown files. - HTML 5 - Open Learning XML ([OLX](https://docs.openedx.org/en/latest/educators/navigation/olx.html)) from [Open edX](https://openedx.org). -Check out the [course.md](https://github.com/overhangio/mu/blob/main/examples/course.md) file to see what an actual course in Markdown format looks like. +Check out the [course.md](https://github.com/overhangio/mu/blob/main/examples/course.md) file to see what an actual course in single-file Markdown format looks like. ## Installation +### Using PyPI (latest stable release) + pip install mu-courses +### Using the development version from source + +Since the PyPI version may not include the latest features and fixes, you can install directly from the repository: + +1. Clone the repository: + ```bash + git clone https://github.com/overhangio/mu.git + cd mu + ``` + +2. Create a virtual environment: + ```bash + python -m venv venv + source venv/bin/activate # On Windows: venv\Scripts\activate + ``` + +3. Install in editable mode with development dependencies: + ```bash + pip install -e . + ``` + +Alternatively, if you only want the latest features without development tools: + ```bash + pip install -e . + ``` + Conversion from and to Markdown is handled with the help of [Pandoc](https://pandoc.org/). Thus, a recent version of Pandoc is required when working with Markdown documents. See the corresponding [installation instructions](https://pandoc.org/installing.html). ## Usage @@ -28,6 +57,78 @@ Conversion from and to Markdown is handled with the help of [Pandoc](https://pan When writing Markdown files, the generated documents will include non-standard (but widely recognized) [header identifiers](https://garrettgman.github.io/rmarkdown/authoring_pandoc_markdown.html#header-identifiers) to store the course unit attributes. +### Folder Markdown + +For large courses, you can organize your content across multiple Markdown files in a folder structure instead of a single file. This is useful for team collaboration and better file organization. + +**Folder structure:** + +``` +course_folder/ +├── index.md # Course metadata and description +├── chapter1/ +│ ├── index.md # Chapter metadata +│ ├── sequential1/ +│ │ ├── index.md # Sequential metadata +│ │ ├── unit1.md # Content units +│ │ └── unit2.md +│ └── sequential2/ +│ └── ... +└── chapter2/ + └── ... +``` + +**File format:** Each Markdown file can include optional YAML frontmatter for metadata: + +```yaml +--- +title: My Unit Title +order: 1 +hidden: false +--- + + +``` + +**Metadata fields:** +- `title`: Display name (uses filename if not specified) +- `order`: Numeric ordering (default: 9999, sorted ascending) +- `hidden` or `draft`: Set to `true` to exclude from compilation +- `org`, `course`, `url_name`: Available at course level for OLX metadata + +**Important:** Course, chapter, and sequential `index.md` files should contain only frontmatter. Any text in the body of these files will be ignored with a warning. If you need to add notes or comments for organization, use HTML comments: + +```markdown +--- +title: Chapter 1 +order: 1 +--- + + +``` + +Only unit files (non-index markdown files within sequentials) should contain actual content in their body. + +**Usage:** + +```bash +# Compile folder markdown to single markdown +mu /path/to/course_folder /path/to/compiled.md + +# Or directly to OLX +mu /path/to/course_folder /path/to/olx/ +``` + +**Debug mode:** + +To debug the folder markdown compilation process, set the `MU_DEBUG_FOLDER_MD` environment variable: + +```bash +MU_DEBUG_FOLDER_MD=1 mu /path/to/course_folder /path/to/olx/ +``` + +This will keep the temporary merged markdown file and print its location to the console, allowing you to inspect the intermediate merged markdown before it's converted to the target format. + ## Examples Example courses are provided in the [examples](./examples) directory. diff --git a/examples/course_folder/chapter_1/.DS_Store b/examples/course_folder/chapter_1/.DS_Store new file mode 100644 index 0000000..1f140d5 Binary files /dev/null and b/examples/course_folder/chapter_1/.DS_Store differ diff --git a/examples/course_folder/chapter_1/Sequential_1_1/index.md b/examples/course_folder/chapter_1/Sequential_1_1/index.md new file mode 100644 index 0000000..f311d28 --- /dev/null +++ b/examples/course_folder/chapter_1/Sequential_1_1/index.md @@ -0,0 +1,6 @@ +--- +title: Sequential 1_1 +order: 1 +--- + + diff --git a/examples/course_folder/chapter_1/Sequential_1_1/problems.md b/examples/course_folder/chapter_1/Sequential_1_1/problems.md new file mode 100644 index 0000000..14732d9 --- /dev/null +++ b/examples/course_folder/chapter_1/Sequential_1_1/problems.md @@ -0,0 +1,32 @@ +--- +title: Problems +order: 3 +--- + + +::: {mu-type=mcq} + +#### Multiple choice question + +What is the answer to Life, the Universe, and Everything? + +* ✅ 6 x 7 +* ❌ 666 +* ❌ 0 +* ✅ 42 + +::: + +::: {mu-type=ftq} + +#### Free text question + +How many legs does a healthy snake have? + + + +* None +* Zero +* 0 + +::: \ No newline at end of file diff --git a/examples/course_folder/chapter_1/Sequential_1_1/syllabus.md b/examples/course_folder/chapter_1/Sequential_1_1/syllabus.md new file mode 100644 index 0000000..769c428 --- /dev/null +++ b/examples/course_folder/chapter_1/Sequential_1_1/syllabus.md @@ -0,0 +1,30 @@ +--- +title: Course Syllabus +order: 2 +--- + + +::: {mu-type=video} + +##### Video + +![](https://s3.amazonaws.com/edx-course-videos/edx-edx101/EDXSPCPJSP13-H010000_100.mp4) + +::: + +##### Some html content + +Each one of these paragraphs should result in a different `

` tag. + +We can even include images here: + +![](https://www.google.com/images/logo.png) + +```c +printf("Hello world!\n"); +``` + +Raw code: + + import base64 + base64.decodebytes(b'UmFjbGV0dGUgY2hlZXNlIGlzIHRoZSBiZXN0') \ No newline at end of file diff --git a/examples/course_folder/chapter_1/Sequential_1_2/index.md b/examples/course_folder/chapter_1/Sequential_1_2/index.md new file mode 100644 index 0000000..d23c62f --- /dev/null +++ b/examples/course_folder/chapter_1/Sequential_1_2/index.md @@ -0,0 +1,6 @@ +--- +title: Second sequential +order: 1 +--- + + \ No newline at end of file diff --git a/examples/course_folder/chapter_1/Sequential_1_2/test.md b/examples/course_folder/chapter_1/Sequential_1_2/test.md new file mode 100644 index 0000000..f987112 --- /dev/null +++ b/examples/course_folder/chapter_1/Sequential_1_2/test.md @@ -0,0 +1,55 @@ +--- +title: Test unit +order: 2 +--- + +This is a test with some code + +```c +#include "esp_wifi.h" +#include "string.h" +#include "esp_log.h" + + +static const char* TAG = "main"; // Used for logging +// ... + +void wifi_init_softap() +{ + esp_netif_init(); + esp_event_loop_create_default(); + esp_netif_create_default_wifi_ap(); + + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); // always start with this + + esp_wifi_init(&cfg); + + esp_event_handler_instance_register(WIFI_EVENT, + ESP_EVENT_ANY_ID, + &wifi_event_handler, + NULL, + NULL); + + wifi_config_t wifi_config = { + .ap = { + .ssid = ESP_WIFI_SSID, + .ssid_len = strlen(ESP_WIFI_SSID), + .channel = ESP_WIFI_CHANNEL, + .password = ESP_WIFI_PASS, + .max_connection = MAX_STA_CONN, + .authmode = WIFI_AUTH_WPA2_PSK, + .pmf_cfg = { + .required = true, + }, + }, + }; + + + esp_wifi_set_mode(WIFI_MODE_AP); + esp_wifi_set_config(WIFI_IF_AP, &wifi_config); + esp_wifi_start(); + + ESP_LOGI(TAG, "wifi_init_softap finished. SSID:%s password:%s channel:%d", + ESP_WIFI_SSID, ESP_WIFI_PASS, ESP_WIFI_CHANNEL); +} +``` \ No newline at end of file diff --git a/examples/course_folder/chapter_1/index.md b/examples/course_folder/chapter_1/index.md new file mode 100644 index 0000000..342edb4 --- /dev/null +++ b/examples/course_folder/chapter_1/index.md @@ -0,0 +1,10 @@ +--- +title: Chapter 1 +order: 1 +--- + + \ No newline at end of file diff --git a/examples/course_folder/chapter_2/Sequential_2_1/index.md b/examples/course_folder/chapter_2/Sequential_2_1/index.md new file mode 100644 index 0000000..d1eb581 --- /dev/null +++ b/examples/course_folder/chapter_2/Sequential_2_1/index.md @@ -0,0 +1,6 @@ +--- +title: Sequential 2.1 +order: 1 +--- + + \ No newline at end of file diff --git a/examples/course_folder/chapter_2/Sequential_2_1/problems.md b/examples/course_folder/chapter_2/Sequential_2_1/problems.md new file mode 100644 index 0000000..c5b565f --- /dev/null +++ b/examples/course_folder/chapter_2/Sequential_2_1/problems.md @@ -0,0 +1,32 @@ +--- +title: Problems 2 +order: 2 +--- + + +::: {mu-type=mcq} + +#### Multiple choice question + +What is the answer to Life, the Universe, and Everything? + +* ✅ 6 x 7 +* ❌ 666 +* ❌ 0 +* ✅ 42 + +::: + +::: {mu-type=ftq} + +#### Free text question + +How many legs does a healthy snake have? + + + +* None +* Zero +* 0 + +::: \ No newline at end of file diff --git a/examples/course_folder/chapter_2/Sequential_2_1/syllabus.md b/examples/course_folder/chapter_2/Sequential_2_1/syllabus.md new file mode 100644 index 0000000..3f44959 --- /dev/null +++ b/examples/course_folder/chapter_2/Sequential_2_1/syllabus.md @@ -0,0 +1,30 @@ +--- +title: Course Syllabus 2 +order: 1 +--- + + +::: {mu-type=video} + +##### Video + +![](https://s3.amazonaws.com/edx-course-videos/edx-edx101/EDXSPCPJSP13-H010000_100.mp4) + +::: + +##### Some html content + +Each one of these paragraphs should result in a different `

` tag. + +We can even include images here: + +![](https://www.google.com/images/logo.png) + +```c +printf("Hello world!\n"); +``` + +Raw code: + + import base64 + base64.decodebytes(b'UmFjbGV0dGUgY2hlZXNlIGlzIHRoZSBiZXN0') \ No newline at end of file diff --git a/examples/course_folder/chapter_2/index.md b/examples/course_folder/chapter_2/index.md new file mode 100644 index 0000000..0a9eb9e --- /dev/null +++ b/examples/course_folder/chapter_2/index.md @@ -0,0 +1,10 @@ +--- +title: Second chapter +order: 1 +--- + + \ No newline at end of file diff --git a/examples/course_folder/index.md b/examples/course_folder/index.md new file mode 100644 index 0000000..0f930e6 --- /dev/null +++ b/examples/course_folder/index.md @@ -0,0 +1,11 @@ +--- +title: New Introduction to mu +org: testing_org +course: all-about-mu +url_name: session1 +order: 1 +--- + + diff --git a/examples/course_folder_merged.md b/examples/course_folder_merged.md new file mode 100644 index 0000000..25818ec --- /dev/null +++ b/examples/course_folder_merged.md @@ -0,0 +1,176 @@ +# New Introduction to mu {olx-org=testing_org olx-course=all-about-mu olx-url_name=session1} + +## Chapter 1 + +### Sequential 1_1 + +#### Course Syllabus + +::: {mu-type=video} + +##### Video + +![](https://s3.amazonaws.com/edx-course-videos/edx-edx101/EDXSPCPJSP13-H010000_100.mp4) + +::: + +##### Some html content + +Each one of these paragraphs should result in a different `

` tag. + +We can even include images here: + +![](https://www.google.com/images/logo.png) + +```c +printf("Hello world!\n"); +``` + +Raw code: + + import base64 + base64.decodebytes(b'UmFjbGV0dGUgY2hlZXNlIGlzIHRoZSBiZXN0') + +#### Problems + +::: {mu-type=mcq} + +#### Multiple choice question + +What is the answer to Life, the Universe, and Everything? + +* ✅ 6 x 7 +* ❌ 666 +* ❌ 0 +* ✅ 42 + +::: + +::: {mu-type=ftq} + +#### Free text question + +How many legs does a healthy snake have? + + + +* None +* Zero +* 0 + +::: + +### Second sequential + +#### Test unit + +This is a test with some code + +```c +#include "esp_wifi.h" +#include "string.h" +#include "esp_log.h" + + +static const char* TAG = "main"; // Used for logging +// ... + +void wifi_init_softap() +{ + esp_netif_init(); + esp_event_loop_create_default(); + esp_netif_create_default_wifi_ap(); + + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); // always start with this + + esp_wifi_init(&cfg); + + esp_event_handler_instance_register(WIFI_EVENT, + ESP_EVENT_ANY_ID, + &wifi_event_handler, + NULL, + NULL); + + wifi_config_t wifi_config = { + .ap = { + .ssid = ESP_WIFI_SSID, + .ssid_len = strlen(ESP_WIFI_SSID), + .channel = ESP_WIFI_CHANNEL, + .password = ESP_WIFI_PASS, + .max_connection = MAX_STA_CONN, + .authmode = WIFI_AUTH_WPA2_PSK, + .pmf_cfg = { + .required = true, + }, + }, + }; + + + esp_wifi_set_mode(WIFI_MODE_AP); + esp_wifi_set_config(WIFI_IF_AP, &wifi_config); + esp_wifi_start(); + + ESP_LOGI(TAG, "wifi_init_softap finished. SSID:%s password:%s channel:%d", + ESP_WIFI_SSID, ESP_WIFI_PASS, ESP_WIFI_CHANNEL); +} +``` + +## Second chapter + +### Sequential 2.1 + +#### Course Syllabus 2 + +::: {mu-type=video} + +##### Video + +![](https://s3.amazonaws.com/edx-course-videos/edx-edx101/EDXSPCPJSP13-H010000_100.mp4) + +::: + +##### Some html content + +Each one of these paragraphs should result in a different `

` tag. + +We can even include images here: + +![](https://www.google.com/images/logo.png) + +```c +printf("Hello world!\n"); +``` + +Raw code: + + import base64 + base64.decodebytes(b'UmFjbGV0dGUgY2hlZXNlIGlzIHRoZSBiZXN0') + +#### Problems 2 + +::: {mu-type=mcq} + +#### Multiple choice question + +What is the answer to Life, the Universe, and Everything? + +* ✅ 6 x 7 +* ❌ 666 +* ❌ 0 +* ✅ 42 + +::: + +::: {mu-type=ftq} + +#### Free text question + +How many legs does a healthy snake have? + + + +* None +* Zero +* 0 + +::: diff --git a/mu/formats/folder_md/__init__.py b/mu/formats/folder_md/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mu/formats/folder_md/reader.py b/mu/formats/folder_md/reader.py new file mode 100644 index 0000000..3e70b82 --- /dev/null +++ b/mu/formats/folder_md/reader.py @@ -0,0 +1,202 @@ +import os +import tempfile +import yaml +from pathlib import Path +from typing import Tuple, Dict, Any, List + +from mu.formats.md.reader import Reader as SingleFileReader +from mu.exceptions import MuError + + +class Reader(SingleFileReader): + """ + Reads a folder structure of Markdown files and merges them into a single course. + + Expected folder structure: + course_folder/ + ├── index.md (course metadata and description) + ├── chapter1/ + │ ├── index.md (chapter metadata) + │ ├── sequential1/ + │ │ ├── index.md (sequential metadata) + │ │ ├── unit1.md + │ │ └── unit2.md + │ └── sequential2/ + │ └── ... + └── chapter2/ + └── ... + + Debug mode: + Set MU_DEBUG_FOLDER_MD=1 environment variable to write merged markdown to disk. + """ + + def __init__(self, folder_path: str) -> None: + root = Path(folder_path) + if not root.is_dir(): + raise MuError(f"Folder path does not exist: {folder_path}") + + merged_content = self._compile_course(root) + + # Debug mode: write merged content to disk + debug_mode = os.getenv("MU_DEBUG_FOLDER_MD", "0") == "1" + + # Create a temp file with merged content and pass to parent Reader + with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False, encoding='utf-8') as tmp: + tmp.write(merged_content) + tmp.flush() + temp_path = tmp.name + + try: + super().__init__(temp_path) + finally: + # Clean up temp file after reading (unless debug mode) + if not debug_mode: + Path(temp_path).unlink() + else: + print(f"[DEBUG] Temp file kept at: {temp_path}") + + @staticmethod + def _read_md(path: Path) -> Tuple[Dict[str, Any], str]: + """ + Read a markdown file and extract frontmatter and body. + Returns a tuple of (frontmatter_dict, body_text). + """ + text = path.read_text(encoding="utf-8") + + if text.startswith("---"): + parts = text.split("---", 2) + if len(parts) >= 3: + fm = yaml.safe_load(parts[1]) or {} + body = parts[2].lstrip() + return fm, body + + return {}, text + + @staticmethod + def _is_hidden(frontmatter: Dict[str, Any]) -> bool: + """Check if file is marked as hidden or draft.""" + return frontmatter.get("hidden") or frontmatter.get("draft") + + @staticmethod + def _has_uncommented_text(body: str) -> bool: + """Check if body contains uncommented text (not just HTML comments).""" + # Remove all HTML comments + text = body + while "", start) + if end == -1: + end = len(text) + else: + end += 3 + text = text[:start] + text[end:] + + # Check if remaining text is non-empty (ignoring whitespace) + return bool(text.strip()) + + @staticmethod + def _sorted_items(items: List[Path]) -> List[Path]: + """Sort items by frontmatter 'order' field, then by name.""" + def sort_key(p: Path): + if p.suffix == ".md": + fm, _ = Reader._read_md(p) + return (fm.get("order", 9999), p.name) + return (9999, p.name) + + return sorted(items, key=sort_key) + + @staticmethod + def _heading(level: int, title: str) -> str: + """Generate markdown heading.""" + return f"{'#' * level} {title}" + + def _compile_course(self, root: Path) -> str: + """ + Recursively compile course structure from folder hierarchy. + Generates merged markdown with proper hierarchy. + """ + output = [] + + # ================ COURSE ================ + index_path = root / "index.md" + if not index_path.exists(): + raise MuError(f"Course index.md not found at {index_path}") + + fm, body = self._read_md(index_path) + + # Warn if course index.md has uncommented text + if self._has_uncommented_text(body): + print(f"[WARNING] Course index.md has uncommented text body, ignoring it: {index_path}") + + title = fm.get("title", "Untitled Course") + org = fm.get("org", "org") + course = fm.get("course", "course") + url_name = fm.get("url_name", "course") + + output.append( + f"# {title} {{olx-org={org} olx-course={course} olx-url_name={url_name}}}" + ) + output.append("") + + # ================ CHAPTERS ================ + chapters = self._sorted_items([p for p in root.iterdir() if p.is_dir()]) + + for chapter in chapters: + chapter_index = chapter / "index.md" + if not chapter_index.exists(): + continue + + fm, body = self._read_md(chapter_index) + if self._is_hidden(fm): + continue + + # Warn if chapter index.md has uncommented text + if self._has_uncommented_text(body): + print(f"[WARNING] Chapter index.md has uncommented text body, ignoring it: {chapter_index}") + + output.append(self._heading(2, fm.get("title", chapter.name))) + output.append("") + + # ================ SEQUENTIALS ================ + sequentials = self._sorted_items( + [p for p in chapter.iterdir() if p.is_dir()] + ) + + for sequential in sequentials: + seq_index = sequential / "index.md" + if not seq_index.exists(): + continue + + fm, body = self._read_md(seq_index) + if self._is_hidden(fm): + continue + + # Warn if sequential index.md has uncommented text + if self._has_uncommented_text(body): + print(f"[WARNING] Sequential index.md has uncommented text body, ignoring it: {seq_index}") + + output.append(self._heading(3, fm.get("title", sequential.name))) + output.append("") + + # ================ UNITS ================ + units = self._sorted_items( + [ + p for p in sequential.iterdir() + if p.suffix == ".md" and p.name != "index.md" + ] + ) + + for unit in units: + fm, body = self._read_md(unit) + if self._is_hidden(fm): + continue + + title = fm.get("title") + if title: + output.append(self._heading(4, title)) + output.append("") + + output.append(body.rstrip()) + output.append("") + + return "\n".join(output).strip() + "\n" \ No newline at end of file diff --git a/mu/main.py b/mu/main.py index 089bf8d..938842d 100755 --- a/mu/main.py +++ b/mu/main.py @@ -51,7 +51,7 @@ def parse_args() -> argparse.Namespace: description="Convert online courses from one format to another." ) parser.add_argument( - "-f", "--from-format", choices=["html", "md", "olx"], default=None + "-f", "--from-format", choices=["html", "md", "folder_md", "olx"], default=None ) parser.add_argument( "-t", "--to-format", choices=["html", "md", "olx"], default=None @@ -80,7 +80,11 @@ def parse_args() -> argparse.Namespace: elif args.input.lower().endswith(".md"): args.from_format = "md" elif os.path.isdir(args.input): - args.from_format = "olx" + # Check if it's a folder_md course (has index.md) + if os.path.isfile(os.path.join(args.input, "index.md")): + args.from_format = "folder_md" + else: + args.from_format = "olx" else: raise MuError("Could not detect input file format.") logger.info("Detected input format: %s", args.from_format) diff --git a/requirements/base.in b/requirements/base.in index 11b82b2..a6fcac0 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -2,3 +2,4 @@ beautifulsoup4 html5lib lxml mypy +PyYAML