Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 104
0.00% covered (danger)
0.00%
0 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
LanguageConverter
0.00% covered (danger)
0.00%
0 / 104
0.00% covered (danger)
0.00%
0 / 12
1406
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 loadDefaultTables
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getMachine
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setMachine
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 classFromCode
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 loadLanguage
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 findVariantLink
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 translate
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 guessVariant
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 maybeConvert
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
72
 baseToVariant
0.00% covered (danger)
0.00%
0 / 51
0.00% covered (danger)
0.00%
0 / 1
182
 implementsLanguageConversionBcp47
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2declare( strict_types = 1 );
3
4/**
5 * A bidirectional Language Converter, capable of round-tripping variant
6 * conversion.
7 *
8 * Language conversion is as DOMPostProcessor pass, run over the
9 * Parsoid-format HTML output, which may have embedded language converter
10 * rules.  We first assign a (guessed) wikitext variant to each DOM node,
11 * the variant we expect the original wikitext was written in,
12 * which will be used when round-tripping the result back to the original
13 * wikitext variant.  Then for each applicable text node in the DOM, we
14 * first "bracket" the text, splitting it into cleanly round-trippable
15 * segments and lossy/unclean segments.  For the lossy segments we add
16 * additional metadata to the output to record the original text used in
17 * the wikitext to allow round-tripping (and variant-aware editing).
18 *
19 * Note that different wikis have different policies for wikitext variant:
20 * in some wikis all articles are authored in one particular variant, by
21 * convention.  In others, it's a "first author gets to choose the variant"
22 * situation.  In both cases, a constant/per-article "wikitext variant" may
23 * be specified via some as-of-yet-unimplemented mechanism; either part of
24 * the site configuration, or per-article metadata like pageLanguage.
25 * In other wikis (like zhwiki) the text is a random mix of variants; in
26 * these cases the "wikitext variant" will be null/unspecified, and we'll
27 * dynamically pick the most likely wikitext variant for each subtree.
28 *
29 * Each individual language has a dynamically-loaded subclass of `Language`,
30 * which may also have a `LanguageConverter` subclass to load appropriate
31 * `ReplacementMachine`s and do other language-specific customizations.
32 */
33
34namespace Wikimedia\Parsoid\Language;
35
36use Wikimedia\Bcp47Code\Bcp47Code;
37use Wikimedia\LangConv\ReplacementMachine;
38use Wikimedia\Parsoid\Config\Env;
39use Wikimedia\Parsoid\Core\ClientError;
40use Wikimedia\Parsoid\DOM\Document;
41use Wikimedia\Parsoid\DOM\Node;
42use Wikimedia\Parsoid\NodeData\TempData;
43use Wikimedia\Parsoid\Utils\DOMCompat;
44use Wikimedia\Parsoid\Utils\DOMDataUtils;
45use Wikimedia\Parsoid\Utils\DOMUtils;
46use Wikimedia\Parsoid\Utils\Timing;
47use Wikimedia\Parsoid\Utils\Utils;
48
49/**
50 * Base class for language variant conversion.
51 */
52class LanguageConverter {
53
54    /** @var Language */
55    private $language;
56
57    /** @var string */
58    private $langCode;
59
60    /** @var string[] */
61    private $variants;
62
63    /** @var ?array */
64    private $variantFallbacks;
65
66    /** @var ?ReplacementMachine */
67    private $machine;
68
69    /**
70     * @param Language $language
71     * @param string $langCode The main language code of this language
72     * @param string[] $variants The supported variants of this language
73     * @param ?array $variantfallbacks The fallback language of each variant
74     * @param ?array $flags Defining the custom strings that maps to the flags
75     * @param ?array $manualLevel Limit for supported variants
76     */
77    public function __construct(
78        Language $language, string $langCode, array $variants,
79        ?array $variantfallbacks = null, ?array $flags = null,
80        ?array $manualLevel = null
81    ) {
82        $this->language = $language;
83        $this->langCode = $langCode;
84        $this->variants = $variants; // XXX subtract disabled variants
85        $this->variantFallbacks = $variantfallbacks;
86        // this.mVariantNames = Language.// XXX
87
88        // Eagerly load conversion tables.
89        // XXX we could defer loading in the future, or cache more
90        // aggressively
91        $this->loadDefaultTables();
92    }
93
94    public function loadDefaultTables() {
95    }
96
97    /**
98     * Return the {@link ReplacementMachine} powering this conversion.
99     * @return ?ReplacementMachine
100     */
101    public function getMachine(): ?ReplacementMachine {
102        return $this->machine;
103    }
104
105    public function setMachine( ReplacementMachine $machine ): void {
106        $this->machine = $machine;
107    }
108
109    /**
110     * Try to return a classname from a given code.
111     * @param string $code
112     * @param bool $fallback Whether we're going through language fallback
113     * @return class-string Name of the language class (if one were to exist)
114     */
115    public static function classFromCode( string $code, bool $fallback ): string {
116        if ( $fallback && $code === 'en' ) {
117            return '\Wikimedia\Parsoid\Language\Language';
118        } else {
119            $code = ucfirst( $code );
120            $code = str_replace( '-', '_', $code );
121            $code = preg_replace( '#/|^\.+#', '', $code ); // avoid path attacks
122            return "\Wikimedia\Parsoid\Language\Language{$code}";
123        }
124    }
125
126    /**
127     * @param Env $env
128     * @param Bcp47Code $lang a language code
129     * @param bool $fallback
130     * @return Language
131     */
132    public static function loadLanguage( Env $env, Bcp47Code $lang, bool $fallback = false ): Language {
133        // Our internal language classes still use MW-internal names.
134        $lang = Utils::bcp47ToMwCode( $lang );
135        try {
136            if ( Language::isValidInternalCode( $lang ) ) {
137                $languageClass = self::classFromCode( $lang, $fallback );
138                return new $languageClass();
139            }
140        } catch ( \Error $e ) {
141            /* fall through */
142        }
143        $fallback = (string)$fallback;
144        $env->log( 'info', "Couldn't load language: {$lang} fallback={$fallback}" );
145        return new Language();
146    }
147
148    // phpcs:ignore MediaWiki.Commenting.FunctionComment.MissingDocumentationPublic
149    public function findVariantLink( $link, $nt, $ignoreOtherCond ) {
150        // XXX unimplemented
151        return [ 'nt' => $nt, 'link' => $link ];
152    }
153
154    /**
155     * @param string $fromVariant
156     * @param string $text
157     * @param string $toVariant
158     * @suppress PhanEmptyPublicMethod
159     */
160    public function translate( $fromVariant, $text, $toVariant ) {
161        // XXX unimplemented
162    }
163
164    /**
165     * @param string $text
166     * @param Bcp47Code $variant a language code
167     * @return bool
168     * @deprecated Appears to be unused
169     */
170    public function guessVariant( $text, $variant ) {
171        return false;
172    }
173
174    /**
175     * Convert the given document into $htmlVariantLanguage, if:
176     *  1) language converter is enabled on this wiki, and
177     *  2) the htmlVariantLanguage is specified, and it is a known variant (not a
178     *     base language code)
179     *
180     * The `$wtVariantLanguage`, if provided is expected to be per-wiki or
181     * per-article metadata which specifies a standard "authoring variant"
182     * for this article or wiki.  For example, all articles are authored in
183     * Cyrillic by convention.  It should be left blank if there is no
184     * consistent convention on the wiki (as for zhwiki, for instance).
185     *
186     * @param Env $env
187     * @param Document $doc The input document.
188     * @param string|Bcp47Code|null $htmlVariantLanguage The desired output variant.
189     *   MediaWiki-internal code string (deprecated), or a BCP 47 language object, or null.
190     * @param string|Bcp47Code|null $wtVariantLanguage The variant used by convention when
191     *   authoring pages, if there is one; otherwise left null.
192     *   MediaWiki-internal code string (deprecated), or a BCP 47 language object, or null.
193     */
194    public static function maybeConvert(
195        Env $env, Document $doc, $htmlVariantLanguage, $wtVariantLanguage
196    ): void {
197        // language converter must be enabled for the pagelanguage
198        if ( !$env->langConverterEnabled() ) {
199            return;
200        }
201        // htmlVariantLanguage must be specified, and a language-with-variants
202        if ( $htmlVariantLanguage === null ) {
203            return;
204        }
205        // Back-compat w/ old string-passing parameter convention
206        if ( is_string( $htmlVariantLanguage ) ) {
207            $htmlVariantLanguage = Utils::mwCodeToBcp47(
208                $htmlVariantLanguage, true, $env->getSiteConfig()->getLogger()
209            );
210        }
211        if ( is_string( $wtVariantLanguage ) ) {
212            $wtVariantLanguage = Utils::mwCodeToBcp47(
213                $wtVariantLanguage, true, $env->getSiteConfig()->getLogger()
214            );
215        }
216        $variants = $env->getSiteConfig()->variantsFor( $htmlVariantLanguage );
217        if ( $variants === null ) {
218            return;
219        }
220
221        // htmlVariantLanguage must not be a base language code
222        if ( Utils::isBcp47CodeEqual( $htmlVariantLanguage, $variants['base'] ) ) {
223            // XXX in the future we probably want to go ahead and expand
224            // empty <span>s left by -{...}- constructs, etc.
225            return;
226        }
227
228        // Record the fact that we've done conversion to htmlVariantLanguage
229        $env->getPageConfig()->setVariantBcp47( $htmlVariantLanguage );
230
231        // But don't actually do the conversion if __NOCONTENTCONVERT__
232        if ( DOMCompat::querySelector( $doc, 'meta[property="mw:PageProp/nocontentconvert"]' ) ) {
233            return;
234        }
235
236        // OK, convert!
237        self::baseToVariant( $env, DOMCompat::getBody( $doc ), $htmlVariantLanguage, $wtVariantLanguage );
238    }
239
240    /**
241     * Convert a text in the "base variant" to a specific variant, given by `htmlVariantLanguage`.  If
242     * `wtVariantLanguage` is given, assume that the input wikitext is in `wtVariantLanguage` to
243     * construct round-trip metadata, instead of using a heuristic to guess the best variant
244     * for each DOM subtree of wikitext.
245     * @param Env $env
246     * @param Node $rootNode The root node of a fragment to convert.
247     * @param string|Bcp47Code $htmlVariantLanguage The variant to be used for the output DOM.
248     *  This is a mediawiki-internal language code string (T320662, deprecated),
249     *  or a BCP 47 language object (preferred).
250     * @param string|Bcp47Code|null $wtVariantLanguage An optional variant assumed for the
251     *  input DOM in order to create roundtrip metadata.
252     *  This is a mediawiki-internal language code (T320662, deprecated),
253     *  or a BCP 47 language object (preferred), or null.
254     */
255    public static function baseToVariant(
256        Env $env, Node $rootNode, $htmlVariantLanguage, $wtVariantLanguage
257    ): void {
258        // Back-compat w/ old string-passing parameter convention
259        if ( is_string( $htmlVariantLanguage ) ) {
260            $htmlVariantLanguage = Utils::mwCodeToBcp47(
261                $htmlVariantLanguage, true, $env->getSiteConfig()->getLogger()
262            );
263        }
264        if ( is_string( $wtVariantLanguage ) ) {
265            $wtVariantLanguage = Utils::mwCodeToBcp47(
266                $wtVariantLanguage, true, $env->getSiteConfig()->getLogger()
267            );
268        }
269        // PageConfig guarantees getPageLanguage() never returns null.
270        $pageLangCode = $env->getPageConfig()->getPageLanguageBcp47();
271        $guesser = null;
272
273        $metrics = $env->getSiteConfig()->metrics();
274        $loadTiming = Timing::start( $metrics );
275        $languageClass = self::loadLanguage( $env, $pageLangCode );
276        $lang = new $languageClass();
277        $langconv = $lang->getConverter();
278        $htmlVariantLanguageMw = Utils::bcp47ToMwCode( $htmlVariantLanguage );
279        // XXX we might want to lazily-load conversion tables here.
280        $loadTiming->end( "langconv.{$htmlVariantLanguageMw}.init" );
281        $loadTiming->end( 'langconv.init' );
282
283        // Check the html variant is valid (and implemented!)
284        $validTarget = $langconv !== null && $langconv->getMachine() !== null
285            && array_key_exists( $htmlVariantLanguageMw, $langconv->getMachine()->getCodes() );
286        if ( !$validTarget ) {
287            // XXX create a warning header? (T197949)
288            $env->log( 'info', "Unimplemented variant: {$htmlVariantLanguageMw}" );
289            return; /* no conversion */
290        }
291        // Check that the wikitext variant is valid.
292        $wtVariantLanguageMw = $wtVariantLanguage ?
293            Utils::bcp47ToMwCode( $wtVariantLanguage ) : null;
294        $validSource = $wtVariantLanguage === null ||
295            array_key_exists( $wtVariantLanguageMw, $langconv->getMachine()->getCodes() );
296        if ( !$validSource ) {
297            throw new ClientError( "Invalid wikitext variant: $wtVariantLanguageMw for target $htmlVariantLanguageMw" );
298        }
299
300        $timing = Timing::start( $metrics );
301        if ( $metrics ) {
302            $metrics->increment( 'langconv.count' );
303            $metrics->increment( "langconv." . $htmlVariantLanguageMw . ".count" );
304        }
305
306        // XXX Eventually we'll want to consult some wiki configuration to
307        // decide whether a ConstantLanguageGuesser is more appropriate.
308        if ( $wtVariantLanguage ) {
309            $guesser = new ConstantLanguageGuesser( $wtVariantLanguage );
310        } else {
311            $guesser = new MachineLanguageGuesser(
312                // @phan-suppress-next-line PhanTypeMismatchArgumentSuperType
313                $langconv->getMachine(), $rootNode, $htmlVariantLanguage
314            );
315        }
316
317        $ct = new ConversionTraverser( $env, $htmlVariantLanguage, $guesser, $langconv->getMachine() );
318        $ct->traverse( null, $rootNode );
319
320        // HACK: to avoid data-parsoid="{}" in the output, set the isNew flag
321        // on synthetic spans
322        DOMUtils::assertElt( $rootNode );
323        foreach ( DOMCompat::querySelectorAll(
324            $rootNode, 'span[typeof="mw:LanguageVariant"][data-mw-variant]'
325        ) as $span ) {
326            $dmwv = DOMDataUtils::getJSONAttribute( $span, 'data-mw-variant', null );
327            if ( $dmwv->rt ?? false ) {
328                $dp = DOMDataUtils::getDataParsoid( $span );
329                $dp->setTempFlag( TempData::IS_NEW );
330            }
331        }
332
333        $timing->end( 'langconv.total' );
334        $timing->end( "langconv.{$htmlVariantLanguageMw}.total" );
335        $loadTiming->end( 'langconv.totalWithInit' );
336    }
337
338    /**
339     * Check if support for html variant conversion is implemented
340     * @internal FIXME: Remove once Parsoid's language variant work is completed
341     * @param Env $env
342     * @param Bcp47Code $htmlVariantLanguage The variant to be checked for implementation
343     * @return bool
344     */
345    public static function implementsLanguageConversionBcp47( Env $env, Bcp47Code $htmlVariantLanguage ): bool {
346        $htmlVariantLanguageMw = Utils::bcp47ToMwCode( $htmlVariantLanguage );
347        $pageLangCode = $env->getPageConfig()->getPageLanguageBcp47();
348        $lang = self::loadLanguage( $env, $pageLangCode );
349        $langconv = $lang->getConverter();
350
351        $validTarget = $langconv !== null && $langconv->getMachine() !== null
352            && array_key_exists( $htmlVariantLanguageMw, $langconv->getMachine()->getCodes() );
353
354        return $validTarget;
355    }
356}