Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.95% covered (warning)
84.95%
79 / 93
33.33% covered (danger)
33.33%
3 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
LibGuidesProfile
84.95% covered (warning)
84.95%
79 / 93
33.33% covered (danger)
33.33%
3 / 9
37.94
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 setConfig
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 init
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 process
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getResults
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
5.27
 findBestMatchByCallNumber
88.46% covered (warning)
88.46%
23 / 26
0.00% covered (danger)
0.00%
0 / 1
9.12
 findBestMatchBySubject
86.36% covered (warning)
86.36%
19 / 22
0.00% covered (danger)
0.00%
0 / 1
6.09
 getLibGuidesData
50.00% covered (danger)
50.00%
4 / 8
0.00% covered (danger)
0.00%
0 / 1
4.12
 populateLibGuidesCache
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3/**
4 * LibGuides Profile Recommendations Module
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Villanova University 2023.
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  Recommendations
25 * @author   Maccabee Levine <msl321@lehigh.edu>
26 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
27 * @link     https://vufind.org/wiki/development:plugins:recommendation_modules Wiki
28 */
29
30namespace VuFind\Recommend;
31
32use Laminas\Cache\Storage\StorageInterface as CacheAdapter;
33use Laminas\Config\Config;
34use VuFind\Connection\LibGuides;
35
36use function intval;
37use function is_string;
38use function strlen;
39
40/**
41 * LibGuides Profile Recommendations Module
42 *
43 * @category VuFind
44 * @package  Recommendations
45 * @author   Maccabee Levine <msl321@lehigh.edu>
46 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
47 * @link     https://vufind.org/wiki/development:plugins:recommendation_modules Wiki
48 */
49class LibGuidesProfile implements
50    RecommendInterface
51{
52    use \VuFind\Cache\CacheTrait;
53    use \VuFindHttp\HttpServiceAwareTrait;
54
55    /**
56     * Search results object
57     *
58     * @var \VuFind\Search\Base\Results
59     */
60    protected $results;
61
62    /**
63     * LibGuides connector
64     *
65     * @var LibGuides
66     */
67    protected $libGuides;
68
69    /**
70     * List of strategies enabled to find a matching LibGuides profile
71     *
72     * @var int
73     */
74    protected $strategies = [];
75
76    /**
77     * Map of call number pattern to config alias
78     *
79     * @var array
80     */
81    protected $callNumberToAlias;
82
83    /**
84     * Map of config alias to LibGuides account ID
85     *
86     * @var array
87     */
88    protected $aliasToAccountId;
89
90    /**
91     * Facet field name containing the call numbers to match against
92     *
93     * @var string
94     */
95    protected $callNumberField;
96
97    /**
98     * Length of the substring at the start of a call number to match against
99     *
100     * @var int
101     */
102    protected $callNumberLength;
103
104    /**
105     * Constructor
106     *
107     * @param LibGuides    $libGuides LibGuides API connection
108     * @param Config       $config    LibGuides API configuration object
109     * @param CacheAdapter $cache     Object cache
110     */
111    public function __construct(
112        LibGuides $libGuides,
113        Config $config,
114        CacheAdapter $cache
115    ) {
116        $this->libGuides = $libGuides;
117        $this->setCacheStorage($cache);
118
119        // Cache the data related to profiles for up to 10 minutes:
120        $this->cacheLifetime = intval($config->GetAccounts->cache_lifetime ?? 600);
121
122        if ($profile = $config->Profile) {
123            $strategies = $profile->get('strategies', []);
124            $this->strategies = is_string($strategies) ? [$strategies] : $strategies;
125
126            $this->callNumberToAlias = $profile->call_numbers ? $profile->call_numbers->toArray() : [];
127            $this->aliasToAccountId = $profile->profile_aliases ? $profile->profile_aliases->toArray() : [];
128            $this->callNumberField = $profile->get('call_number_field', 'callnumber-first');
129            $this->callNumberLength = $profile->get('call_number_length', 3);
130        }
131    }
132
133    /**
134     * Store the configuration of the recommendation module.
135     *
136     * @param string $settings Settings from searches.ini.
137     *
138     * @return void
139     */
140    public function setConfig($settings)
141    {
142        // No action needed.
143    }
144
145    /**
146     * Called before the Search Results object performs its main search
147     * (specifically, in response to \VuFind\Search\SearchRunner::EVENT_CONFIGURED).
148     * This method is responsible for setting search parameters needed by the
149     * recommendation module and for reading any existing search parameters that may
150     * be needed.
151     *
152     * @param \VuFind\Search\Base\Params $params  Search parameter object
153     * @param \Laminas\Stdlib\Parameters $request Parameter object representing user
154     * request.
155     *
156     * @return void
157     *
158     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
159     */
160    public function init($params, $request)
161    {
162        // No action needed.
163    }
164
165    /**
166     * Called after the Search Results object has performed its main search. This
167     * may be used to extract necessary information from the Search Results object
168     * or to perform completely unrelated processing.
169     *
170     * @param \VuFind\Search\Base\Results $results Search results object
171     *
172     * @return void
173     */
174    public function process($results)
175    {
176        $this->results = $results;
177    }
178
179    /**
180     * Get terms related to the query.
181     *
182     * @return array
183     */
184    public function getResults()
185    {
186        if (empty($this->strategies)) {
187            throw new \Exception('LibGuidesAPI.ini must define at least one strategy if LibGuidesProfile is used.');
188        }
189
190        // Consider strategies in the order listed in the config file.
191        foreach ($this->strategies as $strategy) {
192            // Sanitize the strategy name.
193            $strategy = preg_replace('/[^\w]/', '', $strategy);
194
195            $method = 'findBestMatchBy' . $strategy;
196            if (
197                method_exists($this, $method) &&
198                $account = $this->$method($this->results)
199            ) {
200                return $account;
201            }
202        }
203        return false;
204    }
205
206    /**
207     * Find the LibGuides account whose profile best matches the
208     * call number facets in the given search results.
209     *
210     * Adapted from Demian Katz: https://gist.github.com/demiankatz/4600bdfb9af9882ad491f74c406a8a8a#file-guide-php-L308
211     *
212     * @param \VuFind\Search\Base\Results $results Search results object
213     *
214     * @return array LibGuides account
215     */
216    protected function findBestMatchByCallNumber($results)
217    {
218        // Skip if no call number mapping was provided by config.
219        if (empty($this->callNumberToAlias)) {
220            return false;
221        }
222
223        // Get the Call Number facet list.
224        $filter = [
225            $this->callNumberField => 'Call Number',
226        ];
227        $facets = $results->getFacetList($filter);
228
229        // For each call number facet.
230        $profiles = [];
231        foreach ($facets[$this->callNumberField]['list'] ?? [] as $current) {
232            $callNumber = trim(substr($current['value'], 0, $this->callNumberLength));
233
234            // Find an alias for this call number, or a broader call number if none is found.
235            while (strlen($callNumber > 0) && !isset($this->callNumberToAlias[$callNumber])) {
236                $callNumber = substr($callNumber, 0, strlen($callNumber) - 1);
237            }
238
239            // Add "match value" to that alias based on the result count with that call number.
240            if (isset($this->callNumberToAlias[$callNumber])) {
241                $alias = $this->callNumberToAlias[$callNumber];
242                if (!isset($profiles[$alias])) {
243                    $profiles[$alias] = 0;
244                }
245                $profiles[$alias] += $current['count'];
246            }
247        }
248
249        // Identify the alias with the highest match value
250        arsort($profiles);
251        if (empty($profiles)) {
252            return false;
253        }
254        $alias = array_key_first($profiles);
255
256        // Return the profile for that alias.
257        $accountId = $this->aliasToAccountId[$alias] ?? null;
258        if (!$accountId) {
259            return false;
260        }
261        $idToAccount = $this->getLibGuidesData()['idToAccount'];
262        $account = $idToAccount[$accountId];
263        return $account;
264    }
265
266    /**
267     * Find the LibGuides account whose subject expertise in their
268     * profile best matches the given query.
269     *
270     * @param \VuFind\Search\Base\Results $results Search results object
271     *
272     * @return array LibGuides account
273     */
274    protected function findBestMatchBySubject($results)
275    {
276        $data = $this->getLibGuidesData();
277        $subjectToId = $data['subjectToId'];
278        $idToAccount = $data['idToAccount'];
279
280        $query = $results->getParams()->getQuery();
281        $queryString = $query->getAllTerms();
282        if (!$queryString) {
283            return false;
284        }
285        $queryString = strtolower($queryString);
286
287        // Find the closest levenshtein match.
288        $minDistance = PHP_INT_MAX;
289        $subjects = array_keys($subjectToId);
290        $id = null;
291        foreach ($subjects as $subject) {
292            $distance = levenshtein($subject, $queryString);
293            if ($distance < $minDistance) {
294                $id = $subjectToId[$subject];
295                $minDistance = $distance;
296            }
297        }
298        if ($id == null) {
299            return false;
300        }
301
302        $account = $idToAccount[$id];
303        if (!$account) {
304            return false;
305        }
306
307        return $account;
308    }
309
310    /**
311     * Load or retrieve from the cache the list of LibGuides accounts
312     * from the LibGuides API.
313     *
314     * @return array An array containing the idToAccount and subjectToId maps
315     */
316    protected function getLibGuidesData()
317    {
318        $idToAccount = $this->getCachedData('libGuidesProfile-idToAccount');
319        $subjectToId = $this->getCachedData('libGuidesProfile-subjectToId');
320        if (!empty($idToAccount) && !empty($subjectToId)) {
321            return [
322                'idToAccount' => $idToAccount,
323                'subjectToId' => $subjectToId,
324            ];
325        }
326
327        return $this->populateLibGuidesCache();
328    }
329
330    /**
331     * Load the list of LibGuides accounts from the LibGuides API.
332     *
333     * @return array An array containing the idToAccount and subjectToId maps
334     */
335    protected function populateLibGuidesCache()
336    {
337        $idToAccount = [];
338        $subjectToId = [];
339        $accounts = $this->libGuides->getAccounts();
340        foreach ($accounts ?? [] as $account) {
341            $id = $account->id;
342            $idToAccount[$id] = $account;
343
344            foreach ($account->subjects ?? [] as $subject) {
345                $subjectName = strtolower($subject->name);
346
347                // Yes, this will override any previous account ID with the same subject.
348                // Could be modified if someone has a library with more than one librarian
349                // linked to the same Subject Guide if they have some way to decide who to display.
350                $subjectToId[$subjectName] = $id;
351            }
352        }
353
354        $this->putCachedData('libGuidesProfile-idToAccount', $idToAccount);
355        $this->putCachedData('libGuidesProfile-subjectToId', $subjectToId);
356        return [
357            'idToAccount' => $idToAccount,
358            'subjectToId' => $subjectToId,
359        ];
360    }
361}