Skip to content

Commit f995332

Browse files
committed
MDL-87816 langimport: Add hooks before and after langpack update
1 parent 746815a commit f995332

File tree

4 files changed

+291
-8
lines changed

4 files changed

+291
-8
lines changed

public/admin/tool/langimport/classes/controller.php

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ class controller {
4646
private $installer;
4747
/** @var array languages available on the remote server */
4848
public $availablelangs;
49+
/** @var array list of language codes that were successfully updated */
50+
private $updatedlangs;
4951

5052
/**
5153
* Constructor.
@@ -56,6 +58,7 @@ public function __construct() {
5658

5759
$this->info = array();
5860
$this->errors = array();
61+
$this->updatedlangs = array();
5962
$this->installer = new \lang_installer();
6063

6164
$this->availablelangs = $this->installer->get_remote_list_of_languages();
@@ -85,7 +88,7 @@ public function redirect(moodle_url $url): void {
8588
*
8689
* @param string|array $langs array of langcodes or individual langcodes
8790
* @param bool $updating true if updating the langpacks
88-
* @return int false if an error encountered or
91+
* @return int number of successfully installed/updated language packs
8992
* @throws \moodle_exception when error is encountered installing langpack
9093
*/
9194
public function install_languagepacks($langs, $updating = false) {
@@ -94,8 +97,6 @@ public function install_languagepacks($langs, $updating = false) {
9497
$this->installer->set_queue($langs);
9598
$results = $this->installer->run();
9699

97-
$updatedpacks = 0;
98-
99100
foreach ($results as $langcode => $langstatus) {
100101
switch ($langstatus) {
101102
case \lang_installer::RESULT_DOWNLOADERROR:
@@ -106,7 +107,7 @@ public function install_languagepacks($langs, $updating = false) {
106107
throw new \moodle_exception('remotedownloaderror', 'error', '', $a);
107108
break;
108109
case \lang_installer::RESULT_INSTALLED:
109-
$updatedpacks++;
110+
$this->updatedlangs[] = $langcode;
110111
if ($updating) {
111112
event\langpack_updated::event_with_langcode($langcode)->trigger();
112113
$this->info[] = get_string('langpackupdated', 'tool_langimport', $langcode);
@@ -121,7 +122,7 @@ public function install_languagepacks($langs, $updating = false) {
121122
}
122123
}
123124

124-
return $updatedpacks;
125+
return count($this->updatedlangs);
125126
}
126127

127128
/**
@@ -214,15 +215,23 @@ public function update_all_installed_languages() {
214215
}
215216
}
216217

218+
// Dispatch hook before updating language packs.
219+
$hook = new \tool_langimport\hook\before_langpacks_updated($neededlangs);
220+
\core\di::get(\core\hook\manager::class)->dispatch($hook);
221+
217222
try {
218-
$updated = $this->install_languagepacks($neededlangs, true);
223+
$this->install_languagepacks($neededlangs, true);
219224
} catch (\moodle_exception $e) {
220225
$this->errors[] = 'An exception occurred while installing language packs: ' . $e->getMessage();
221-
return false;
222226
}
223227

224-
if ($updated) {
228+
if (!empty($this->updatedlangs)) {
225229
$this->info[] = get_string('langupdatecomplete', 'tool_langimport');
230+
231+
// Dispatch hook before purging the string cache.
232+
$hook = new \tool_langimport\hook\after_langpacks_updated($this->updatedlangs);
233+
\core\di::get(\core\hook\manager::class)->dispatch($hook);
234+
226235
// The strings have been changed so we need to purge their cache to ensure users see the changes.
227236
get_string_manager()->reset_caches();
228237
} else {
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
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 tool_langimport\hook;
18+
19+
/**
20+
* Hook dispatched after language packs have been updated, before the string cache is purged.
21+
*
22+
* This hook allows plugins to perform actions after language pack updates
23+
* are completed but before the language string cache is reset.
24+
*
25+
* @package tool_langimport
26+
* @copyright 2026 ISB Bayern
27+
* @author Dr. Peter Mayer
28+
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
29+
*/
30+
#[\core\attribute\label('Allows plugins to perform actions after language packs are updated, before the string cache is purged.')]
31+
#[\core\attribute\tags('language', 'langimport')]
32+
class after_langpacks_updated {
33+
/**
34+
* Constructor for the hook.
35+
*
36+
* @param array $updatedlangs Array of language codes that were updated.
37+
*/
38+
public function __construct(
39+
/** @var array Array of language codes that were updated */
40+
public readonly array $updatedlangs,
41+
) {
42+
}
43+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
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 tool_langimport\hook;
18+
19+
/**
20+
* Hook dispatched before language packs are updated.
21+
*
22+
* This hook allows plugins to perform actions before language pack updates
23+
* are executed, such as backing up current translations or preparing for changes.
24+
*
25+
* @package tool_langimport
26+
* @copyright 2026 ISB Bayern
27+
* @author Dr. Peter Mayer
28+
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
29+
*/
30+
#[\core\attribute\label('Allows plugins to perform actions before language packs are updated.')]
31+
#[\core\attribute\tags('language', 'langimport')]
32+
class before_langpacks_updated {
33+
/**
34+
* Constructor for the hook.
35+
*
36+
* @param array $langstobeupdated Array of language codes that will be updated.
37+
*/
38+
public function __construct(
39+
/** @var array Array of language codes that will be updated */
40+
public readonly array $langstobeupdated,
41+
) {
42+
}
43+
}
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
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 tool_langimport\hook;
18+
19+
/**
20+
* Tests for langimport hooks before and after language pack updates.
21+
*
22+
* @package tool_langimport
23+
* @category test
24+
* @copyright 2026 ISB Bayern
25+
* @author Dr. Peter Mayer
26+
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
27+
*/
28+
final class hook_test extends \advanced_testcase {
29+
/**
30+
* Test the before_langpacks_updated hook: construction, data integrity, and dispatching.
31+
*
32+
* @covers \tool_langimport\hook\before_langpacks_updated
33+
*/
34+
public function test_before_langpacks_updated(): void {
35+
// Verify construction with multiple languages and data integrity.
36+
$langs = ['de', 'fr', 'es'];
37+
$hook = new before_langpacks_updated($langs);
38+
$this->assertInstanceOf(before_langpacks_updated::class, $hook);
39+
$this->assertSame($langs, $hook->langstobeupdated);
40+
41+
// Verify construction with empty array.
42+
$emptyhook = new before_langpacks_updated([]);
43+
$this->assertSame([], $emptyhook->langstobeupdated);
44+
45+
// Verify the hook is correctly dispatched and received.
46+
$count = 0;
47+
$receivedhook = null;
48+
$this->redirectHook(
49+
before_langpacks_updated::class,
50+
function (before_langpacks_updated $hook) use (&$receivedhook, &$count): void {
51+
$count++;
52+
$receivedhook = $hook;
53+
}
54+
);
55+
56+
$dispatched = new before_langpacks_updated(['de', 'fr']);
57+
\core\hook\manager::get_instance()->dispatch($dispatched);
58+
59+
$this->assertSame(1, $count);
60+
$this->assertSame($dispatched, $receivedhook);
61+
$this->assertSame(['de', 'fr'], $receivedhook->langstobeupdated);
62+
}
63+
64+
/**
65+
* Test the after_langpacks_updated hook: construction, data integrity, and dispatching.
66+
*
67+
* @covers \tool_langimport\hook\after_langpacks_updated
68+
*/
69+
public function test_after_langpacks_updated(): void {
70+
// Verify construction with multiple languages and data integrity.
71+
$langs = ['de', 'fr', 'es'];
72+
$hook = new after_langpacks_updated($langs);
73+
$this->assertInstanceOf(after_langpacks_updated::class, $hook);
74+
$this->assertSame($langs, $hook->updatedlangs);
75+
76+
// Verify construction with empty array.
77+
$emptyhook = new after_langpacks_updated([]);
78+
$this->assertSame([], $emptyhook->updatedlangs);
79+
80+
// Verify the hook is correctly dispatched and received.
81+
$count = 0;
82+
$receivedhook = null;
83+
$this->redirectHook(
84+
after_langpacks_updated::class,
85+
function (after_langpacks_updated $hook) use (&$receivedhook, &$count): void {
86+
$count++;
87+
$receivedhook = $hook;
88+
}
89+
);
90+
91+
$dispatched = new after_langpacks_updated(['de', 'fr']);
92+
\core\hook\manager::get_instance()->dispatch($dispatched);
93+
94+
$this->assertSame(1, $count);
95+
$this->assertSame($dispatched, $receivedhook);
96+
$this->assertSame(['de', 'fr'], $receivedhook->updatedlangs);
97+
}
98+
99+
/**
100+
* Test that after_langpacks_updated hook is dispatched with only the successfully updated
101+
* languages when install_languagepacks partially fails with an exception.
102+
*
103+
* This simulates the scenario where e.g. 3 of 5 langpacks are updated successfully
104+
* before a download error occurs on the 4th. The after hook should still fire with
105+
* the 3 successful langcodes.
106+
*
107+
* @covers \tool_langimport\hook\after_langpacks_updated
108+
* @covers \tool_langimport\hook\before_langpacks_updated
109+
*/
110+
public function test_after_hook_dispatched_on_partial_failure(): void {
111+
$this->resetAfterTest();
112+
113+
// Set up hook capture for both hooks.
114+
$receivedbefore = null;
115+
$receivedafter = null;
116+
$this->redirectHook(
117+
before_langpacks_updated::class,
118+
function (before_langpacks_updated $hook) use (&$receivedbefore): void {
119+
$receivedbefore = $hook;
120+
}
121+
);
122+
$this->redirectHook(
123+
after_langpacks_updated::class,
124+
function (after_langpacks_updated $hook) use (&$receivedafter): void {
125+
$receivedafter = $hook;
126+
}
127+
);
128+
129+
// Create a controller mock that skips the real constructor (which does remote calls).
130+
$controller = $this->getMockBuilder(\tool_langimport\controller::class)
131+
->disableOriginalConstructor()
132+
->onlyMethods(['install_languagepacks'])
133+
->getMock();
134+
135+
// Initialise public properties that the constructor would normally set.
136+
$controller->info = [];
137+
$controller->errors = [];
138+
$controller->updatedlangs = [];
139+
140+
// Simulate partial success: install_languagepacks records 2 successful updates
141+
// in updatedlangs, then throws an exception (as if the 3rd langpack failed).
142+
$controller->method('install_languagepacks')
143+
->willReturnCallback(function () use ($controller): int {
144+
$controller->updatedlangs = ['de', 'fr'];
145+
throw new \moodle_exception('remotedownloaderror', 'error');
146+
});
147+
148+
// Use reflection to set the private installer mock (needed for get_remote_list_of_languages).
149+
$installermethod = new \ReflectionProperty(\tool_langimport\controller::class, 'installer');
150+
$installermock = $this->createMock(\lang_installer::class);
151+
$installermock->method('get_remote_list_of_languages')->willReturn([
152+
['de', 'abc123'],
153+
['fr', 'def456'],
154+
['es', 'ghi789'],
155+
]);
156+
$installermethod->setValue($controller, $installermock);
157+
158+
// Also mock is_installed_lang to indicate all packs need updating.
159+
// Since we mocked install_languagepacks, we need update_all_installed_languages to
160+
// call through — but is_installed_lang is not mockable without adding it to onlyMethods.
161+
// Instead, we simulate the update flow by directly calling the relevant code path.
162+
163+
// Simulate the controller flow: dispatch before hook, try install, catch, then check updatedlangs.
164+
$neededlangs = ['de', 'fr', 'es'];
165+
$hook = new before_langpacks_updated($neededlangs);
166+
\core\hook\manager::get_instance()->dispatch($hook);
167+
168+
try {
169+
$controller->install_languagepacks($neededlangs, true);
170+
} catch (\moodle_exception $e) {
171+
$controller->errors[] = 'An exception occurred while installing language packs: ' . $e->getMessage();
172+
}
173+
174+
// After the catch, the controller dispatches the after hook if any packs succeeded.
175+
if (!empty($controller->updatedlangs)) {
176+
$afterhook = new after_langpacks_updated($controller->updatedlangs);
177+
\core\hook\manager::get_instance()->dispatch($afterhook);
178+
}
179+
180+
// Before hook should have received all 3 planned languages.
181+
$this->assertNotNull($receivedbefore);
182+
$this->assertSame(['de', 'fr', 'es'], $receivedbefore->langstobeupdated);
183+
184+
// After hook should have received only the 2 successfully updated languages.
185+
$this->assertNotNull($receivedafter);
186+
$this->assertSame(['de', 'fr'], $receivedafter->updatedlangs);
187+
}
188+
}

0 commit comments

Comments
 (0)