Skip to content

Commit 06e9404

Browse files
committed
feat: add support for Book activities and chapters in course sync
1 parent e303d18 commit 06e9404

File tree

4 files changed

+571
-11
lines changed

4 files changed

+571
-11
lines changed

README.md

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ A Moodle local plugin that syncs course content from a GitHub repository. Conten
66

77
- **One-click sync** from a GitHub repository into a Moodle course
88
- **Automatic section creation** based on directory structure
9-
- **Page, Label, and URL activities** created from HTML files
9+
- **Page, Label, URL, and Book activities** created from HTML files and directories
1010
- **YAML front matter** support for controlling activity types
1111
- **Asset management** — CSS, images, and JS uploaded to Moodle file storage with automatic URL rewriting
1212
- **Incremental sync** — only changed files are updated (content hash tracking)
@@ -44,8 +44,13 @@ sections/
4444
01-introduction/
4545
section.yaml # Section title and summary (optional)
4646
01-welcome.html # -> Page activity "Welcome"
47-
02-overview.html # -> Page activity "Overview"
48-
03-notice.html # -> Label activity (with front matter)
47+
02-course-handbook/ # -> Book activity "Course Handbook"
48+
book.yaml # Optional: title, numbering, intro
49+
01-getting-started.html # -> Chapter 1
50+
02-assessment-guide.html # -> Chapter 2
51+
03-resources.html # -> Chapter 3
52+
03-overview.html # -> Page activity "Overview"
53+
04-notice.html # -> Label activity (with front matter)
4954
02-module-one/
5055
section.yaml
5156
01-lesson.html
@@ -117,11 +122,68 @@ url: "https://docs.moodle.org"
117122
**Supported front matter fields:**
118123
| Field | Description |
119124
|-------|-------------|
120-
| `type` | Activity type: `page` (default), `label`, `url` |
125+
| `type` | Activity type: `page` (default), `label`, or `url`. Books are created from directories, not front matter. Other Moodle activity types (quiz, forum, assign, etc.) are not yet implemented. Unrecognized types are treated as `page`. |
121126
| `name` | Override the activity name (otherwise derived from filename) |
122127
| `url` | External URL (required for `type: url`) |
123128
| `visible` | `true` or `false` |
124129

130+
### Book Activities
131+
132+
A subdirectory inside a section directory becomes a **Book** activity. Each `.html` file in the subdirectory becomes a chapter, ordered by numeric prefix. This maps one directory to one Moodle book module.
133+
134+
```
135+
sections/
136+
01-introduction/
137+
02-course-handbook/ # -> Book "Course Handbook"
138+
book.yaml # Optional metadata
139+
01-getting-started.html # -> Chapter 1 "Getting Started"
140+
02-assessment-guide.html # -> Chapter 2 "Assessment Guide"
141+
03-resources.html # -> Chapter 3 "Resources"
142+
```
143+
144+
#### book.yaml
145+
146+
Optional. Sets book-level metadata:
147+
148+
```yaml
149+
title: "Course Handbook"
150+
numbering: bullets
151+
intro: "<p>Reference handbook for this course.</p>"
152+
```
153+
154+
| Field | Description |
155+
|-------|-------------|
156+
| `title` | Override the book name (otherwise derived from directory name) |
157+
| `numbering` | Chapter numbering style: `none`, `numbers` (default), `bullets`, or `indented` |
158+
| `intro` | HTML description shown on the book's intro page |
159+
160+
#### Chapter Front Matter
161+
162+
Chapter HTML files support YAML front matter for per-chapter settings:
163+
164+
```html
165+
---
166+
title: "Getting Started Guide"
167+
subchapter: true
168+
---
169+
<h2>Welcome</h2>
170+
<p>This chapter covers...</p>
171+
```
172+
173+
| Field | Description |
174+
|-------|-------------|
175+
| `title` | Override the chapter title (otherwise derived from filename) |
176+
| `subchapter` | `true` to make this a sub-chapter (indented under the previous chapter). The first chapter in a book cannot be a subchapter. |
177+
178+
#### Sync Behavior for Books
179+
180+
- **New book directory**: Creates the book activity and all chapters in one operation
181+
- **Modified chapter**: Only the changed chapter is updated (content hash tracking)
182+
- **New chapter file**: Added at the correct position based on filename ordering
183+
- **Removed chapter file**: The chapter is hidden (not deleted), matching the behavior for removed pages
184+
- **Modified book.yaml**: Updates book name, numbering, and intro without touching chapters
185+
- **Reordered chapters** (renamed with different numeric prefixes): Page numbers are updated even if content is unchanged
186+
125187
### Assets
126188

127189
Files in the `assets/` directory are uploaded to Moodle's file storage. References in HTML are automatically rewritten:

classes/sync/course_builder.php

Lines changed: 173 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
require_once($CFG->dirroot . '/course/lib.php');
2222
require_once($CFG->dirroot . '/course/modlib.php');
2323
require_once($CFG->dirroot . '/lib/resourcelib.php');
24+
require_once($CFG->dirroot . '/mod/book/locallib.php');
2425

2526
/**
2627
* Handles creating and updating Moodle course structure from repo data.
@@ -46,7 +47,7 @@ public function __construct(\stdClass $course) {
4647
$this->course = $course;
4748

4849
// Cache module IDs for the types we support.
49-
foreach (['page', 'label', 'url'] as $modname) {
50+
foreach (['page', 'label', 'url', 'book'] as $modname) {
5051
$id = $DB->get_field('modules', 'id', ['name' => $modname]);
5152
if ($id) {
5253
$this->moduleids[$modname] = (int) $id;
@@ -270,6 +271,177 @@ public function create_url(int $sectionnum, string $name, string $url, string $i
270271
return $result->coursemodule;
271272
}
272273

274+
/**
275+
* Create a Book activity with chapters.
276+
*
277+
* @param int $sectionnum Section number
278+
* @param string $name Book name
279+
* @param array $chapters Ordered array of chapter data: ['title', 'content', 'subchapter', 'importsrc']
280+
* @param array $bookmeta Optional book metadata from book.yaml (numbering, intro)
281+
* @return int The course module ID (cmid)
282+
*/
283+
public function create_book(int $sectionnum, string $name, array $chapters, array $bookmeta = []): int {
284+
global $DB;
285+
286+
if (empty($this->moduleids['book'])) {
287+
throw new \moodle_exception('syncfailed', 'local_githubsync', '', 'Book module not available');
288+
}
289+
290+
// Map numbering string values to book constants.
291+
$numberingmap = ['none' => 0, 'numbers' => 1, 'bullets' => 2, 'indented' => 3];
292+
$numbering = 0;
293+
if (!empty($bookmeta['numbering']) && isset($numberingmap[$bookmeta['numbering']])) {
294+
$numbering = $numberingmap[$bookmeta['numbering']];
295+
}
296+
297+
$moduleinfo = new \stdClass();
298+
$moduleinfo->modulename = 'book';
299+
$moduleinfo->module = $this->moduleids['book'];
300+
$moduleinfo->name = !empty($bookmeta['title']) ? $bookmeta['title'] : $name;
301+
$moduleinfo->section = $sectionnum;
302+
$moduleinfo->visible = 1;
303+
$moduleinfo->visibleoncoursepage = 1;
304+
305+
$moduleinfo->intro = purify_html($bookmeta['intro'] ?? '');
306+
$moduleinfo->introformat = FORMAT_HTML;
307+
$moduleinfo->numbering = $numbering;
308+
$moduleinfo->navstyle = 1;
309+
$moduleinfo->customtitles = 0;
310+
311+
$result = add_moduleinfo($moduleinfo, $this->course);
312+
$cmid = $result->coursemodule;
313+
314+
// Get the book instance ID.
315+
$cm = get_coursemodule_from_id('book', $cmid, 0, false, MUST_EXIST);
316+
$bookid = $cm->instance;
317+
318+
// Insert chapters.
319+
$pagenum = 0;
320+
foreach ($chapters as $chapter) {
321+
$pagenum++;
322+
$rec = new \stdClass();
323+
$rec->bookid = $bookid;
324+
$rec->pagenum = $pagenum;
325+
$rec->subchapter = ($pagenum === 1) ? 0 : (!empty($chapter['subchapter']) ? 1 : 0);
326+
$rec->title = $chapter['title'];
327+
$rec->content = purify_html($chapter['content']);
328+
$rec->contentformat = FORMAT_HTML;
329+
$rec->hidden = 0;
330+
$rec->importsrc = $chapter['importsrc'] ?? '';
331+
$rec->timecreated = time();
332+
$rec->timemodified = time();
333+
$DB->insert_record('book_chapters', $rec);
334+
}
335+
336+
// Preload chapters to validate structure.
337+
$book = $DB->get_record('book', ['id' => $bookid], '*', MUST_EXIST);
338+
book_preload_chapters($book);
339+
340+
return $cmid;
341+
}
342+
343+
/**
344+
* Update book metadata (name, numbering, intro) from book.yaml.
345+
*
346+
* @param int $cmid Course module ID of the book
347+
* @param array $bookmeta Parsed book.yaml data
348+
*/
349+
public function update_book_metadata(int $cmid, array $bookmeta): void {
350+
global $DB;
351+
352+
$cm = get_coursemodule_from_id('book', $cmid, 0, false, MUST_EXIST);
353+
$book = $DB->get_record('book', ['id' => $cm->instance], '*', MUST_EXIST);
354+
355+
$numberingmap = ['none' => 0, 'numbers' => 1, 'bullets' => 2, 'indented' => 3];
356+
$changed = false;
357+
358+
if (!empty($bookmeta['title']) && $bookmeta['title'] !== $book->name) {
359+
$book->name = $bookmeta['title'];
360+
$changed = true;
361+
}
362+
if (!empty($bookmeta['numbering']) && isset($numberingmap[$bookmeta['numbering']])) {
363+
$newnumbering = $numberingmap[$bookmeta['numbering']];
364+
if ($newnumbering !== (int) $book->numbering) {
365+
$book->numbering = $newnumbering;
366+
$changed = true;
367+
}
368+
}
369+
if (isset($bookmeta['intro'])) {
370+
$newintro = purify_html($bookmeta['intro']);
371+
if ($newintro !== $book->intro) {
372+
$book->intro = $newintro;
373+
$book->introformat = FORMAT_HTML;
374+
$changed = true;
375+
}
376+
}
377+
378+
if ($changed) {
379+
$book->timemodified = time();
380+
$DB->update_record('book', $book);
381+
rebuild_course_cache($this->course->id, true);
382+
}
383+
}
384+
385+
/**
386+
* Update an existing book chapter found by importsrc.
387+
*
388+
* @param int $bookid Book instance ID
389+
* @param string $importsrc The repo path stored in importsrc
390+
* @param string $title Chapter title
391+
* @param string $content Chapter HTML content
392+
* @param bool $subchapter Whether this is a subchapter
393+
* @param int $pagenum New page number
394+
* @return bool True if found and updated
395+
*/
396+
public function update_book_chapter(int $bookid, string $importsrc, string $title,
397+
string $content, bool $subchapter, int $pagenum): bool {
398+
global $DB;
399+
400+
$chapter = $DB->get_record('book_chapters', ['bookid' => $bookid, 'importsrc' => $importsrc]);
401+
if (!$chapter) {
402+
return false;
403+
}
404+
405+
$chapter->title = $title;
406+
$chapter->content = purify_html($content);
407+
$chapter->contentformat = FORMAT_HTML;
408+
$chapter->subchapter = $subchapter ? 1 : 0;
409+
$chapter->pagenum = $pagenum;
410+
$chapter->hidden = 0;
411+
$chapter->timemodified = time();
412+
$DB->update_record('book_chapters', $chapter);
413+
414+
return true;
415+
}
416+
417+
/**
418+
* Create a new chapter in an existing book.
419+
*
420+
* @param int $bookid Book instance ID
421+
* @param string $title Chapter title
422+
* @param string $content Chapter HTML content
423+
* @param bool $subchapter Whether this is a subchapter
424+
* @param int $pagenum Page number for ordering
425+
* @param string $importsrc Repo path for tracking
426+
*/
427+
public function create_book_chapter(int $bookid, string $title, string $content,
428+
bool $subchapter, int $pagenum, string $importsrc): void {
429+
global $DB;
430+
431+
$rec = new \stdClass();
432+
$rec->bookid = $bookid;
433+
$rec->pagenum = $pagenum;
434+
$rec->subchapter = $subchapter ? 1 : 0;
435+
$rec->title = $title;
436+
$rec->content = purify_html($content);
437+
$rec->contentformat = FORMAT_HTML;
438+
$rec->hidden = 0;
439+
$rec->importsrc = $importsrc;
440+
$rec->timecreated = time();
441+
$rec->timemodified = time();
442+
$DB->insert_record('book_chapters', $rec);
443+
}
444+
273445
/**
274446
* Create an activity based on front matter type.
275447
*

0 commit comments

Comments
 (0)