Skip to content

Commit 802da0b

Browse files
committed
MDL-84440 backup: Add configurable default backup filename format
1 parent 7a318d5 commit 802da0b

File tree

7 files changed

+958
-76
lines changed

7 files changed

+958
-76
lines changed

admin/settings/courses.php

+38
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@
2626

2727
require_once($CFG->libdir . '/pdflib.php');
2828

29+
// Until MDL-83618 is fixed, this must be required as it will not be autoloaded.
30+
require_once($CFG->dirroot . '/backup/classes/backup_filename_helper.php');
31+
32+
use core\backup\backup_filename_helper;
2933
use core_admin\local\settings\filesize;
3034

3135
$capabilities = array(
@@ -439,6 +443,40 @@
439443
$temp->add(new admin_setting_configcheckbox_with_lock('backup/backup_general_legacyfiles',
440444
new lang_string('generallegacyfiles', 'backup'),
441445
new lang_string('configlegacyfiles', 'backup'), array('value' => 1, 'locked' => 0)));
446+
447+
// Filename defaults.
448+
$temp->add(new admin_setting_heading('defaultbackupfilenamesettings',
449+
new lang_string('defaultbackupfilenamesettings', 'backup'), ''));
450+
$temp->add(new admin_setting_description('defaultbackupfilenamesettings_help', '',
451+
new lang_string('defaultbackupfilenamesettings_help', 'backup'), ''));
452+
453+
$temp->add(new admin_setting_configmustachetemplate('backup/backup_default_filename_template_course',
454+
new lang_string('defaultbackupfilenamecourse', 'backup'),
455+
new lang_string('defaultbackupfilenamecourse_desc', 'backup'),
456+
backup_filename_helper::DEFAULT_FILENAME_TEMPLATE_COURSE,
457+
PARAM_TEXT,
458+
'60',
459+
'3'
460+
));
461+
462+
$temp->add(new admin_setting_configmustachetemplate('backup/backup_default_filename_template_section',
463+
new lang_string('defaultbackupfilenamesection', 'backup'),
464+
new lang_string('defaultbackupfilenamesection_desc', 'backup'),
465+
backup_filename_helper::DEFAULT_FILENAME_TEMPLATE_SECTION,
466+
PARAM_TEXT,
467+
'60',
468+
'3'
469+
));
470+
471+
$temp->add(new admin_setting_configtextarea('backup/backup_default_filename_template_activity',
472+
new lang_string('defaultbackupfilenameactivity', 'backup'),
473+
new lang_string('defaultbackupfilenameactivity_desc', 'backup'),
474+
backup_filename_helper::DEFAULT_FILENAME_TEMPLATE_ACTIVITY,
475+
PARAM_TEXT,
476+
'60',
477+
'3'
478+
));
479+
442480
$ADMIN->add('backups', $temp);
443481

444482
// Create a page for general import configuration and defaults.
+215
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
<?php
2+
// This file is part of Moodle - http://moodle.org/
3+
//
4+
// Moodle is free software: you can redistribute it and/or modify
5+
// it under the terms of the GNU General Public License as published by
6+
// the Free Software Foundation, either version 3 of the License, or
7+
// (at your option) any later version.
8+
//
9+
// Moodle is distributed in the hope that it will be useful,
10+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
// GNU General Public License for more details.
13+
//
14+
// You should have received a copy of the GNU General Public License
15+
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
16+
17+
namespace core\backup;
18+
19+
use core\context\course;
20+
use backup;
21+
use core\context\module;
22+
use core\exception\coding_exception;
23+
use core\output\mustache_engine;
24+
use core\output\mustache_string_helper;
25+
use core_text;
26+
use Throwable;
27+
28+
/**
29+
* Backup filename helper.
30+
*
31+
* @package core
32+
* @subpackage backup
33+
* @copyright 2025 Matthew Hilton <[email protected]>
34+
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
35+
*/
36+
class backup_filename_helper {
37+
/**
38+
* @var string Default template for course backups
39+
*/
40+
public const DEFAULT_FILENAME_TEMPLATE_COURSE = '{{#str}}backupfilename{{/str}}-{{format}}-{{type}}-{{id}}{{^useidonly}}-{{course.shortname}}{{/useidonly}}-{{date}}' .
41+
'{{^users}}-nu{{/users}}{{#anonymised}}{{#users}}-an{{/users}}{{/anonymised}}{{^files}}-nf{{/files}}';
42+
43+
/**
44+
* @var string Default template for section backups
45+
*/
46+
public const DEFAULT_FILENAME_TEMPLATE_SECTION = '{{#str}}backupfilename{{/str}}-{{format}}-{{type}}-{{id}}{{^useidonly}}{{#section.name}}-{{section.name}}' .
47+
'{{/section.name}}{{^section.name}}-{{section.section}}{{/section.name}}{{/useidonly}}-{{date}}{{^users}}' .
48+
'-nu{{/users}}{{#anonymised}}{{#users}}-an{{/users}}{{/anonymised}}{{^files}}-nf{{/files}}';
49+
50+
/**
51+
* @var string Default template for activity backups
52+
*/
53+
public const DEFAULT_FILENAME_TEMPLATE_ACTIVITY = '{{#str}}backupfilename{{/str}}-{{format}}-{{type}}-{{id}}{{^useidonly}}-{{activity.modname}}{{id}}{{/useidonly}}' .
54+
'-{{date}}{{^users}}-nu{{/users}}{{#anonymised}}{{#users}}-an{{/users}}{{/anonymised}}{{^files}}-nf{{/files}}';
55+
56+
/**
57+
* Get mustache engine instance
58+
* @return mustache_engine
59+
*/
60+
private function get_mustache(): mustache_engine {
61+
return new mustache_engine([
62+
'helpers' => [
63+
'str' => [new mustache_string_helper(), 'str'],
64+
],
65+
]);
66+
}
67+
68+
/**
69+
* Returns the default backup filename, based in passed params.
70+
*
71+
* @param string $format One of backup::FORMAT_
72+
* @param string $type One of backup::TYPE_
73+
* @param int $id course id, section id, or course module id
74+
* @param bool $users Should be true is users were included in the backup
75+
* @param bool $anonymised Should be true is user information was anonymized
76+
* @param bool $useidonly only use the ID in the file name
77+
* @param bool $files if files are included
78+
* @param int|null $time time to use in any dates, if not given uses current time
79+
* @return string The filename to use
80+
*/
81+
public function get_default_backup_filename(string $format, string $type, int $id, bool $users, bool $anonymised,
82+
bool $useidonly = false, bool $files = true, ?int $time = null): string {
83+
global $DB;
84+
85+
if ($time === null) {
86+
$time = time();
87+
}
88+
89+
$backupdateformat = str_replace(' ', '_', get_string('backupnameformat', 'langconfig'));
90+
$formatdate = function(int $date) use ($backupdateformat): string {
91+
$date = userdate($date, $backupdateformat, 99, false);
92+
return core_text::strtolower(trim(clean_filename($date), '_'));
93+
};
94+
95+
$mustachecontext = [
96+
'format' => $format,
97+
'type' => $type,
98+
'id' => $id,
99+
'users' => $users,
100+
'anonymised' => $anonymised,
101+
'files' => $files,
102+
'useidonly' => $useidonly,
103+
'time' => $time,
104+
'date' => $formatdate($time),
105+
];
106+
107+
// Add extra context based on the type of backup.
108+
// It is important to use array and not stdClass here, otherwise array_walk_recursive will not work.
109+
switch ($type) {
110+
case backup::TYPE_1COURSE:
111+
$mustachecontext['course'] = (array) $DB->get_record('course', ['id' => $id],
112+
'shortname,fullname,startdate,enddate', MUST_EXIST);
113+
$mustachecontext['course']['startdate'] = $formatdate($mustachecontext['course']['startdate']);
114+
$mustachecontext['course']['enddate'] = $formatdate($mustachecontext['course']['enddate']);
115+
break;
116+
case backup::TYPE_1SECTION:
117+
$mustachecontext['section'] = (array) $DB->get_record('course_sections', ['id' => $id], 'name,section', MUST_EXIST);
118+
break;
119+
case backup::TYPE_1ACTIVITY:
120+
$cm = get_coursemodule_from_id(null, $id, 0, false, MUST_EXIST);
121+
$mustachecontext['activity'] = [
122+
'modname' => $cm->modname,
123+
'name' => $cm->name,
124+
];
125+
break;
126+
default:
127+
throw new coding_exception('Unknown backup type - cannot get template context');
128+
}
129+
130+
// Get the relevant context for the item, which is used for format_string.
131+
$itemcontext = null;
132+
switch ($type) {
133+
case backup::TYPE_1COURSE:
134+
$itemcontext = course::instance($id);
135+
break;
136+
case backup::TYPE_1SECTION:
137+
// A section is still course context, but needs an extra step to find the course id.
138+
$courseid = $DB->get_field('course_sections', 'course', ['id' => $id], MUST_EXIST);
139+
$itemcontext = course::instance($courseid);
140+
break;
141+
case backup::TYPE_1ACTIVITY:
142+
$itemcontext = module::instance($id);
143+
break;
144+
default:
145+
throw new coding_exception('Unknown backup type - cannot get instance context');
146+
}
147+
148+
// Recursively format all the strings and trim any extra whitespace.
149+
array_walk_recursive($mustachecontext, function(&$item) use ($itemcontext) {
150+
if (is_string($item)) {
151+
// Update by reference.
152+
$item = trim(format_string($item, true, ['context' => $itemcontext]));
153+
}
154+
});
155+
156+
// List of templates in order (if one fails, go to next) for each type.
157+
$templates = [
158+
backup::TYPE_1COURSE => [
159+
get_config('backup', 'backup_default_filename_template_course'),
160+
self::DEFAULT_FILENAME_TEMPLATE_COURSE,
161+
],
162+
backup::TYPE_1SECTION => [
163+
get_config('backup', 'backup_default_filename_template_section'),
164+
self::DEFAULT_FILENAME_TEMPLATE_SECTION,
165+
],
166+
backup::TYPE_1ACTIVITY => [
167+
get_config('backup', 'backup_default_filename_template_activity'),
168+
self::DEFAULT_FILENAME_TEMPLATE_ACTIVITY
169+
],
170+
];
171+
172+
$mustache = $this->get_mustache();
173+
174+
// Render the templates until one succeeds.
175+
foreach ($templates[$type] as $possibletemplate) {
176+
try {
177+
$new = @$mustache->render($possibletemplate, $mustachecontext);
178+
179+
// Clean as filename, remove spaces, and trim to max 251 chars (filename limit, 255 including .mbz extension).
180+
$cleaned = substr(str_replace(' ', '_', clean_filename($new)), 0, 251);
181+
182+
// Success - this template rendered - return it.
183+
return $cleaned . '.mbz';
184+
} catch (Throwable $e) {
185+
// Skip and try the next.
186+
continue;
187+
}
188+
}
189+
190+
// The only way to reach here would be if the constant default templates
191+
// became broken, which indicates a coding error.
192+
throw new coding_exception("No backup filename templates rendered correctly");
193+
}
194+
195+
/**
196+
* Validates the given template renders correctly.
197+
*
198+
* Used mainly for form validation.
199+
* @param string $template mustache template
200+
* @return array array of errors, if empty then there are no errors
201+
*/
202+
public function validate(string $template): array {
203+
try {
204+
// Rnder without any context, if it is syntatically invalid,
205+
// this will throw an exception.
206+
// this also outputs warnings if invalid, so we just ignore them using '@'.
207+
@$this->get_mustache()->render($template);
208+
209+
// All good - is valid!
210+
return [];
211+
} catch (Throwable $e) {
212+
return [$e->getMessage()];
213+
}
214+
}
215+
}

0 commit comments

Comments
 (0)