Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
29.73% covered (danger)
29.73%
55 / 185
37.50% covered (danger)
37.50%
3 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
ClientHooks
29.73% covered (danger)
29.73%
55 / 185
37.50% covered (danger)
37.50%
3 / 8
320.82
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 onPageSaveComplete
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
5.01
 onPageDeleteComplete
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
4.18
 onPageMoveComplete
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
5.07
 onMakeGlobalVariablesScript
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
5
 loadProviderList
15.38% covered (danger)
15.38%
2 / 13
0.00% covered (danger)
0.00%
0 / 1
13.69
 onResourceLoaderRegisterModules
1.72% covered (danger)
1.72%
2 / 116
0.00% covered (danger)
0.00%
0 / 1
11.54
 getClientTargetUrl
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3/**
4 * WikiLambda extension Parser-related ('client-mode') hooks
5 *
6 * @file
7 * @ingroup Extensions
8 * @copyright 2020– Abstract Wikipedia team; see AUTHORS.txt
9 * @license MIT
10 */
11
12namespace MediaWiki\Extension\WikiLambda\HookHandler;
13
14use MediaWiki\Config\Config;
15use MediaWiki\Extension\CommunityConfiguration\Provider\ConfigurationProviderFactory;
16use MediaWiki\Extension\WikiLambda\WikiLambdaMode;
17use MediaWiki\Extension\WikiLambda\WikiLambdaServices;
18use MediaWiki\Linker\LinkTarget;
19use MediaWiki\Logger\LoggerFactory;
20use MediaWiki\Output\OutputPage;
21use MediaWiki\Page\ProperPageIdentity;
22use MediaWiki\Page\WikiPage;
23use MediaWiki\Permissions\Authority;
24use MediaWiki\Registration\ExtensionRegistry;
25use MediaWiki\ResourceLoader\CodexModule;
26use MediaWiki\ResourceLoader\ImageModule;
27use MediaWiki\ResourceLoader\ResourceLoader;
28use MediaWiki\Revision\RevisionRecord;
29use MediaWiki\Storage\EditResult;
30use MediaWiki\Title\Title;
31use MediaWiki\User\UserIdentity;
32use MediaWiki\WikiMap\WikiMap;
33use Psr\Log\LoggerInterface;
34use Throwable;
35
36class ClientHooks implements
37    \MediaWiki\Storage\Hook\PageSaveCompleteHook,
38    \MediaWiki\Page\Hook\PageDeleteCompleteHook,
39    \MediaWiki\Hook\PageMoveCompleteHook,
40    \MediaWiki\ResourceLoader\Hook\ResourceLoaderRegisterModulesHook,
41    \MediaWiki\Output\Hook\MakeGlobalVariablesScriptHook
42{
43    private LoggerInterface $logger;
44
45    public function __construct(
46        private readonly Config $config,
47        private readonly WikiLambdaMode $mode,
48        private readonly ?ConfigurationProviderFactory $providerFactory,
49    ) {
50        // Non-injected items
51        $this->logger = LoggerFactory::getInstance( 'WikiLambdaClient' );
52    }
53
54    /**
55     * @see https://www.mediawiki.org/wiki/Manual:Hooks/PageSaveComplete
56     *
57     * @param WikiPage $wikiPage
58     * @param UserIdentity $user
59     * @param string $summary
60     * @param int $flags
61     * @param RevisionRecord $revisionRecord
62     * @param EditResult $editResult
63     * @return bool|void
64     */
65    public function onPageSaveComplete(
66        $wikiPage,
67        $user,
68        $summary,
69        $flags,
70        $revisionRecord,
71        $editResult
72    ) {
73        if ( !$this->mode->isClient() ) {
74            // Nothing for us to do.
75            return;
76        }
77
78        if ( defined( 'MW_UPDATER' ) || defined( 'MEDIAWIKI_INSTALL' ) ) {
79            // During an install or schema upgrade the wiki's pages are being (re)created by
80            // the bootstrap before the cross-wiki usage table exists (it lives on a virtual
81            // domain, so its schema update runs in a later pass than the page creation). A
82            // freshly bootstrapped page has no prior usage to clear anyway, so skip the write.
83            // Mirrors Echo's PageSaveComplete guard against the same install-time problem.
84            return;
85        }
86
87        // Clear this page's rows from the shared cross-wiki usage table (T390557); any
88        // Functions still in use are re-recorded afterwards by WikifunctionsClientUsageUpdateJob.
89        //
90        // NOTE: This fires on every page save and deletes by (wiki, page_id) even for the
91        // vast majority of pages that never use a Function, so it is usually a no-op delete
92        // against the shared x1 cluster. We accept that for now.
93        $pageId = $wikiPage->getId();
94        if ( $pageId > 0 ) {
95            $this->logger->debug( __METHOD__ . ': Clearing usage tracking for {page}', [
96                'page' => $wikiPage->getTitle()->getFullText(),
97            ] );
98            WikiLambdaServices::getWikifunctionsUsageStore()->deleteUsageForPage(
99                WikiMap::getCurrentWikiId(),
100                $pageId
101            );
102        }
103    }
104
105    /**
106     * @see https://www.mediawiki.org/wiki/Manual:Hooks/PageDeleteComplete
107     *
108     * @param ProperPageIdentity $page
109     * @param Authority $deleter
110     * @param string $reason
111     * @param int $pageID
112     * @param RevisionRecord $deletedRev
113     * @param \ManualLogEntry $logEntry
114     * @param int $archivedRevisionCount
115     * @return bool|void
116     */
117    public function onPageDeleteComplete(
118        $page, $deleter, $reason, $pageID, $deletedRev, $logEntry, $archivedRevisionCount
119    ) {
120        if ( !$this->config->get( 'WikiLambdaEnableClientMode' ) ) {
121            // Nothing for us to do.
122            return;
123        }
124
125        if ( defined( 'MW_UPDATER' ) || defined( 'MEDIAWIKI_INSTALL' ) ) {
126            // Skip during install/upgrade: the cross-wiki usage table may not exist yet, and
127            // the bootstrap does not delete pages. See onPageSaveComplete() for the full note.
128            return;
129        }
130
131        // A deleted page no longer uses any Function, so drop its rows from the shared
132        // cross-wiki usage table. Unlike an edit, deletion fires no re-render to reconcile
133        // the rows, so without this they would leak permanently (page_ids are not reused).
134        $wikifunctionsUsageStore = WikiLambdaServices::getWikifunctionsUsageStore();
135        $this->logger->debug( __METHOD__ . ': Clearing usage tracking for deleted page {pageId}', [
136            'pageId' => $pageID,
137        ] );
138        $wikifunctionsUsageStore->deleteUsageForPage( WikiMap::getCurrentWikiId(), $pageID );
139    }
140
141    /**
142     * @see https://www.mediawiki.org/wiki/Manual:Hooks/PageMoveComplete
143     *
144     * @param LinkTarget $old
145     * @param LinkTarget $new
146     * @param UserIdentity $userIdentity
147     * @param int $pageid
148     * @param int $redirid
149     * @param string $reason
150     * @param RevisionRecord $revision
151     * @return bool|void
152     */
153    public function onPageMoveComplete(
154        $old, $new, $userIdentity, $pageid, $redirid, $reason, $revision
155    ) {
156        if ( !$this->config->get( 'WikiLambdaEnableClientMode' ) ) {
157            // Nothing for us to do.
158            return;
159        }
160
161        if ( defined( 'MW_UPDATER' ) || defined( 'MEDIAWIKI_INSTALL' ) ) {
162            // Skip during install/upgrade: the cross-wiki usage table may not exist yet, and
163            // the bootstrap does not move pages. See onPageSaveComplete() for the full note.
164            return;
165        }
166
167        // A move keeps the page_id but may change the namespace and/or the title.
168        $oldTitle = Title::newFromLinkTarget( $old );
169        $newTitle = Title::newFromLinkTarget( $new );
170        $wiki = WikiMap::getCurrentWikiId();
171        $wikifunctionsUsageStore = WikiLambdaServices::getWikifunctionsUsageStore();
172        $this->logger->debug( __METHOD__ . ': Updating usage tracking for moved page {pageId}', [
173            'pageId' => $pageid,
174        ] );
175
176        if ( $oldTitle->getNamespace() === $newTitle->getNamespace() ) {
177            // In-namespace rename: the row's identity (wfu_wiki_id, encoding the namespace) is
178            // unchanged, so only the denormalised title is stale. Refresh it in place so the
179            // repo shows the new name immediately, rather than only after the moved page is
180            // next re-rendered.
181            $wikifunctionsUsageStore->updatePageTitle( $wiki, $pageid, $newTitle->getDBkey() );
182        } else {
183            // A namespace change moves the row to a different wfu_wiki_id, which is part of its
184            // identity, so it can't be updated in place; and we don't know the Functions the
185            // page uses here to re-insert under the new id. Clear the stale rows — the page's
186            // next re-render re-records them with the correct namespace via the usage job.
187            $wikifunctionsUsageStore->deleteUsageForPage( $wiki, $pageid );
188        }
189    }
190
191    /**
192     * @see https://www.mediawiki.org/wiki/Manual:Hooks/MakeGlobalVariablesScript
193     *
194     * @param array &$vars
195     * @param OutputPage $out
196     */
197    public function onMakeGlobalVariablesScript( &$vars, $out ): void {
198        // 1. Add configuration flags
199        $vars['wgWikiLambdaEnableAbstractMode'] = $this->config->get( 'WikiLambdaEnableAbstractMode' );
200        $vars['wgWikiLambdaEnableRepoMode'] = $this->config->get( 'WikiLambdaEnableRepoMode' );
201
202        // 2. Add wgWikifunctionsBaseUrl when the setup is non-repo
203        if ( !$this->mode->isRepo() ) {
204            $vars['wgWikifunctionsBaseUrl'] = $this->getClientTargetUrl();
205        }
206
207        // 3. Add primary namespace for Abstract content
208        if ( $this->mode->isAbstract() ) {
209            $namespaces = $this->config->get( 'WikiLambdaAbstractNamespaces' );
210            $vars['wgWikiLambdaAbstractPrimaryNamespace'] = array_values( $namespaces )[0][0];
211        }
212
213        // 4. In client mode, expose the recommended-Wikifunctions list for the VE dialog.
214        // Sourced from CommunityConfiguration (T394410).
215        if ( $this->mode->isClient() ) {
216            $vars['wgWikiLambdaSuggestedFunctions'] = $this->loadProviderList(
217                'WikifunctionsSuggestions'
218            );
219        }
220
221        // 5. In abstract mode, expose the suggested HTML-returning Wikifunctions shown
222        // in the Abstract Article "Add fragment" menu.
223        if ( $this->mode->isAbstract() ) {
224            $vars['wgWikiLambdaAbstractSuggestions'] = $this->loadProviderList(
225                'AbstractWikiSuggestedWikifunctions'
226            );
227        }
228    }
229
230    /**
231     * Resolve a CommunityConfiguration-managed list of ZIDs for injection into
232     * wgWikiLambda* config. Returns an empty list if CommunityConfiguration is
233     * not loaded or the lookup fails.
234     *
235     * @param string $providerId CC provider ID (e.g. "WikifunctionsSuggestions")
236     * @return string[]
237     */
238    private function loadProviderList( string $providerId ): array {
239        if ( !$this->providerFactory ) {
240            return [];
241        }
242        try {
243            $provider = $this->providerFactory->newProvider( $providerId );
244            $status = $provider->loadValidConfiguration();
245            if ( $status->isOK() ) {
246                $value = $status->getValue();
247                return array_values( (array)( $value->SuggestedFunctions ?? [] ) );
248            }
249        } catch ( Throwable $e ) {
250            $this->logger->warning(
251                __METHOD__ . ': CommunityConfiguration lookup for {id} failed: {msg}',
252                [ 'id' => $providerId, 'msg' => $e->getMessage() ]
253            );
254        }
255        return [];
256    }
257
258    /**
259     * @see https://www.mediawiki.org/wiki/Manual:Hooks/ResourceLoaderRegisterModules
260     *
261     * @param ResourceLoader $resourceLoader
262     * @return void
263     */
264    public function onResourceLoaderRegisterModules( ResourceLoader $resourceLoader ): void {
265        // TODO (T386013): Once client mode is always enabled, register this statically in extension.json
266        // via the ResourceModules definition.
267
268        if (
269            $this->mode->isClient()
270            && ExtensionRegistry::getInstance()->isLoaded( 'VisualEditor' )
271        ) {
272            $directoryName = __DIR__ . '/../../resources/ext.wikilambda.visualeditor';
273
274            // First, register our custom icons so we can depend on them
275            $resourceLoader->register( 'ext.wikilambda.visualeditor.icons', [
276                'class' => ImageModule::class,
277                // We're writing to the global OOUI icon namespace for now.
278                'selector' => '.oo-ui-icon-{name}',
279                'images' => [
280                    'functionObject' => [ "file" => "icons/functionObject.svg" ]
281                ],
282                'localBasePath' => $directoryName,
283                'remoteExtPath' => 'WikiLambda/resources'
284            ] );
285
286            // Now register our actual bundle
287            $files = [
288                've.init.mw.WikifunctionsCall.js',
289                've.dm.WikifunctionsCallNode.js',
290                've.ce.WikifunctionsCallNode.js',
291                've.ui.WikifunctionsCallContextItem.js',
292                've.ui.WikifunctionsCallDialogTool.js',
293                've.ui.WikifunctionsCallDialog.js',
294            ];
295
296            $files[] = [
297                'name' => 'init.js',
298                'main' => true,
299                'content' => array_reduce( $files, static function ( $carry, $file ) {
300                    return "$carry\nrequire('./$file');\n";
301                }, '' ),
302            ];
303
304            $visualEditorWfConfig = [
305                'dependencies' => [
306                    'ext.visualEditor.mwcore',
307                    'ext.visualEditor.mwtransclusion',
308                    'ext.wikilambda.visualeditor.icons',
309                ],
310                'localBasePath' => $directoryName,
311                'remoteExtPath' => 'WikiLambda/resources',
312                'packageFiles' => $files,
313                'messages' => [
314                    'wikilambda-visualeditor-wikifunctionscall-ce-loading',
315                    'wikilambda-visualeditor-wikifunctionscall-ce-abort',
316                    'wikilambda-visualeditor-wikifunctionscall-error',
317                    'wikilambda-visualeditor-wikifunctionscall-title',
318                    'wikilambda-visualeditor-wikifunctionscall-popup-loading',
319                    'wikilambda-visualeditor-wikifunctionscall-dialog-search-no-results',
320                    'wikilambda-visualeditor-wikifunctionscall-dialog-search-placeholder',
321                    'wikilambda-visualeditor-wikifunctionscall-dialog-search-results-title',
322                    'wikilambda-visualeditor-wikifunctionscall-dialog-suggested-functions-title',
323                    'wikilambda-visualeditor-wikifunctionscall-dialog-string-input-placeholder',
324                    'wikilambda-visualeditor-wikifunctionscall-dialog-enum-selector-placeholder',
325                    'wikilambda-visualeditor-wikifunctionscall-dialog-function-link-footer',
326                    'wikilambda-visualeditor-wikifunctionscall-dialog-cta-suggest-title',
327                    'wikilambda-visualeditor-wikifunctionscall-dialog-cta-suggest-description',
328                    'wikilambda-visualeditor-wikifunctionscall-dialog-cta-create-title',
329                    'wikilambda-visualeditor-wikifunctionscall-dialog-cta-create-description',
330                    'wikilambda-visualeditor-wikifunctionscall-dialog-cta-explore-title',
331                    'wikilambda-visualeditor-wikifunctionscall-dialog-cta-explore-description',
332                    'wikilambda-visualeditor-wikifunctionscall-error-bad-function',
333                    'wikilambda-visualeditor-wikifunctionscall-error-enum',
334                    'wikilambda-visualeditor-wikifunctionscall-error-language',
335                    'wikilambda-visualeditor-wikifunctionscall-error-parser',
336                    'wikilambda-visualeditor-wikifunctionscall-error-parser-empty',
337                    'wikilambda-visualeditor-wikifunctionscall-error-wikidata-lexeme',
338                    'wikilambda-visualeditor-wikifunctionscall-error-wikidata-property',
339                    'wikilambda-visualeditor-wikifunctionscall-error-wikidata-item',
340                    'wikilambda-visualeditor-wikifunctionscall-error-wikidata-lexeme-form',
341                    'wikilambda-visualeditor-wikifunctionscall-dialog-read-more-description',
342                    'wikilambda-visualeditor-wikifunctionscall-dialog-read-less-description',
343                    'wikilambda-visualeditor-wikifunctionscall-info-missing-content',
344                    'brackets',
345                    'wikilambda-visualeditor-wikifunctionscall-back',
346                    'wikilambda-visualeditor-wikifunctionscall-changedesc-title',
347                    'wikilambda-visualeditor-wikifunctionscall-no-name',
348                    'wikilambda-visualeditor-wikifunctionscall-no-description',
349                    'wikilambda-visualeditor-wikifunctionscall-no-input-label',
350                    'wikilambda-visualeditor-wikifunctionscall-preview-title',
351                    'wikilambda-visualeditor-wikifunctionscall-preview-no-result',
352                    'wikilambda-visualeditor-wikifunctionscall-preview-retry-button-label',
353                    'wikilambda-visualeditor-wikifunctionscall-preview-cancel-button-label',
354                    'wikilambda-visualeditor-wikifunctionscall-preview-cancelled',
355                    'wikilambda-visualeditor-wikifunctionscall-preview-error',
356                    'wikilambda-visualeditor-wikifunctionscall-preview-html-fragment-toggle',
357                    'wikilambda-visualeditor-wikifunctionscall-default-value-date',
358                    'wikilambda-visualeditor-wikifunctionscall-default-value-wikidata-item',
359                    'wikilambda-visualeditor-wikifunctionscall-default-value-language',
360                    'wikilambda-functioncall-error-message',
361                    "wikilambda-functioncall-error-message-unknown",
362                    "wikilambda-functioncall-error-message-not-supported",
363                    "wikilambda-functioncall-error-message-bad-inputs",
364                    "wikilambda-functioncall-error-message-bad-input-type",
365                    "wikilambda-functioncall-error-message-bad-langs",
366                    "wikilambda-functioncall-error-message-disabled",
367                    "wikilambda-functioncall-error-message-system",
368                    'wikilambda-functioncall-error',
369                    'wikilambda-functioncall-error-evaluation',
370                    "wikilambda-functioncall-error-unclear",
371                    "wikilambda-functioncall-error-unknown-zid",
372                    "wikilambda-functioncall-error-invalid-zobject",
373                    "wikilambda-functioncall-error-nonfunction",
374                    "wikilambda-functioncall-error-nonstringinput",
375                    "wikilambda-functioncall-error-nonstringoutput",
376                    "wikilambda-functioncall-error-bad-langs",
377                    "wikilambda-functioncall-error-bad-inputs",
378                    "wikilambda-functioncall-error-bad-input-type",
379                    "wikilambda-functioncall-error-bad-output",
380                ],
381                'styles' => [
382                    'ext.wikilambda.visualeditor.less',
383                ]
384            ];
385
386            $resourceLoader->register( 'ext.wikilambda.visualeditor', $visualEditorWfConfig );
387
388            // Finally, register the Codex module for the inline errors
389            $resourceLoader->register( 'ext.wikilambda.inlineerrors', [
390                'class' => CodexModule::class,
391                'codexStyleOnly' => true,
392                'codexComponents' => [
393                    'CdxInfoChip',
394                ],
395            ] );
396        }
397    }
398
399    /**
400     * Return the Url of the Wikilambda server instance,
401     * and if not available in the configuration variables,
402     * returns an empty string and logs an error.
403     *
404     * @return string
405     */
406    private function getClientTargetUrl(): string {
407        $targetUrl = $this->config->get( 'WikiLambdaClientTargetAPI' );
408        if ( !$targetUrl ) {
409            $this->logger->error( __METHOD__ . ': missing configuration variable WikiLambdaClientTargetAPI' );
410        }
411        return $targetUrl ?? '';
412    }
413}