Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.56% covered (warning)
79.56%
436 / 548
52.50% covered (warning)
52.50%
21 / 40
CRAP
0.00% covered (danger)
0.00%
0 / 1
Upgrade
79.56% covered (warning)
79.56%
436 / 548
52.50% covered (warning)
52.50%
21 / 40
586.49
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 run
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
2
 getNewConfigs
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getWarnings
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addWarning
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 iniMerge
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 loadOldBaseConfig
45.45% covered (danger)
45.45%
5 / 11
0.00% covered (danger)
0.00%
0 / 1
9.06
 getOldConfigPath
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 loadConfigs
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
6
 applyOldSettings
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 saveModifiedConfig
10.00% covered (danger)
10.00%
2 / 20
0.00% covered (danger)
0.00%
0 / 1
68.05
 saveUnmodifiedConfig
14.29% covered (danger)
14.29%
2 / 14
0.00% covered (danger)
0.00%
0 / 1
48.30
 checkTheme
75.00% covered (warning)
75.00%
15 / 20
0.00% covered (danger)
0.00%
0 / 1
5.39
 isDefaultBulkExportOptions
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
6.17
 checkAmazonConfig
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 upgradeConfig
93.58% covered (success)
93.58%
102 / 109
0.00% covered (danger)
0.00%
0 / 1
32.27
 upgradeAdminPermissions
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
5.01
 changeArrayKey
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 renameFacet
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
4
 upgradeFacetsAndCollection
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 upgradeAutocompleteName
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 upgradeSearches
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
8
 upgradeSpellingSettings
72.22% covered (warning)
72.22%
13 / 18
0.00% covered (danger)
0.00%
0 / 1
12.14
 upgradeFulltext
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 upgradeSitemap
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 upgradeSms
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 upgradeAuthority
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 upgradeReserves
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
3.58
 upgradeSummon
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
5.01
 upgradeSummonPermissions
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
6.01
 upgradePrimo
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 upgradePrimoPermissions
90.00% covered (success)
90.00%
27 / 30
0.00% covered (danger)
0.00%
0 / 1
10.10
 upgradePrimoServerSettings
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
3.01
 upgradeWorldCat
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
7
 fileContainsMeaningfulLines
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 upgradeSolrMarc
16.67% covered (danger)
16.67%
2 / 12
0.00% covered (danger)
0.00%
0 / 1
35.36
 upgradeSearchSpecs
20.00% covered (danger)
20.00%
2 / 10
0.00% covered (danger)
0.00%
0 / 1
17.80
 upgradeILS
33.33% covered (danger)
33.33%
5 / 15
0.00% covered (danger)
0.00%
0 / 1
26.96
 upgradeShardSettings
30.00% covered (danger)
30.00%
6 / 20
0.00% covered (danger)
0.00%
0 / 1
29.95
 extractComments
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
13
1<?php
2
3/**
4 * VF Configuration Upgrade Tool
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Villanova University 2010.
9 *
10 * This program is free software; you can redistribute it and/or modify
11 * it under the terms of the GNU General Public License version 2,
12 * as published by the Free Software Foundation.
13 *
14 * This program is distributed in the hope that it will be useful,
15 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17 * GNU General Public License for more details.
18 *
19 * You should have received a copy of the GNU General Public License
20 * along with this program; if not, write to the Free Software
21 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
22 *
23 * @category VuFind
24 * @package  Config
25 * @author   Demian Katz <demian.katz@villanova.edu>
26 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
27 * @link     https://vufind.org Main Site
28 */
29
30namespace VuFind\Config;
31
32use Composer\Semver\Comparator;
33use VuFind\Config\Writer as ConfigWriter;
34use VuFind\Exception\FileAccess as FileAccessException;
35
36use function count;
37use function in_array;
38use function is_array;
39
40/**
41 * Class to upgrade previous VuFind configurations to the current version
42 *
43 * @category VuFind
44 * @package  Config
45 * @author   Demian Katz <demian.katz@villanova.edu>
46 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
47 * @link     https://vufind.org Main Site
48 */
49class Upgrade
50{
51    /**
52     * Version we're upgrading from
53     *
54     * @var string
55     */
56    protected $from;
57
58    /**
59     * Version we're upgrading to
60     *
61     * @var string
62     */
63    protected $to;
64
65    /**
66     * Directory containing configurations to upgrade
67     *
68     * @var string
69     */
70    protected $oldDir;
71
72    /**
73     * Directory containing unmodified new configurations
74     *
75     * @var string
76     */
77    protected $rawDir;
78
79    /**
80     * Directory where new configurations should be written (null for test mode)
81     *
82     * @var string
83     */
84    protected $newDir;
85
86    /**
87     * Parsed old configurations
88     *
89     * @var array
90     */
91    protected $oldConfigs = [];
92
93    /**
94     * Processed new configurations
95     *
96     * @var array
97     */
98    protected $newConfigs = [];
99
100    /**
101     * Comments parsed from configuration files
102     *
103     * @var array
104     */
105    protected $comments = [];
106
107    /**
108     * Warnings generated during upgrade process
109     *
110     * @var array
111     */
112    protected $warnings = [];
113
114    /**
115     * Are we upgrading files in place rather than creating them?
116     *
117     * @var bool
118     */
119    protected $inPlaceUpgrade;
120
121    /**
122     * Have we modified permissions.ini?
123     *
124     * @var bool
125     */
126    protected $permissionsModified = false;
127
128    /**
129     * Constructor
130     *
131     * @param string $from   Version we're upgrading from.
132     * @param string $to     Version we're upgrading to.
133     * @param string $oldDir Directory containing old configurations.
134     * @param string $rawDir Directory containing raw new configurations.
135     * @param string $newDir Directory to write updated new configurations into
136     * (leave null to disable writes -- used in test mode).
137     */
138    public function __construct($from, $to, $oldDir, $rawDir, $newDir = null)
139    {
140        $this->from = $from;
141        $this->to = $to;
142        $this->oldDir = $oldDir;
143        $this->rawDir = $rawDir;
144        $this->newDir = $newDir;
145        $this->inPlaceUpgrade = ($this->oldDir == $this->newDir);
146    }
147
148    /**
149     * Run through all of the necessary upgrading.
150     *
151     * @return void
152     */
153    public function run()
154    {
155        // Load all old configurations:
156        $this->loadConfigs();
157
158        // Upgrade them one by one and write the results to disk; order is
159        // important since in some cases, settings may migrate out of config.ini
160        // and into other files.
161        $this->upgradeConfig();
162        $this->upgradeAuthority();
163        $this->upgradeFacetsAndCollection();
164        $this->upgradeFulltext();
165        $this->upgradeReserves();
166        $this->upgradeSearches();
167        $this->upgradeSitemap();
168        $this->upgradeSms();
169        $this->upgradeSummon();
170        $this->upgradePrimo();
171        $this->upgradeWorldCat();
172
173        // The previous upgrade routines may have added values to permissions.ini,
174        // so we should save it last. It doesn't have its own upgrade routine.
175        $this->saveModifiedConfig('permissions.ini');
176
177        // The following routines load special configurations that were not
178        // explicitly loaded by loadConfigs... note that some pieces only apply to
179        // the 1.x upgrade!
180        if (Comparator::lessThan($this->from, '2.0')) {
181            $this->upgradeSolrMarc();
182            $this->upgradeSearchSpecs();
183        }
184        $this->upgradeILS();
185    }
186
187    /**
188     * Get processed configurations (used by test routines).
189     *
190     * @return array
191     */
192    public function getNewConfigs()
193    {
194        return $this->newConfigs;
195    }
196
197    /**
198     * Get warning strings generated during upgrade process.
199     *
200     * @return array
201     */
202    public function getWarnings()
203    {
204        return $this->warnings;
205    }
206
207    /**
208     * Add a warning message.
209     *
210     * @param string $msg Warning message.
211     *
212     * @return void
213     */
214    protected function addWarning($msg)
215    {
216        $this->warnings[] = $msg;
217    }
218
219    /**
220     * Support function -- merge the contents of two arrays parsed from ini files.
221     *
222     * @param string $config_ini The base config array.
223     * @param string $custom_ini Overrides to apply on top of the base array.
224     *
225     * @return array             The merged results.
226     */
227    public static function iniMerge($config_ini, $custom_ini)
228    {
229        foreach ($custom_ini as $k => $v) {
230            // Make a recursive call if we need to merge array values into an
231            // existing key... otherwise just drop the value in place.
232            if (is_array($v) && isset($config_ini[$k])) {
233                $config_ini[$k] = self::iniMerge($config_ini[$k], $custom_ini[$k]);
234            } else {
235                $config_ini[$k] = $v;
236            }
237        }
238        return $config_ini;
239    }
240
241    /**
242     * Load the old config.ini settings.
243     *
244     * @return void
245     */
246    protected function loadOldBaseConfig()
247    {
248        // Load the base settings:
249        $oldIni = $this->oldDir . '/config.ini';
250        $mainArray = file_exists($oldIni) ? parse_ini_file($oldIni, true) : [];
251
252        // Merge in local overrides as needed. VuFind 2 structures configurations
253        // differently, so people who used this mechanism will need to refactor
254        // their configurations to take advantage of the new "local directory"
255        // feature. For now, we'll just merge everything to avoid losing settings.
256        if (
257            isset($mainArray['Extra_Config'])
258            && isset($mainArray['Extra_Config']['local_overrides'])
259        ) {
260            $file = trim(
261                $this->oldDir . '/' . $mainArray['Extra_Config']['local_overrides']
262            );
263            $localOverride = @parse_ini_file($file, true);
264            if ($localOverride) {
265                $mainArray = self::iniMerge($mainArray, $localOverride);
266            }
267        }
268
269        // Save the configuration to the appropriate place:
270        $this->oldConfigs['config.ini'] = $mainArray;
271    }
272
273    /**
274     * Find the path to the old configuration file.
275     *
276     * @param string $filename Filename of configuration file.
277     *
278     * @return string
279     */
280    protected function getOldConfigPath($filename)
281    {
282        // Check if the user has overridden the filename in the [Extra_Config]
283        // section:
284        $index = str_replace('.ini', '', $filename);
285        if (isset($this->oldConfigs['config.ini']['Extra_Config'][$index])) {
286            $path = $this->oldDir . '/'
287                . $this->oldConfigs['config.ini']['Extra_Config'][$index];
288            if (file_exists($path) && is_file($path)) {
289                return $path;
290            }
291        }
292        return $this->oldDir . '/' . $filename;
293    }
294
295    /**
296     * Load all of the user's existing configurations.
297     *
298     * @return void
299     */
300    protected function loadConfigs()
301    {
302        // Configuration files to load. Note that config.ini must always be loaded
303        // first so that getOldConfigPath can work properly!
304        $configs = ['config.ini'];
305        foreach (glob($this->rawDir . '/*.ini') as $ini) {
306            $parts = explode('/', str_replace('\\', '/', $ini));
307            $filename = array_pop($parts);
308            if ($filename !== 'config.ini') {
309                $configs[] = $filename;
310            }
311        }
312        foreach ($configs as $config) {
313            // Special case for config.ini, since we may need to overlay extra
314            // settings:
315            if ($config == 'config.ini') {
316                $this->loadOldBaseConfig();
317            } else {
318                $path = $this->getOldConfigPath($config);
319                $this->oldConfigs[$config] = file_exists($path)
320                    ? parse_ini_file($path, true) : [];
321            }
322            $this->newConfigs[$config]
323                = parse_ini_file($this->rawDir . '/' . $config, true);
324            $this->comments[$config]
325                = $this->extractComments($this->rawDir . '/' . $config);
326        }
327    }
328
329    /**
330     * Apply settings from an old configuration to a new configuration.
331     *
332     * @param string $filename     Name of the configuration being updated.
333     * @param array  $fullSections Array of section names that need to be fully
334     * overridden (as opposed to overridden on a setting-by-setting basis).
335     *
336     * @return void
337     */
338    protected function applyOldSettings($filename, $fullSections = [])
339    {
340        // First override all individual settings:
341        foreach ($this->oldConfigs[$filename] as $section => $subsection) {
342            foreach ($subsection as $key => $value) {
343                $this->newConfigs[$filename][$section][$key] = $value;
344            }
345        }
346
347        // Now override on a section-by-section basis where necessary:
348        foreach ($fullSections as $section) {
349            $this->newConfigs[$filename][$section]
350                = $this->oldConfigs[$filename][$section] ?? [];
351        }
352    }
353
354    /**
355     * Save a modified configuration file.
356     *
357     * @param string $filename Name of config file to write (contents will be
358     * pulled from current state of object properties).
359     *
360     * @throws FileAccessException
361     * @return void
362     */
363    protected function saveModifiedConfig($filename)
364    {
365        if (null === $this->newDir) {   // skip write if no destination
366            return;
367        }
368
369        // If we're doing an in-place upgrade, and the source file is empty,
370        // there is no point in upgrading anything (the file doesn't exist).
371        if (empty($this->oldConfigs[$filename]) && $this->inPlaceUpgrade) {
372            // Special case: if we set up custom permissions, we need to
373            // write the file even if it didn't previously exist.
374            if (!$this->permissionsModified || $filename !== 'permissions.ini') {
375                return;
376            }
377        }
378
379        // If target file already exists, back it up:
380        $outfile = $this->newDir . '/' . $filename;
381        $bakfile = $outfile . '.bak.' . time();
382        if (file_exists($outfile) && !copy($outfile, $bakfile)) {
383            throw new FileAccessException(
384                "Error: Could not copy {$outfile} to {$bakfile}."
385            );
386        }
387
388        $writer = new ConfigWriter(
389            $outfile,
390            $this->newConfigs[$filename],
391            $this->comments[$filename]
392        );
393        if (!$writer->save()) {
394            throw new FileAccessException(
395                "Error: Problem writing to {$outfile}."
396            );
397        }
398    }
399
400    /**
401     * Save an unmodified configuration file -- copy the old version, unless it is
402     * the same as the new version!
403     *
404     * @param string $filename Path to the old config file
405     *
406     * @throws FileAccessException
407     * @return void
408     */
409    protected function saveUnmodifiedConfig($filename)
410    {
411        if (null === $this->newDir) {   // skip write if no destination
412            return;
413        }
414
415        if ($this->inPlaceUpgrade) {    // skip write if doing in-place upgrade
416            return;
417        }
418
419        // Figure out directories for all versions of this config file:
420        $src = $this->getOldConfigPath($filename);
421        $raw = $this->rawDir . '/' . $filename;
422        $dest = $this->newDir . '/' . $filename;
423
424        // Compare the source file against the raw file; if they happen to be the
425        // same, we don't need to copy anything!
426        if (
427            file_exists($src) && file_exists($raw)
428            && md5(file_get_contents($src)) === md5(file_get_contents($raw))
429        ) {
430            return;
431        }
432
433        // If we got this far, we need to copy the user's file into place:
434        if (file_exists($src) && !copy($src, $dest)) {
435            throw new FileAccessException(
436                "Error: Could not copy {$src} to {$dest}."
437            );
438        }
439    }
440
441    /**
442     * Check for invalid theme setting.
443     *
444     * @param string $setting Name of setting in [Site] section to check.
445     * @param string $default Default value to use if invalid option was found.
446     *
447     * @return void
448     */
449    protected function checkTheme($setting, $default = null)
450    {
451        // If a setting is not set, there is nothing to check:
452        $theme = $this->newConfigs['config.ini']['Site'][$setting] ?? null;
453        if (empty($theme)) {
454            return;
455        }
456
457        $parts = explode(',', $theme);
458        $theme = trim($parts[0]);
459
460        if (
461            !file_exists(APPLICATION_PATH . '/themes/' . $theme)
462            || !is_dir(APPLICATION_PATH . '/themes/' . $theme)
463        ) {
464            if ($default === null) {
465                $this->addWarning(
466                    "WARNING: This version of VuFind does not support the {$theme} "
467                    . "theme. As such, we have disabled your {$setting} setting."
468                );
469                unset($this->newConfigs['config.ini']['Site'][$setting]);
470            } else {
471                $this->addWarning(
472                    'WARNING: This version of VuFind does not support '
473                    . "the {$theme} theme. Your config.ini [Site] {$setting} setting"
474                    . " has been reset to the default: {$default}. You may need to "
475                    . 'reimplement your custom theme.'
476                );
477                $this->newConfigs['config.ini']['Site'][$setting] = $default;
478            }
479        }
480    }
481
482    /**
483     * Is this a default BulkExport options setting?
484     *
485     * @param string $eo Bulk export options
486     *
487     * @return bool
488     */
489    protected function isDefaultBulkExportOptions($eo)
490    {
491        if (Comparator::greaterThanOrEqualTo($this->from, '2.4')) {
492            $default = 'MARC:MARCXML:EndNote:EndNoteWeb:RefWorks:BibTeX:RIS';
493        } elseif (Comparator::greaterThanOrEqualTo($this->from, '2.0')) {
494            $default = 'MARC:MARCXML:EndNote:EndNoteWeb:RefWorks:BibTeX';
495        } elseif (Comparator::greaterThanOrEqualTo($this->from, '1.4')) {
496            $default = 'MARC:MARCXML:EndNote:RefWorks:BibTeX';
497        } elseif (Comparator::greaterThanOrEqualTo($this->from, '1.3')) {
498            $default = 'MARC:EndNote:RefWorks:BibTeX';
499        } elseif (Comparator::greaterThanOrEqualTo($this->from, '1.2')) {
500            $default = 'MARC:EndNote:BibTeX';
501        } else {
502            $default = 'MARC:EndNote';
503        }
504        return $eo == $default;
505    }
506
507    /**
508     * Add warnings if Amazon problems were found.
509     *
510     * @param array $config Configuration to check
511     *
512     * @return void
513     */
514    protected function checkAmazonConfig($config)
515    {
516        // Warn the user if they have Amazon enabled but do not have the appropriate
517        // credentials set up.
518        $hasAmazonReview = stristr($config['Content']['reviews'] ?? '', 'amazon');
519        $hasAmazonCover = stristr($config['Content']['coverimages'] ?? '', 'amazon');
520        if ($hasAmazonReview || $hasAmazonCover) {
521            $this->addWarning(
522                'WARNING: You have Amazon content enabled, but VuFind no longer '
523                . 'supports it. You should remove Amazon references from config.ini.'
524            );
525        }
526    }
527
528    /**
529     * Upgrade config.ini.
530     *
531     * @throws FileAccessException
532     * @return void
533     */
534    protected function upgradeConfig()
535    {
536        // override new version's defaults with matching settings from old version:
537        $this->applyOldSettings('config.ini');
538
539        // Set up reference for convenience (and shorter lines):
540        $newConfig = & $this->newConfigs['config.ini'];
541
542        // If the [BulkExport] options setting is present and non-default, warn
543        // the user about its deprecation.
544        if (isset($newConfig['BulkExport']['options'])) {
545            $default = $this->isDefaultBulkExportOptions(
546                $newConfig['BulkExport']['options']
547            );
548            if (!$default) {
549                $this->addWarning(
550                    'The [BulkExport] options setting is deprecated; please '
551                    . 'customize the [Export] section instead.'
552                );
553            }
554            unset($newConfig['BulkExport']['options']);
555        }
556
557        // If [Statistics] is present, warn the user about its removal.
558        if (isset($newConfig['Statistics'])) {
559            $this->addWarning(
560                'The Statistics module has been removed from VuFind. ' .
561                'For usage tracking, please configure Google Analytics or Matomo.'
562            );
563            unset($newConfig['Statistics']);
564        }
565
566        // Warn the user about Amazon configuration issues:
567        $this->checkAmazonConfig($newConfig);
568
569        // Warn the user if they have enabled a deprecated Google API:
570        if (isset($newConfig['GoogleSearch'])) {
571            unset($newConfig['GoogleSearch']);
572            $this->addWarning(
573                'The [GoogleSearch] section of config.ini is no '
574                . 'longer supported due to changes in Google APIs.'
575            );
576        }
577        if (
578            isset($newConfig['Content']['recordMap'])
579            && 'google' == strtolower($newConfig['Content']['recordMap'])
580        ) {
581            unset($newConfig['Content']['recordMap']);
582            unset($newConfig['Content']['googleMapApiKey']);
583            $this->addWarning(
584                'Google Maps is no longer a supported Content/recordMap option;'
585                . ' please review your config.ini.'
586            );
587        }
588        if (isset($newConfig['GoogleAnalytics']['apiKey'])) {
589            if (
590                !isset($newConfig['GoogleAnalytics']['universal'])
591                || !$newConfig['GoogleAnalytics']['universal']
592            ) {
593                $this->addWarning(
594                    'The [GoogleAnalytics] universal setting is off. See config.ini '
595                    . 'for important information on how to upgrade your Analytics.'
596                );
597            }
598        }
599
600        // Upgrade CAPTCHA Options
601        $legacySettingsMap = [
602            'publicKey' => 'recaptcha_siteKey',
603            'siteKey' => 'recaptcha_siteKey',
604            'privateKey' => 'recaptcha_secretKey',
605            'secretKey' => 'recaptcha_secretKey',
606            'theme' => 'recaptcha_theme',
607        ];
608        $foundRecaptcha = false;
609        foreach ($legacySettingsMap as $old => $new) {
610            if (isset($newConfig['Captcha'][$old])) {
611                $newConfig['Captcha'][$new]
612                    = $newConfig['Captcha'][$old];
613                unset($newConfig['Captcha'][$old]);
614            }
615            if (isset($newConfig['Captcha'][$new])) {
616                $foundRecaptcha = true;
617            }
618        }
619        if ($foundRecaptcha && !isset($newConfig['Captcha']['types'])) {
620            $newConfig['Captcha']['types'] = ['recaptcha'];
621        }
622
623        // Warn the user about deprecated WorldCat settings:
624        if (isset($newConfig['WorldCat']['LimitCodes'])) {
625            unset($newConfig['WorldCat']['LimitCodes']);
626            $this->addWarning(
627                'The [WorldCat] LimitCodes setting never had any effect and has been'
628                . ' removed.'
629            );
630        }
631        $badKeys
632            = ['id', 'xISBN_token', 'xISBN_secret', 'xISSN_token', 'xISSN_secret'];
633        foreach ($badKeys as $key) {
634            if (isset($newConfig['WorldCat'][$key])) {
635                unset($newConfig['WorldCat'][$key]);
636                $this->addWarning(
637                    'The [WorldCat] ' . $key . ' setting is no longer used and'
638                    . ' has been removed.'
639                );
640            }
641        }
642        if (
643            isset($newConfig['Record']['related'])
644            && in_array('Editions', $newConfig['Record']['related'])
645        ) {
646            $newConfig['Record']['related'] = array_diff(
647                $newConfig['Record']['related'],
648                ['Editions']
649            );
650            $this->addWarning(
651                'The Editions related record module is no longer '
652                . 'supported due to OCLC\'s xID API shutdown.'
653                . ' It has been removed from your settings.'
654            );
655        }
656
657        // Upgrade Google Options:
658        if (
659            isset($newConfig['Content']['GoogleOptions'])
660            && !is_array($newConfig['Content']['GoogleOptions'])
661        ) {
662            $newConfig['Content']['GoogleOptions']
663                = ['link' => $newConfig['Content']['GoogleOptions']];
664        }
665
666        // Disable unused, obsolete setting:
667        unset($newConfig['Index']['local']);
668
669        // Warn the user if they are using an unsupported theme:
670        $this->checkTheme('theme', 'bootprint3');
671        $this->checkTheme('mobile_theme', null);
672
673        // Translate legacy auth settings:
674        if (strtolower($newConfig['Authentication']['method']) == 'db') {
675            $newConfig['Authentication']['method'] = 'Database';
676        }
677        if (strtolower($newConfig['Authentication']['method']) == 'sip') {
678            $newConfig['Authentication']['method'] = 'SIP2';
679        }
680
681        // Translate legacy session settings:
682        $newConfig['Session']['type'] = ucwords(
683            str_replace('session', '', strtolower($newConfig['Session']['type']))
684        );
685        if ($newConfig['Session']['type'] == 'Mysql') {
686            $newConfig['Session']['type'] = 'Database';
687        }
688
689        // Eliminate obsolete database settings:
690        $newConfig['Database']
691            = ['database' => $newConfig['Database']['database']];
692
693        // Eliminate obsolete config override settings:
694        unset($newConfig['Extra_Config']);
695
696        // Update generator if it contains a version number:
697        if (
698            isset($newConfig['Site']['generator'])
699            && preg_match('/^VuFind (\d+\.?)+$/', $newConfig['Site']['generator'])
700        ) {
701            $newConfig['Site']['generator'] = 'VuFind ' . $this->to;
702        }
703
704        // Update Syndetics config:
705        if (isset($newConfig['Syndetics']['url'])) {
706            $newConfig['Syndetics']['use_ssl']
707                = (!str_contains($newConfig['Syndetics']['url'], 'https://'))
708                ? '' : 1;
709            unset($newConfig['Syndetics']['url']);
710        }
711
712        // Convert spellchecker 'simple' option
713        if (
714            // If 'simple' is set
715            isset($newConfig['Spelling']['simple']) &&
716            // and 'dictionaries' is set to default
717            ($newConfig['Spelling']['dictionaries'] == ['default', 'basicSpell'])
718        ) {
719            $newConfig['Spelling']['dictionaries'] = $newConfig['Spelling']['simple']
720                ? ['basicSpell'] : ['default', 'basicSpell'];
721        }
722        unset($newConfig['Spelling']['simple']);
723
724        // Translate obsolete permission settings:
725        $this->upgradeAdminPermissions();
726
727        // Deal with shard settings (which may have to be moved to another file):
728        $this->upgradeShardSettings();
729
730        // save the file
731        $this->saveModifiedConfig('config.ini');
732    }
733
734    /**
735     * Translate obsolete permission settings.
736     *
737     * @return void
738     */
739    protected function upgradeAdminPermissions()
740    {
741        $config = & $this->newConfigs['config.ini'];
742        $permissions = & $this->newConfigs['permissions.ini'];
743
744        if (isset($config['AdminAuth'])) {
745            $permissions['access.AdminModule'] = [];
746            if (isset($config['AdminAuth']['ipRegEx'])) {
747                $permissions['access.AdminModule']['ipRegEx']
748                    = $config['AdminAuth']['ipRegEx'];
749            }
750            if (isset($config['AdminAuth']['userWhitelist'])) {
751                $permissions['access.AdminModule']['username']
752                    = $config['AdminAuth']['userWhitelist'];
753            }
754            // If no settings exist in config.ini, we grant access to everyone
755            // by allowing both logged-in and logged-out roles.
756            if (empty($permissions['access.AdminModule'])) {
757                $permissions['access.AdminModule']['role'] = ['guest', 'loggedin'];
758            }
759            $permissions['access.AdminModule']['permission'] = 'access.AdminModule';
760            $this->permissionsModified = true;
761
762            // Remove any old settings remaining in config.ini:
763            unset($config['AdminAuth']);
764        }
765    }
766
767    /**
768     * Change an array key.
769     *
770     * @param array  $array Array to rewrite
771     * @param string $old   Old key name
772     * @param string $new   New key name
773     *
774     * @return array
775     */
776    protected function changeArrayKey($array, $old, $new)
777    {
778        $newArr = [];
779        foreach ($array as $k => $v) {
780            if ($k === $old) {
781                $k = $new;
782            }
783            $newArr[$k] = $v;
784        }
785        return $newArr;
786    }
787
788    /**
789     * Support method for upgradeFacetsAndCollection() - change the name of
790     * a facet field.
791     *
792     * @param string $old Old field name
793     * @param string $new New field name
794     *
795     * @return void
796     */
797    protected function renameFacet($old, $new)
798    {
799        $didWork = false;
800        if (isset($this->newConfigs['facets.ini']['Results'][$old])) {
801            $this->newConfigs['facets.ini']['Results'] = $this->changeArrayKey(
802                $this->newConfigs['facets.ini']['Results'],
803                $old,
804                $new
805            );
806            $didWork = true;
807        }
808        if (isset($this->newConfigs['Collection.ini']['Facets'][$old])) {
809            $this->newConfigs['Collection.ini']['Facets'] = $this->changeArrayKey(
810                $this->newConfigs['Collection.ini']['Facets'],
811                $old,
812                $new
813            );
814            $didWork = true;
815        }
816        if ($didWork) {
817            $this->newConfigs['facets.ini']['LegacyFields'][$old] = $new;
818        }
819    }
820
821    /**
822     * Upgrade facets.ini and Collection.ini (since these are tied together).
823     *
824     * @throws FileAccessException
825     * @return void
826     */
827    protected function upgradeFacetsAndCollection()
828    {
829        // we want to retain the old installation's various facet groups
830        // exactly as-is
831        $facetGroups = [
832            'Results', 'ResultsTop', 'Advanced', 'Author', 'CheckboxFacets',
833            'HomePage',
834        ];
835        $this->applyOldSettings('facets.ini', $facetGroups);
836        $this->applyOldSettings('Collection.ini', ['Facets', 'Sort']);
837
838        // fill in home page facets with advanced facets if missing:
839        if (!isset($this->oldConfigs['facets.ini']['HomePage'])) {
840            $this->newConfigs['facets.ini']['HomePage']
841                = $this->newConfigs['facets.ini']['Advanced'];
842        }
843
844        // rename changed facets
845        $this->renameFacet('authorStr', 'author_facet');
846
847        // save the file
848        $this->saveModifiedConfig('facets.ini');
849        $this->saveModifiedConfig('Collection.ini');
850    }
851
852    /**
853     * Update an old VuFind 1.x-style autocomplete handler name to the new style.
854     *
855     * @param string $name Name of module.
856     *
857     * @return string
858     */
859    protected function upgradeAutocompleteName($name)
860    {
861        if ($name == 'NoAutocomplete') {
862            return 'None';
863        }
864        return str_replace('Autocomplete', '', $name);
865    }
866
867    /**
868     * Upgrade searches.ini.
869     *
870     * @throws FileAccessException
871     * @return void
872     */
873    protected function upgradeSearches()
874    {
875        // we want to retain the old installation's Basic/Advanced search settings
876        // and sort settings exactly as-is
877        $groups = [
878            'Basic_Searches', 'Advanced_Searches', 'Sorting', 'DefaultSortingByType',
879        ];
880        $this->applyOldSettings('searches.ini', $groups);
881
882        // Fix autocomplete settings in case they use the old style:
883        $newConfig = & $this->newConfigs['searches.ini'];
884        if (isset($newConfig['Autocomplete']['default_handler'])) {
885            $newConfig['Autocomplete']['default_handler']
886                = $this->upgradeAutocompleteName(
887                    $newConfig['Autocomplete']['default_handler']
888                );
889        }
890        if (isset($newConfig['Autocomplete_Types'])) {
891            foreach ($newConfig['Autocomplete_Types'] as $k => $v) {
892                $parts = explode(':', $v);
893                $parts[0] = $this->upgradeAutocompleteName($parts[0]);
894                $newConfig['Autocomplete_Types'][$k] = implode(':', $parts);
895            }
896        }
897
898        // fix call number sort settings:
899        if (isset($newConfig['Sorting']['callnumber'])) {
900            $newConfig['Sorting']['callnumber-sort']
901                = $newConfig['Sorting']['callnumber'];
902            unset($newConfig['Sorting']['callnumber']);
903        }
904        if (isset($newConfig['DefaultSortingByType'])) {
905            foreach ($newConfig['DefaultSortingByType'] as & $v) {
906                if ($v === 'callnumber') {
907                    $v = 'callnumber-sort';
908                }
909            }
910        }
911        $this->upgradeSpellingSettings('searches.ini', ['CallNumber', 'WorkKeys']);
912
913        // save the file
914        $this->saveModifiedConfig('searches.ini');
915    }
916
917    /**
918     * Upgrade spelling settings to account for refactoring of spelling as a
919     * recommendation module starting in release 2.4.
920     *
921     * @param string $ini  .ini file to modify
922     * @param array  $skip Keys to skip within [TopRecommendations]
923     *
924     * @return void
925     */
926    protected function upgradeSpellingSettings($ini, $skip = [])
927    {
928        // Turn on the spelling recommendations if we're upgrading from a version
929        // prior to 2.4.
930        if (Comparator::lessThan($this->from, '2.4')) {
931            // Fix defaults in general section:
932            $cfg = & $this->newConfigs[$ini]['General'];
933            $keys = ['default_top_recommend', 'default_noresults_recommend'];
934            foreach ($keys as $key) {
935                if (!isset($cfg[$key])) {
936                    $cfg[$key] = [];
937                }
938                if (!in_array('SpellingSuggestions', $cfg[$key])) {
939                    $cfg[$key][] = 'SpellingSuggestions';
940                }
941            }
942
943            // Fix settings in [TopRecommendations]
944            $cfg = & $this->newConfigs[$ini]['TopRecommendations'];
945            // Add SpellingSuggestions to all non-skipped handlers:
946            foreach ($cfg as $key => & $value) {
947                if (
948                    !in_array($key, $skip)
949                    && !in_array('SpellingSuggestions', $value)
950                ) {
951                    $value[] = 'SpellingSuggestions';
952                }
953            }
954            // Define handlers with no spelling support as the default minus the
955            // Spelling option:
956            foreach ($skip as $key) {
957                if (!isset($cfg[$key])) {
958                    $cfg[$key] = array_diff(
959                        $this->newConfigs[$ini]['General']['default_top_recommend'],
960                        ['SpellingSuggestions']
961                    );
962                }
963            }
964        }
965    }
966
967    /**
968     * Upgrade fulltext.ini.
969     *
970     * @throws FileAccessException
971     * @return void
972     */
973    protected function upgradeFulltext()
974    {
975        $this->saveUnmodifiedConfig('fulltext.ini');
976    }
977
978    /**
979     * Upgrade sitemap.ini.
980     *
981     * @throws FileAccessException
982     * @return void
983     */
984    protected function upgradeSitemap()
985    {
986        $this->saveUnmodifiedConfig('sitemap.ini');
987    }
988
989    /**
990     * Upgrade sms.ini.
991     *
992     * @throws FileAccessException
993     * @return void
994     */
995    protected function upgradeSms()
996    {
997        $this->applyOldSettings('sms.ini', ['Carriers']);
998        $this->saveModifiedConfig('sms.ini');
999    }
1000
1001    /**
1002     * Upgrade authority.ini.
1003     *
1004     * @throws FileAccessException
1005     * @return void
1006     */
1007    protected function upgradeAuthority()
1008    {
1009        // we want to retain the old installation's search and facet settings
1010        // exactly as-is
1011        $groups = [
1012            'Facets', 'Basic_Searches', 'Advanced_Searches', 'Sorting',
1013        ];
1014        $this->applyOldSettings('authority.ini', $groups);
1015
1016        // save the file
1017        $this->saveModifiedConfig('authority.ini');
1018    }
1019
1020    /**
1021     * Upgrade reserves.ini.
1022     *
1023     * @throws FileAccessException
1024     * @return void
1025     */
1026    protected function upgradeReserves()
1027    {
1028        // If Reserves module is disabled, don't bother updating config:
1029        if (
1030            !isset($this->newConfigs['config.ini']['Reserves']['search_enabled'])
1031            || !$this->newConfigs['config.ini']['Reserves']['search_enabled']
1032        ) {
1033            return;
1034        }
1035
1036        // we want to retain the old installation's search and facet settings
1037        // exactly as-is
1038        $groups = [
1039            'Facets', 'Basic_Searches', 'Advanced_Searches', 'Sorting',
1040        ];
1041        $this->applyOldSettings('reserves.ini', $groups);
1042
1043        // save the file
1044        $this->saveModifiedConfig('reserves.ini');
1045    }
1046
1047    /**
1048     * Upgrade Summon.ini.
1049     *
1050     * @throws FileAccessException
1051     * @return void
1052     */
1053    protected function upgradeSummon()
1054    {
1055        // If Summon is disabled in our current configuration, we don't need to
1056        // load any Summon-specific settings:
1057        if (!isset($this->newConfigs['config.ini']['Summon']['apiKey'])) {
1058            return;
1059        }
1060
1061        // we want to retain the old installation's search and facet settings
1062        // exactly as-is
1063        $groups = [
1064            'Facets', 'FacetsTop', 'Basic_Searches', 'Advanced_Searches', 'Sorting',
1065        ];
1066        $this->applyOldSettings('Summon.ini', $groups);
1067
1068        // Turn on advanced checkbox facets if we're upgrading from a version
1069        // prior to 2.3.
1070        if (Comparator::lessThan($this->from, '2.3')) {
1071            $cfg = & $this->newConfigs['Summon.ini']['Advanced_Facet_Settings'];
1072            $specialFacets = $cfg['special_facets'] ?? null;
1073            if (empty($specialFacets)) {
1074                $cfg['special_facets'] = 'checkboxes:Summon';
1075            } elseif (!str_contains('checkboxes', (string)$specialFacets)) {
1076                $cfg['special_facets'] .= ',checkboxes:Summon';
1077            }
1078        }
1079
1080        // update permission settings
1081        $this->upgradeSummonPermissions();
1082
1083        $this->upgradeSpellingSettings('Summon.ini');
1084
1085        // save the file
1086        $this->saveModifiedConfig('Summon.ini');
1087    }
1088
1089    /**
1090     * Translate obsolete permission settings.
1091     *
1092     * @return void
1093     */
1094    protected function upgradeSummonPermissions()
1095    {
1096        $config = & $this->newConfigs['Summon.ini'];
1097        $permissions = & $this->newConfigs['permissions.ini'];
1098        if (isset($config['Auth'])) {
1099            $permissions['access.SummonExtendedResults'] = [];
1100            if (
1101                isset($config['Auth']['check_login'])
1102                && $config['Auth']['check_login']
1103            ) {
1104                $permissions['access.SummonExtendedResults']['role'] = ['loggedin'];
1105            }
1106            if (isset($config['Auth']['ip_range'])) {
1107                $permissions['access.SummonExtendedResults']['ipRegEx']
1108                    = $config['Auth']['ip_range'];
1109            }
1110            if (!empty($permissions['access.SummonExtendedResults'])) {
1111                $permissions['access.SummonExtendedResults']['boolean'] = 'OR';
1112                $permissions['access.SummonExtendedResults']['permission']
1113                    = 'access.SummonExtendedResults';
1114                $this->permissionsModified = true;
1115            } else {
1116                unset($permissions['access.SummonExtendedResults']);
1117            }
1118
1119            // Remove any old settings remaining in Summon.ini:
1120            unset($config['Auth']);
1121        }
1122    }
1123
1124    /**
1125     * Upgrade Primo.ini.
1126     *
1127     * @throws FileAccessException
1128     * @return void
1129     */
1130    protected function upgradePrimo()
1131    {
1132        // we want to retain the old installation's search and facet settings
1133        // exactly as-is
1134        $groups = [
1135            'Facets', 'FacetsTop', 'Basic_Searches', 'Advanced_Searches', 'Sorting',
1136        ];
1137        $this->applyOldSettings('Primo.ini', $groups);
1138
1139        // update permission settings
1140        $this->upgradePrimoPermissions();
1141
1142        // update server settings
1143        $this->upgradePrimoServerSettings();
1144
1145        // save the file
1146        $this->saveModifiedConfig('Primo.ini');
1147    }
1148
1149    /**
1150     * Translate obsolete permission settings.
1151     *
1152     * @return void
1153     */
1154    protected function upgradePrimoPermissions()
1155    {
1156        $config = & $this->newConfigs['Primo.ini'];
1157        $permissions = & $this->newConfigs['permissions.ini'];
1158        if (
1159            isset($config['Institutions']['code'])
1160            && isset($config['Institutions']['regex'])
1161        ) {
1162            $codes = $config['Institutions']['code'];
1163            $regex = $config['Institutions']['regex'];
1164            if (count($regex) != count($codes)) {
1165                $this->addWarning(
1166                    'Mismatched code/regex counts in Primo.ini [Institutions].'
1167                );
1168            }
1169
1170            // Map parallel arrays into code => array of regexes and detect
1171            // wildcard regex to treat as default code.
1172            $map = [];
1173            $default = null;
1174            foreach ($codes as $i => $code) {
1175                if ($regex[$i] == '/.*/') {
1176                    $default = $code;
1177                } else {
1178                    $map[$code] = !isset($map[$code])
1179                        ? [$regex[$i]]
1180                        : array_merge($map[$code], [$regex[$i]]);
1181                }
1182            }
1183            foreach ($map as $code => $regexes) {
1184                $perm = "access.PrimoInstitution.$code";
1185                $config['Institutions']["onCampusRule['$code']"] = $perm;
1186                $permissions[$perm] = [
1187                    'ipRegEx' => count($regexes) == 1 ? $regexes[0] : $regexes,
1188                    'permission' => $perm,
1189                ];
1190                $this->permissionsModified = true;
1191            }
1192            if (null !== $default) {
1193                $config['Institutions']['defaultCode'] = $default;
1194            }
1195
1196            // Remove any old settings remaining in Primo.ini:
1197            unset($config['Institutions']['code']);
1198            unset($config['Institutions']['regex']);
1199        }
1200    }
1201
1202    /**
1203     * Translate obsolete server settings.
1204     *
1205     * @return void
1206     */
1207    protected function upgradePrimoServerSettings()
1208    {
1209        $config = & $this->newConfigs['Primo.ini'];
1210        // Convert apiId to url
1211        if (isset($config['General']['apiId'])) {
1212            $url = 'http://' . $config['General']['apiId']
1213                . '.hosted.exlibrisgroup.com';
1214            if (isset($config['General']['port'])) {
1215                $url .= ':' . $config['General']['port'];
1216            } else {
1217                $url .= ':1701';
1218            }
1219
1220            $config['General']['url'] = $url;
1221
1222            // Remove any old settings remaining in Primo.ini:
1223            unset($config['General']['apiId']);
1224            unset($config['General']['port']);
1225        }
1226    }
1227
1228    /**
1229     * Upgrade WorldCat.ini.
1230     *
1231     * @throws FileAccessException
1232     * @return void
1233     */
1234    protected function upgradeWorldCat()
1235    {
1236        // If WorldCat is disabled in our current configuration, we don't need to
1237        // load any WorldCat-specific settings:
1238        if (!isset($this->newConfigs['config.ini']['WorldCat']['apiKey'])) {
1239            return;
1240        }
1241
1242        // we want to retain the old installation's search settings exactly as-is
1243        $groups = [
1244            'Basic_Searches', 'Advanced_Searches', 'Sorting',
1245        ];
1246        $this->applyOldSettings('WorldCat.ini', $groups);
1247
1248        // we need to fix an obsolete search setting for authors
1249        foreach (['Basic_Searches', 'Advanced_Searches'] as $section) {
1250            $new = [];
1251            foreach ($this->newConfigs['WorldCat.ini'][$section] as $k => $v) {
1252                if ($k == 'srw.au:srw.pn:srw.cn') {
1253                    $k = 'srw.au';
1254                }
1255                $new[$k] = $v;
1256            }
1257            $this->newConfigs['WorldCat.ini'][$section] = $new;
1258        }
1259
1260        // Deal with deprecated related record module.
1261        $newConfig = & $this->newConfigs['WorldCat.ini'];
1262        if (
1263            isset($newConfig['Record']['related'])
1264            && in_array('WorldCatEditions', $newConfig['Record']['related'])
1265        ) {
1266            $newConfig['Record']['related'] = array_diff(
1267                $newConfig['Record']['related'],
1268                ['WorldCatEditions']
1269            );
1270            $this->addWarning(
1271                'The WorldCatEditions related record module is no longer '
1272                . 'supported due to OCLC\'s xID API shutdown.'
1273                . ' It has been removed from your settings.'
1274            );
1275        }
1276
1277        // save the file
1278        $this->saveModifiedConfig('WorldCat.ini');
1279    }
1280
1281    /**
1282     * Does the specified properties file contain any meaningful
1283     * (non-empty/non-comment) lines?
1284     *
1285     * @param string $src File to check
1286     *
1287     * @return bool
1288     */
1289    protected function fileContainsMeaningfulLines($src)
1290    {
1291        // Does the file contain any meaningful lines?
1292        foreach (file($src) as $line) {
1293            $line = trim($line);
1294            if ('' !== $line && !str_starts_with($line, '#')) {
1295                return true;
1296            }
1297        }
1298        return false;
1299    }
1300
1301    /**
1302     * Upgrade SolrMarc configurations.
1303     *
1304     * @throws FileAccessException
1305     * @return void
1306     */
1307    protected function upgradeSolrMarc()
1308    {
1309        if (null === $this->newDir) {   // skip this step if no write destination
1310            return;
1311        }
1312
1313        // Is there a marc_local.properties file?
1314        $src = realpath($this->oldDir . '/../../import/marc_local.properties');
1315        if (empty($src) || !file_exists($src)) {
1316            return;
1317        }
1318
1319        // Copy the file if it contains customizations:
1320        if ($this->fileContainsMeaningfulLines($src)) {
1321            $dest = realpath($this->newDir . '/../../import')
1322                . '/marc_local.properties';
1323            if (!copy($src, $dest) || !file_exists($dest)) {
1324                throw new FileAccessException(
1325                    "Cannot copy {$src} to {$dest}."
1326                );
1327            }
1328        }
1329    }
1330
1331    /**
1332     * Upgrade .yaml configurations.
1333     *
1334     * @throws FileAccessException
1335     * @return void
1336     */
1337    protected function upgradeSearchSpecs()
1338    {
1339        if (null === $this->newDir) {   // skip this step if no write destination
1340            return;
1341        }
1342
1343        // VuFind 1.x uses *_local.yaml files as overrides; VuFind 2.x uses files
1344        // with the same filename in the local directory. Copy any old override
1345        // files into the new expected location:
1346        $files = ['searchspecs', 'authsearchspecs', 'reservessearchspecs'];
1347        foreach ($files as $file) {
1348            $old = $this->oldDir . '/' . $file . '_local.yaml';
1349            $new = $this->newDir . '/' . $file . '.yaml';
1350            if (file_exists($old)) {
1351                if (!copy($old, $new)) {
1352                    throw new FileAccessException(
1353                        "Cannot copy {$old} to {$new}."
1354                    );
1355                }
1356            }
1357        }
1358    }
1359
1360    /**
1361     * Upgrade ILS driver configuration.
1362     *
1363     * @throws FileAccessException
1364     * @return void
1365     */
1366    protected function upgradeILS()
1367    {
1368        $driver = $this->newConfigs['config.ini']['Catalog']['driver'] ?? '';
1369        if (empty($driver)) {
1370            $this->addWarning('WARNING: Could not find ILS driver setting.');
1371        } elseif ('Sample' == $driver) {
1372            // No configuration file for Sample driver
1373        } elseif ('AdminScripts' == $driver) {
1374            // Prevent abuse if upgrade process is hijacked
1375        } elseif (!file_exists($this->oldDir . '/' . $driver . '.ini')) {
1376            $this->addWarning(
1377                "WARNING: Could not find {$driver}.ini file; "
1378                . 'check your ILS driver configuration.'
1379            );
1380        } else {
1381            $this->saveUnmodifiedConfig($driver . '.ini');
1382        }
1383
1384        // If we're set to load NoILS.ini on failure, copy that over as well:
1385        if (
1386            isset($this->newConfigs['config.ini']['Catalog']['loadNoILSOnFailure'])
1387            && $this->newConfigs['config.ini']['Catalog']['loadNoILSOnFailure']
1388        ) {
1389            // If NoILS is also the main driver, we don't need to copy it twice:
1390            if ($driver != 'NoILS') {
1391                $this->saveUnmodifiedConfig('NoILS.ini');
1392            }
1393        }
1394    }
1395
1396    /**
1397     * Upgrade shard settings (they have moved to a different config file, so
1398     * this is handled as a separate method so that all affected settings are
1399     * addressed in one place.
1400     *
1401     * This gets called from updateConfig(), which gets called before other
1402     * configuration upgrade routines. This means that we need to modify the
1403     * config.ini settings in the newConfigs property (since it is currently
1404     * being worked on and will be written to disk shortly), but we need to
1405     * modify the searches.ini/facets.ini settings in the oldConfigs property
1406     * (because they have not been processed yet).
1407     *
1408     * @return void
1409     */
1410    protected function upgradeShardSettings()
1411    {
1412        // move settings from config.ini to searches.ini:
1413        if (isset($this->newConfigs['config.ini']['IndexShards'])) {
1414            $this->oldConfigs['searches.ini']['IndexShards']
1415                = $this->newConfigs['config.ini']['IndexShards'];
1416            unset($this->newConfigs['config.ini']['IndexShards']);
1417        }
1418        if (isset($this->newConfigs['config.ini']['ShardPreferences'])) {
1419            $this->oldConfigs['searches.ini']['ShardPreferences']
1420                = $this->newConfigs['config.ini']['ShardPreferences'];
1421            unset($this->newConfigs['config.ini']['ShardPreferences']);
1422        }
1423
1424        // move settings from facets.ini to searches.ini (merging StripFacets
1425        // setting with StripFields setting):
1426        if (isset($this->oldConfigs['facets.ini']['StripFacets'])) {
1427            if (!isset($this->oldConfigs['searches.ini']['StripFields'])) {
1428                $this->oldConfigs['searches.ini']['StripFields'] = [];
1429            }
1430            foreach ($this->oldConfigs['facets.ini']['StripFacets'] as $k => $v) {
1431                // If we already have values for the current key, merge and dedupe:
1432                if (isset($this->oldConfigs['searches.ini']['StripFields'][$k])) {
1433                    $v .= ',' . $this->oldConfigs['searches.ini']['StripFields'][$k];
1434                    $parts = explode(',', $v);
1435                    foreach ($parts as $i => $part) {
1436                        $parts[$i] = trim($part);
1437                    }
1438                    $v = implode(',', array_unique($parts));
1439                }
1440                $this->oldConfigs['searches.ini']['StripFields'][$k] = $v;
1441            }
1442            unset($this->oldConfigs['facets.ini']['StripFacets']);
1443        }
1444    }
1445
1446    /**
1447     * Read the specified file and return an associative array of this format
1448     * containing all comments extracted from the file:
1449     *
1450     * [
1451     *   'sections' => array
1452     *     'section_name_1' => array
1453     *       'before' => string ("Comments found at the beginning of this section")
1454     *       'inline' => string ("Comments found at the end of the section's line")
1455     *       'settings' => array
1456     *         'setting_name_1' => array
1457     *           'before' => string ("Comments found before this setting")
1458     *           'inline' => string ("Comments found at the end of setting's line")
1459     *           ...
1460     *         'setting_name_n' => array (same keys as setting_name_1)
1461     *        ...
1462     *      'section_name_n' => array (same keys as section_name_1)
1463     *   'after' => string ("Comments found at the very end of the file")
1464     * ]
1465     *
1466     * @param string $filename Name of ini file to read.
1467     *
1468     * @return array           Associative array as described above.
1469     */
1470    protected function extractComments($filename)
1471    {
1472        $lines = file($filename);
1473
1474        // Initialize our return value:
1475        $retVal = ['sections' => [], 'after' => ''];
1476
1477        // Initialize variables for tracking status during parsing:
1478        $section = $comments = '';
1479
1480        foreach ($lines as $line) {
1481            // To avoid redundant processing, create a trimmed version of the current
1482            // line:
1483            $trimmed = trim($line);
1484
1485            // Is the current line a comment?  If so, add to the currentComments
1486            // string. Note that we treat blank lines as comments.
1487            if ('' === $trimmed || str_starts_with($trimmed, ';')) {
1488                $comments .= $line;
1489            } elseif (
1490                str_starts_with($trimmed, '[')
1491                && ($closeBracket = strpos($trimmed, ']')) > 1
1492            ) {
1493                // Is the current line the start of a section?  If so, create the
1494                // appropriate section of the return value:
1495                $section = substr($trimmed, 1, $closeBracket - 1);
1496                if ('' !== $section) {
1497                    // Grab comments at the end of the line, if any:
1498                    if (($semicolon = strpos($trimmed, ';')) !== false) {
1499                        $inline = trim(substr($trimmed, $semicolon));
1500                    } else {
1501                        $inline = '';
1502                    }
1503                    $retVal['sections'][$section] = [
1504                        'before' => $comments,
1505                        'inline' => $inline,
1506                        'settings' => []];
1507                    $comments = '';
1508                }
1509            } elseif (($equals = strpos($trimmed, '=')) !== false) {
1510                // Is the current line a setting?  If so, add to the return value:
1511                $set = trim(substr($trimmed, 0, $equals));
1512                $set = trim(str_replace('[]', '', $set));
1513                if ('' !== $section && '' !== $set) {
1514                    // Grab comments at the end of the line, if any:
1515                    if (($semicolon = strpos($trimmed, ';')) !== false) {
1516                        $inline = trim(substr($trimmed, $semicolon));
1517                    } else {
1518                        $inline = '';
1519                    }
1520                    // Currently, this data structure doesn't support arrays very
1521                    // well, since it can't distinguish which line of the array
1522                    // corresponds with which comments. For now, we just append all
1523                    // the preceding and inline comments together for arrays.  Since
1524                    // we rarely use arrays in the config.ini file, this isn't a big
1525                    // concern, but we should improve it if we ever need to.
1526                    if (!isset($retVal['sections'][$section]['settings'][$set])) {
1527                        $retVal['sections'][$section]['settings'][$set]
1528                            = ['before' => $comments, 'inline' => $inline];
1529                    } else {
1530                        $retVal['sections'][$section]['settings'][$set]['before']
1531                            .= $comments;
1532                        $retVal['sections'][$section]['settings'][$set]['inline']
1533                            .= "\n" . $inline;
1534                    }
1535                    $comments = '';
1536                }
1537            }
1538        }
1539
1540        // Store any leftover comments following the last setting:
1541        $retVal['after'] = $comments;
1542
1543        return $retVal;
1544    }
1545}