Skip to content

Commit 58bc1be

Browse files
committed
MDL-84440 backup: Add configurable default backup filename format
1 parent 2f82f5f commit 58bc1be

13 files changed

+981
-83
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
issueNumber: MDL-84440
2+
notes:
3+
core_backup:
4+
- message: >-
5+
backup_plan_dbops::get_default_backup_filename has been deprecated.
6+
Please use backup_filename_helper::get_default_backup_filename
7+
type: deprecated

admin/cli/backup.php

+3-1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
2424
*/
2525

26+
use core\backup\backup_filename_helper;
27+
2628
define('CLI_SCRIPT', 1);
2729

2830
require(__DIR__.'/../../config.php');
@@ -104,7 +106,7 @@
104106
$id = $bc->get_id();
105107
$users = $bc->get_plan()->get_setting('users')->get_value();
106108
$anonymised = $bc->get_plan()->get_setting('anonymize')->get_value();
107-
$filename = backup_plan_dbops::get_default_backup_filename($format, $type, $id, $users, $anonymised);
109+
$filename = (new backup_filename_helper())->get_default_backup_filename($format, $type, $id, $users, $anonymised);
108110
$bc->get_plan()->get_setting('filename')->set_value($filename);
109111

110112
// Execution.

admin/settings/courses.php

+37
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@
2525
defined('MOODLE_INTERNAL') || die();
2626

2727
require_once($CFG->libdir . '/pdflib.php');
28+
// Until MDL-83618 is fixed, this must be required as it will not be autoloaded.
29+
require_once($CFG->dirroot . '/backup/classes/backup_filename_helper.php');
2830

31+
use core\backup\backup_filename_helper;
2932
use core_admin\local\settings\filesize;
3033

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

444481
// Create a page for general import configuration and defaults.
+209
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
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_backup
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}}-' .
41+
'{{course.shortname}}{{/useidonly}}-{{date}}{{^users}}-nu{{/users}}{{#anonymised}}{{#users}}-an{{/users}}{{/anonymised}}' .
42+
'{{^files}}-nf{{/files}}';
43+
44+
/**
45+
* @var string Default template for section backups
46+
*/
47+
public const DEFAULT_FILENAME_TEMPLATE_SECTION = '{{#str}}backupfilename{{/str}}-{{format}}-{{type}}-{{id}}{{^useidonly}}' .
48+
'{{#section.name}}-{{section.name}}{{/section.name}}{{^section.name}}-{{section.section}}{{/section.name}}{{/useidonly}}-' .
49+
'{{date}}{{^users}}-nu{{/users}}{{#anonymised}}{{#users}}-an{{/users}}{{/anonymised}}{{^files}}-nf{{/files}}';
50+
51+
/**
52+
* @var string Default template for activity backups
53+
*/
54+
public const DEFAULT_FILENAME_TEMPLATE_ACTIVITY = '{{#str}}backupfilename{{/str}}-{{format}}-{{type}}-{{id}}{{^useidonly}}' .
55+
'-{{activity.modname}}{{id}}{{/useidonly}}-{{date}}{{^users}}-nu{{/users}}{{#anonymised}}{{#users}}-an{{/users}}' .
56+
'{{/anonymised}}{{^files}}-nf{{/files}}';
57+
58+
/**
59+
* Get mustache engine instance
60+
* @return mustache_engine
61+
*/
62+
private function get_mustache(): mustache_engine {
63+
return new mustache_engine([
64+
'helpers' => [
65+
'str' => [new mustache_string_helper(), 'str'],
66+
],
67+
]);
68+
}
69+
70+
/**
71+
* Returns the default backup filename, based on the passed params.
72+
*
73+
* @param string $format One of backup::FORMAT_
74+
* @param string $type One of backup::TYPE_
75+
* @param int $id course id, section id, or course module id
76+
* @param bool $users Should be true is users were included in the backup
77+
* @param bool $anonymised Should be true is user information was anonymized
78+
* @param bool $useidonly only use the ID in the file name
79+
* @param bool $files if files are included
80+
* @param int|null $time time to use in any dates, if not given uses current time
81+
* @return string The filename to use
82+
*/
83+
public function get_default_backup_filename(string $format, string $type, int $id, bool $users, bool $anonymised,
84+
bool $useidonly = false, bool $files = true, ?int $time = null): string {
85+
global $DB;
86+
87+
if ($time === null) {
88+
$time = time();
89+
}
90+
91+
$backupdateformat = str_replace(' ', '_', get_string('backupnameformat', 'langconfig'));
92+
$formatdate = function(int $date) use ($backupdateformat): string {
93+
$date = userdate($date, $backupdateformat, 99, false);
94+
return core_text::strtolower(trim(clean_filename($date), '_'));
95+
};
96+
97+
$mustachecontext = [
98+
'format' => $format,
99+
'type' => $type,
100+
'id' => $id,
101+
'users' => $users,
102+
'anonymised' => $anonymised,
103+
'files' => $files,
104+
'useidonly' => $useidonly,
105+
'time' => $time,
106+
'date' => $formatdate($time),
107+
];
108+
109+
// Add extra context based on the type of backup.
110+
// It is important to use array and not stdClass here, otherwise array_walk_recursive will not work.
111+
// Additionally get the moodle context of an item, which is used for format_string.
112+
$itemcontext = null;
113+
switch ($type) {
114+
case backup::TYPE_1COURSE:
115+
$mustachecontext['course'] = (array) $DB->get_record('course', ['id' => $id],
116+
'shortname,fullname,startdate,enddate', MUST_EXIST);
117+
$mustachecontext['course']['startdate'] = $formatdate($mustachecontext['course']['startdate']);
118+
$mustachecontext['course']['enddate'] = $formatdate($mustachecontext['course']['enddate']);
119+
120+
$itemcontext = course::instance($id);
121+
break;
122+
case backup::TYPE_1SECTION:
123+
$mustachecontext['section'] = (array) $DB->get_record('course_sections', ['id' => $id], 'name,section', MUST_EXIST);
124+
125+
// A section is still course context, but needs an extra step to find the course id.
126+
$courseid = $DB->get_field('course_sections', 'course', ['id' => $id], MUST_EXIST);
127+
$itemcontext = course::instance($courseid);
128+
break;
129+
case backup::TYPE_1ACTIVITY:
130+
$cm = get_coursemodule_from_id(null, $id, 0, false, MUST_EXIST);
131+
$mustachecontext['activity'] = [
132+
'modname' => $cm->modname,
133+
'name' => $cm->name,
134+
];
135+
136+
$itemcontext = module::instance($id);
137+
break;
138+
default:
139+
throw new coding_exception('Unknown backup type ' . $type);
140+
}
141+
142+
// Recursively format all the strings and trim any extra whitespace.
143+
array_walk_recursive($mustachecontext, function(&$item) use ($itemcontext) {
144+
if (is_string($item)) {
145+
// Update by reference.
146+
$item = trim(format_string($item, true, ['context' => $itemcontext]));
147+
}
148+
});
149+
150+
// List of templates in order (if one fails, go to next) for each type.
151+
$templates = [
152+
backup::TYPE_1COURSE => [
153+
get_config('backup', 'backup_default_filename_template_course'),
154+
self::DEFAULT_FILENAME_TEMPLATE_COURSE,
155+
],
156+
backup::TYPE_1SECTION => [
157+
get_config('backup', 'backup_default_filename_template_section'),
158+
self::DEFAULT_FILENAME_TEMPLATE_SECTION,
159+
],
160+
backup::TYPE_1ACTIVITY => [
161+
get_config('backup', 'backup_default_filename_template_activity'),
162+
self::DEFAULT_FILENAME_TEMPLATE_ACTIVITY,
163+
],
164+
];
165+
166+
$mustache = $this->get_mustache();
167+
168+
// Render the templates until one succeeds.
169+
foreach ($templates[$type] as $possibletemplate) {
170+
try {
171+
$new = @$mustache->render($possibletemplate, $mustachecontext);
172+
173+
// Clean as filename, remove spaces, and trim to max 251 chars (filename limit, 255 including .mbz extension).
174+
$cleaned = substr(str_replace(' ', '_', clean_filename($new)), 0, 251);
175+
176+
// Success - this template rendered - return it.
177+
return $cleaned . '.mbz';
178+
} catch (Throwable $e) {
179+
// Skip and try the next.
180+
continue;
181+
}
182+
}
183+
184+
// At a minumum the fallback default filenames should have rendered correctly.
185+
// If we reached here it means this did not happen and that something is very wrong.
186+
throw new coding_exception("No backup filename templates rendered correctly");
187+
}
188+
189+
/**
190+
* Validates the given template renders correctly.
191+
*
192+
* Used mainly for form validation.
193+
* @param string $template mustache template
194+
* @return array array of string error messages, if empty then there are no errors and it is valid
195+
*/
196+
public function validate(string $template): array {
197+
try {
198+
// Render without any context, if it is syntatically invalid,
199+
// this will throw an exception.
200+
// this also outputs warnings if invalid, so we just ignore them using '@'.
201+
@$this->get_mustache()->render($template);
202+
203+
// No exceptions thrown - is valid!
204+
return [];
205+
} catch (Throwable $e) {
206+
return [$e->getMessage()];
207+
}
208+
}
209+
}

0 commit comments

Comments
 (0)