1<?php
2/**
3 * @file
4 * @author Niklas Laxström
5 * @license GPL-2.0-or-later
6 */
7
8namespace LocalisationUpdate;
9
10use LocalisationUpdate\Fetcher\FetcherFactory;
11use LocalisationUpdate\Reader\ReaderFactory;
12
13/**
14 * Executes the localisation update.
15 */
16class Updater {
17
18	/**
19	 * @var Update
20	 */
21	private $logger;
22
23	/**
24	 * Whether the path is a pattern and thus we need to use appropriate
25	 * code for fetching directories.
26	 *
27	 * @param string $path Url
28	 * @return bool
29	 */
30	public function isDirectory( $path ) {
31		$filename = basename( $path );
32		return strpos( $filename, '*' ) !== false;
33	}
34
35	/**
36	 * Expands repository relative path to full url with the given repository
37	 * patterns. Extra variables in $info are used as variables and will be
38	 * replaced the pattern.
39	 *
40	 * @param array $info Component information.
41	 * @param array $repos Repository information.
42	 * @return string
43	 */
44	public function expandRemotePath( $info, $repos ) {
45		$pattern = $repos[$info['repo']];
46		unset( $info['repo'], $info['orig'] );
47
48		// This assumes all other keys are used as variables
49		// in the pattern. For example name -> %NAME%.
50		$keys = [];
51		foreach ( array_keys( $info ) as $key ) {
52			$keys[] = '%' . strtoupper( $key ) . '%';
53		}
54
55		$values = array_values( $info );
56		return str_replace( $keys, $values, $pattern );
57	}
58
59	/**
60	 * Parses translations from given list of files.
61	 *
62	 * @param ReaderFactory $readerFactory Factory to construct parsers.
63	 * @param array $files List of files with their contents as array values.
64	 * @return array List of translations indexed by language code.
65	 */
66	public function readMessages( ReaderFactory $readerFactory, array $files ) {
67		$messages = [];
68
69		foreach ( $files as $filename => $contents ) {
70			$reader = $readerFactory->getReader( $filename );
71			try {
72				$parsed = $reader->parse( $contents );
73			} catch ( \Exception $e ) {
74				trigger_error( __METHOD__ . ": Unable to parse messages from $filename", E_USER_WARNING );
75				continue;
76			}
77
78			foreach ( $parsed as $code => $langMessages ) {
79				if ( !isset( $messages[$code] ) ) {
80					$messages[$code] = [];
81				}
82				$messages[$code] = array_merge( $messages[$code], $langMessages );
83			}
84
85			$c = array_sum( array_map( 'count', $parsed ) );
86			// Useful for debugging, maybe create interface to pass this to the script?
87			# echo "$filename with " . get_class( $reader ) . " and $c\n";
88		}
89
90		return $messages;
91	}
92
93	/**
94	 * Find new and changed translations in $remote and returns them.
95	 *
96	 * @param array $origin
97	 * @param array $remote
98	 * @param array $ignore Array of message keys to ignore, keys as as array keys.
99	 * @return array
100	 */
101	public function findChangedTranslations( $origin, $remote, $ignore = [] ) {
102		$changed = [];
103		foreach ( $remote as $key => $value ) {
104			if ( isset( $ignore[$key] ) ) {
105				continue;
106			}
107
108			if ( !isset( $origin[$key] ) || $value !== $origin[$key] ) {
109				$changed[$key] = $value;
110			}
111		}
112		return $changed;
113	}
114
115	/**
116	 * Fetches files from given Url pattern.
117	 *
118	 * @param FetcherFactory $factory Factory to construct fetchers.
119	 * @param string $path Url to the file or pattern of files.
120	 * @return array List of Urls with file contents as path.
121	 */
122	public function fetchFiles( FetcherFactory $factory, $path ) {
123		$fetcher = $factory->getFetcher( $path );
124
125		if ( $this->isDirectory( $path ) ) {
126			$files = $fetcher->fetchDirectory( $path );
127		} else {
128			$files = [ $path => $fetcher->fetchFile( $path ) ];
129		}
130
131		// Remove files which were not found
132		return array_filter( $files );
133	}
134
135	/**
136	 * @param Finder $finder
137	 * @param ReaderFactory $readerFactory
138	 * @param FetcherFactory $fetcherFactory
139	 * @param array $repos
140	 * @param Update $logger
141	 * @return array
142	 */
143	public function execute(
144		Finder $finder,
145		ReaderFactory $readerFactory,
146		FetcherFactory $fetcherFactory,
147		array $repos,
148		$logger
149	) {
150		$components = $finder->getComponents();
151
152		$updatedMessages = [];
153
154		foreach ( $components as $key => $info ) {
155			$logger->logInfo( "Updating component $key" );
156
157			$originFiles = $this->fetchFiles( $fetcherFactory, $info['orig'] );
158			$remotePath = $this->expandRemotePath( $info, $repos );
159			try {
160				$remoteFiles = $this->fetchFiles( $fetcherFactory, $remotePath );
161			} catch ( \Exception $e ) {
162				$logger->logError( __METHOD__ . ": Unable to fetch messages from $remotePath" );
163				continue;
164			}
165
166			if ( $remoteFiles === [] ) {
167				// Small optimization: if nothing to compare with, skip
168				continue;
169			}
170
171			$originMessages = $this->readMessages( $readerFactory, $originFiles );
172			$remoteMessages = $this->readMessages( $readerFactory, $remoteFiles );
173
174			if ( !isset( $remoteMessages['en'] ) ) {
175				// Could not find remote messages
176				continue;
177			}
178
179			// If remote translation in English is not present or differs, we do not want
180			// translations for other languages for those messages, as they are either not
181			// used in this version of code or can be incompatible.
182			$forbiddenKeys = $this->findChangedTranslations(
183				$originMessages['en'],
184				$remoteMessages['en']
185			);
186
187			// We never accept updates for English strings
188			unset( $originMessages['en'], $remoteMessages['en'] );
189
190			// message: string in all languages; translation: string in one language.
191			foreach ( $remoteMessages as $language => $remoteTranslations ) {
192				// Check for completely new languages
193				$originTranslations = [];
194				if ( isset( $originMessages[$language] ) ) {
195					$originTranslations = $originMessages[$language];
196				}
197
198				$updatedTranslations = $this->findChangedTranslations(
199					$originTranslations,
200					$remoteTranslations,
201					$forbiddenKeys
202				);
203
204				// Avoid empty arrays
205				if ( $updatedTranslations === [] ) {
206					continue;
207				}
208
209				if ( !isset( $updatedMessages[$language] ) ) {
210					$updatedMessages[$language] = [];
211				}
212
213				// In case of conflicts, which should not exist, this prefers the
214				// first translation seen.
215				$updatedMessages[$language] += $updatedTranslations;
216			}
217		}
218
219		return $updatedMessages;
220	}
221}
222