Passed
Push — master ( 1ab329...3a819d )
by Andreas
09:51
created

midcom_helper_nav::get_node()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 1
dl 0
loc 3
ccs 2
cts 2
cp 1
crap 1
rs 10
c 0
b 0
f 0
1
<?php
2
/**
3
 * @package midcom.helper
4
 * @author The Midgard Project, http://www.midgard-project.org
5
 * @copyright The Midgard Project, http://www.midgard-project.org
6
 * @license http://www.gnu.org/licenses/lgpl.html GNU Lesser General Public License
7
 */
8
9
/**
10
 * Main Navigation interface class.
11
 *
12
 * Basically, this class proxies all requests to a midcom_helper_nav_backend
13
 * class. See the interface definition of it for further details.
14
 *
15
 * Additionally this class implements a couple of helper functions to make
16
 * common NAP tasks easier.
17
 *
18
 * <b>Important note:</b> Whenever you add new code to this class, or extend it through
19
 * inheritance, never call the proxy-functions of the backend directly, this is strictly
20
 * forbidden.
21
 *
22
 * @todo End-User documentation of node and leaf data, as the one in the backend is incomplete too.
23
 * @package midcom.helper
24
 * @see midcom_helper_nav_backend
25
 */
26
class midcom_helper_nav
27
{
28
    private midcom_helper_nav_backend $_backend;
29
30
    /**
31
     * @var midcom_helper_nav_backend[]
32
     */
33
    private static array $_backends = [];
34
35
    private midcom_core_context $context;
36
37
    /**
38
     * Create a NAP instance for the currently active context
39
     */
40 453
    public function __construct()
41
    {
42 453
        $this->context = midcom_core_context::get();
43 453
        $this->_backend = $this->_get_backend();
44
    }
45
46
    /**
47
     * This function maintains one NAP Class per context. Usually this is enough,
48
     * since you mostly will access it in context 0, the default. The problem is, that
49
     * this is not 100% efficient: If you instantiate two different NAP Classes in
50
     * different contexts both referring to the same root node, you will get two
51
     * different instances.
52
     *
53
     * @see midcom_helper_nav
54
     */
55 453
    private function _get_backend() : midcom_helper_nav_backend
56
    {
57 453
        if (!isset(self::$_backends[$this->context->id])) {
58 286
            $root = $this->context->get_key(MIDCOM_CONTEXT_ROOTTOPIC);
59 286
            $urltopics = $this->context->get_key(MIDCOM_CONTEXT_URLTOPICS);
60 286
            self::$_backends[$this->context->id] = new midcom_helper_nav_backend($root, $urltopics);
0 ignored issues
show
Bug introduced by
It seems like $root can also be of type false; however, parameter $root of midcom_helper_nav_backend::__construct() does only seem to accept midcom_db_topic, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

60
            self::$_backends[$this->context->id] = new midcom_helper_nav_backend(/** @scrutinizer ignore-type */ $root, $urltopics);
Loading history...
Bug introduced by
It seems like $urltopics can also be of type false; however, parameter $urltopics of midcom_helper_nav_backend::__construct() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

60
            self::$_backends[$this->context->id] = new midcom_helper_nav_backend($root, /** @scrutinizer ignore-type */ $urltopics);
Loading history...
61
        }
62
63 453
        return self::$_backends[$this->context->id];
64
    }
65
66
    /* The following methods are just interfaces to midcom_helper_nav_backend */
67
68
    /**
69
     * Retrieve the ID of the currently displayed node. Defined by the topic of
70
     * the component that declared able to handle the request.
71
     *
72
     * @see midcom_helper_nav_backend::get_current_node()
73
     */
74 240
    public function get_current_node() : int
75
    {
76 240
        return $this->_backend->get_current_node();
77
    }
78
79
    /**
80
     * Retrieve the ID of the currently displayed leaf. This is a leaf that is
81
     * displayed by the handling topic. If no leaf is active, this function
82
     * returns null. (Remember to make a type sensitive check, e.g.
83
     * nav::get_current_leaf() !== null to distinguish '0' and 'null'.)
84
     *
85
     * @see midcom_helper_nav_backend::get_current_leaf()
86
     */
87 257
    public function get_current_leaf() : ?string
88
    {
89 257
        return $this->_backend->get_current_leaf();
90
    }
91
92
    /**
93
     * Retrieve the ID of the root node. Note that this ID is dependent from the
94
     * ID of the MidCOM Root topic and therefore will change as easily as the
95
     * root topic ID might. The MIDCOM_NAV_URL entry of the root node's data will
96
     * always be empty.
97
     *
98
     * @see midcom_helper_nav_backend::get_root_node()
99
     */
100 97
    public function get_root_node() : int
101
    {
102 97
        return $this->_backend->get_root_node();
103
    }
104
105
    /**
106
     * Lists all Sub-nodes of $parent_node. If there are no subnodes you will get
107
     * an empty array
108
     *
109
     * @param boolean $show_noentry Show all objects on-site which have the noentry flag set.
110
     *     This defaults to false.
111
     * @see midcom_helper_nav_backend::list_nodes()
112
     */
113 12
    public function get_nodes(int $parent_node_id, bool $show_noentry = false) : array
114
    {
115 12
        return array_map([$this, 'get_node'], $this->_backend->list_nodes($parent_node_id, $show_noentry));
116
    }
117
118
    /**
119
     * Lists all leaves of $parent_node. If there are no leaves you will get an
120
     * empty array.
121
     *
122
     * @param boolean $show_noentry Show all objects on-site which have the noentry flag set.
123
     *     This defaults to false.
124
     * @see midcom_helper_nav_backend::list_leaves()
125
     */
126 17
    public function get_leaves(int $parent_node_id, bool $show_noentry = false) : array
127
    {
128 17
        return array_map([$this, 'get_leaf'], $this->_backend->list_leaves($parent_node_id, $show_noentry));
129
    }
130
131
    /**
132
     * This will give you a key-value pair describing the node with the ID
133
     * $node_id. The defined keys are described above in Node data interchange
134
     * format. You will get false if the node ID is invalid.
135
     *
136
     * @see midcom_helper_nav_backend::get_node()
137
     */
138 326
    public function get_node($node_id) : ?array
139
    {
140 326
        return $this->_backend->get_node($node_id);
141
    }
142
143
    /**
144
     * This will give you a key-value pair describing the leaf with the ID
145
     * $node_id. The defined keys are described above in leaf data interchange
146
     * format. You will get false if the leaf ID is invalid.
147
     *
148
     * @see midcom_helper_nav_backend::get_leaf()
149
     */
150 40
    public function get_leaf(string $leaf_id) : ?array
151
    {
152 40
        return $this->_backend->get_leaf($leaf_id);
153
    }
154
155
    /**
156
     * Checks if the given node is within the tree of another node.
157
     *
158
     * @param int    $node_id    The node in question.
159
     * @param int    $root_id    The root node to use.
160
     */
161 78
    public function is_node_in_tree($node_id, $root_id) : bool
162
    {
163 78
        if ($node_id == $root_id) {
164 24
            return true;
165
        }
166 73
        $uplink = $this->get_node($node_id)[MIDCOM_NAV_NODEID] ?? false;
167
168 73
        if (in_array($uplink, [false, -1])) {
169 55
            return false;
170
        }
171 24
        return $this->is_node_in_tree($uplink, $root_id);
0 ignored issues
show
Bug introduced by
It seems like $uplink can also be of type false; however, parameter $node_id of midcom_helper_nav::is_node_in_tree() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

171
        return $this->is_node_in_tree(/** @scrutinizer ignore-type */ $uplink, $root_id);
Loading history...
172
    }
173
174
    /**
175
     * List all child elements, nodes and leaves alike, of the node with ID
176
     * $parent_node_id. For every child element, an array of ID and type (node/leaf)
177
     * is given as
178
     *
179
     * - MIDCOM_NAV_ID => 0,
180
     * - MIDCOM_NAV_TYPE => 'node'
181
     *
182
     * If there are no child elements at all the method will return an empty array,
183
     * in case of an error false.  NOTE: This method should be quite slow, there's
184
     * room for improvement... :-)
185
     */
186 9
    public function list_child_elements(int $parent_node_id) : ?array
187
    {
188 9
        if ($parent_node = $this->get_node($parent_node_id)) {
189 9
            $nav_object = midcom_helper_nav_itemlist::factory($this, $parent_node);
190 9
            return $nav_object->get_sorted_list();
191
        }
192
        return null;
193
    }
194
195
    /**
196
     * Try to resolve a guid into a NAP object.
197
     *
198
     * The code is optimized trying to avoid a full-scan if possible. To do this it
199
     * will treat topic and article guids specially: In both cases the system will
200
     * translate it using the topic id into a node id and scan only that part of the
201
     * tree non-recursively.
202
     *
203
     * A full scan of the NAP data is only done if another MidgardObject is used.
204
     *
205
     * Note: If you want to resolve a GUID you got from a Permalink, use the Permalinks
206
     * service within MidCOM, as it covers more objects than the NAP listings.
207
     *
208
     * @param boolean $node_is_sufficient if we could return a good guess of correct parent node but said node does not list the $guid in leaves return the node or try to do a full (and very expensive) NAP scan ?
209
     * @return ?array Either a node or leaf structure, distinguishable by MIDCOM_NAV_TYPE, or null on failure.
210
     * @see midcom_services_permalinks
211
     */
212 28
    public function resolve_guid(string $guid, bool $node_is_sufficient = false) : ?array
213
    {
214
        // First, check if the GUID is already known by the backend:
215 28
        if ($cached_result = $this->_backend->get_loaded_object_by_guid($guid)) {
216 1
            debug_add('The GUID was already known by the backend instance, returning the cached copy directly.');
217 1
            return $cached_result;
218
        }
219
220
        // Fetch the object in question for a start, so that we know what to do (tm)
221
        // Note, that objects that cannot be resolved will still be processed using a full-scan of
222
        // the tree. This is, for example, used by the on-delete cache invalidation.
223
        try {
224 27
            $object = midcom::get()->dbfactory->get_object_by_guid($guid);
225
        } catch (midcom_error $e) {
226
            debug_add("Could not load GUID {$guid}, trying to continue anyway. Last error was: " . $e->getMessage(), MIDCOM_LOG_WARN);
227
        }
228 27
        if (!empty($object)) {
229 27
            if ($object instanceof midcom_db_topic) {
230
                // Ok. This topic should be within the content tree,
231
                // we check this and return the node if everything is ok.
232 20
                if (!$this->is_node_in_tree($object->id, $this->get_root_node())) {
233 18
                    debug_add("The GUID {$guid} leads to an unknown topic not in our tree.", MIDCOM_LOG_WARN);
234 18
                    return null;
235
                }
236
237 2
                return $this->get_node($object->id);
238
            }
239
240 8
            if ($object instanceof midcom_db_article) {
241
                // Ok, let's try to find the article using the topic in the tree.
242
                if (!$this->is_node_in_tree($object->topic, $this->get_root_node())) {
243
                    debug_add("The GUID {$guid} leads to an unknown topic not in our tree.", MIDCOM_LOG_WARN);
244
                    return null;
245
                }
246
                if ($leaf = $this->_find_leaf_in_topic($object->topic, $guid)) {
247
                    return $leaf;
248
                }
249
250
                debug_add("The Article GUID {$guid} is somehow hidden from the NAP data in its topic, no results shown.", MIDCOM_LOG_INFO);
251
                return null;
252
            }
253
254
            // Ok, unfortunately, this is not an immediate topic. We try to traverse
255
            // upwards in the object chain to find a topic.
256 8
            if ($topic = $this->find_closest_topic($object)) {
257
                debug_add("Found topic #{$topic->id}, searching the leaves");
258
                if ($leaf = $this->_find_leaf_in_topic($topic->id, $guid)) {
259
                    return $leaf;
260
                }
261
                if ($node_is_sufficient) {
262
                    debug_add("Could not find guid in leaves (maybe not listed?), but node is sufficient, returning node");
263
                    return $this->get_node($topic->id);
264
                }
265
            }
266
        }
267
268
        // this is the rest of the lot, we need to traverse everything, unfortunately.
269
        // First, we traverse a list of nodes to be checked on by one, avoiding a recursive
270
        // function call.
271 8
        $unprocessed_node_ids = [$this->get_root_node()];
272
273 8
        while (!empty($unprocessed_node_ids)) {
274 8
            $node_id = array_shift($unprocessed_node_ids);
275
276
            // Check leaves of this node first.
277 8
            if ($leaf = $this->_find_leaf_in_topic($node_id, $guid)) {
278
                return $leaf;
279
            }
280
281
            // Ok, append all subnodes to the queue.
282 8
            $unprocessed_node_ids = array_merge($unprocessed_node_ids, $this->_backend->list_nodes($node_id, false));
283
        }
284
285 8
        debug_add("We were unable to find the GUID {$guid} in the MidCOM tree even with a full scan.", MIDCOM_LOG_INFO);
286 8
        return null;
287
    }
288
289 8
    private function _find_leaf_in_topic(int $topic, string $guid) : ?array
290
    {
291 8
        foreach ($this->get_leaves($topic, true) as $leaf) {
292
            if ($leaf[MIDCOM_NAV_GUID] == $guid) {
293
                return $leaf;
294
            }
295
        }
296 8
        return null;
297
    }
298
299 275
    public function find_closest_topic(midcom_core_dbaobject $object) : ?midcom_db_topic
300
    {
301 275
        debug_add('Looking for a topic to use via get_parent()');
302 275
        while ($parent = $object->get_parent()) {
303
            // Verify that this topic is within the current sites tree, if it is not,
304
            // we ignore it.
305 139
            if (   $parent instanceof midcom_db_topic
306 139
                && $this->is_node_in_tree($parent->id, $this->get_root_node())) {
307 22
                return $parent;
308
            }
309 130
            $object = $parent;
310
        }
311 254
        return null;
312
    }
313
314
    /* The more complex interface methods starts here */
315
316
    /**
317
     * Construct a breadcrumb line.
318
     *
319
     * Gives you a line like 'Start > Topic1 > Topic2 > Article' using NAP to
320
     * traverse upwards till the root node. $separator is inserted between the
321
     * pairs, $class, if non-null, will be used as CSS-class for the A-Tags.
322
     *
323
     * The parameter skip_levels indicates how much nodes should be skipped at
324
     * the beginning of the current path. Default is to show the complete path. A
325
     * value of 1 will skip the home link, 2 will skip the home link and the first
326
     * subtopic and so on. If a leaf or node is selected, that normally would be
327
     * hidden, only its name will be shown.
328
     *
329
     * @param string    $separator        The separator to use between the elements.
330
     * @param string    $class            If not-null, it will be assigned to all A tags.
331
     * @param int       $skip_levels      The number of topic levels to skip before starting to work (use this to skip 'Home' links etc.).
332
     * @param string    $current_class    The class that should be assigned to the currently active element.
333
     */
334 10
    public function get_breadcrumb_line(string $separator = ' &gt; ', string $class = null, int $skip_levels = 0, string $current_class = null, array $skip_guids = []) : string
335
    {
336 10
        $breadcrumb_data = $this->get_breadcrumb_data();
337 10
        $result = '';
338
339
        // Detect real starting Node
340 10
        if ($skip_levels > 0) {
341 3
            if ($skip_levels >= count($breadcrumb_data)) {
342
                debug_add('We were asked to skip all breadcrumb elements that were present (or even more). Returning an empty breadcrumb line therefore.', MIDCOM_LOG_INFO);
343
                return $result;
344
            }
345 3
            $breadcrumb_data = array_slice($breadcrumb_data, $skip_levels);
346
        }
347
348 10
        $class = $class === null ? '' : ' class="' . $class . '"';
349 10
        while (current($breadcrumb_data) !== false) {
350 10
            $data = current($breadcrumb_data);
351 10
            $entry = htmlspecialchars($data[MIDCOM_NAV_NAME]);
352
353
            // Add the next element sensitive to the fact whether we are at the end or not.
354 10
            if (next($breadcrumb_data) === false) {
355 10
                if ($current_class !== null) {
356 10
                    $entry = "<span class=\"{$current_class}\">{$entry}</span>";
357
                }
358
            } else {
359 9
                if (   !empty($data['napobject'][MIDCOM_NAV_GUID])
360 9
                    && in_array($data['napobject'][MIDCOM_NAV_GUID], $skip_guids)) {
361
                    continue;
362
                }
363
364 9
                $entry = "<a href=\"{$data[MIDCOM_NAV_URL]}\"{$class}>{$entry}</a>{$separator}";
365
            }
366 10
            $result .= $entry;
367
        }
368
369 10
        return $result;
370
    }
371
372
    /**
373
     * Construct source data for a breadcrumb line.
374
     *
375
     * Gives you the data needed to construct a line like
376
     * 'Start > Topic1 > Topic2 > Article' using NAP to
377
     * traverse upwards till the root node. The components custom breadcrumb
378
     * data is inserted at the end of the computed breadcrumb line after any
379
     * set NAP leaf.
380
     *
381
     * See get_breadcrumb_line for a more end-user oriented way of life.
382
     *
383
     * <b>Return Value</b>
384
     *
385
     * The breadcrumb data will be returned as a list of associative arrays each
386
     * containing these keys:
387
     *
388
     * - MIDCOM_NAV_URL The fully qualified URL to the node.
389
     * - MIDCOM_NAV_NAME The clear-text name of the node.
390
     * - MIDCOM_NAV_TYPE One of 'node', 'leaf', 'custom' indicating what type of entry
391
     *   this is.
392
     * - MIDCOM_NAV_ID The Identifier of the structure used to build this entry, this is
393
     *   either a NAP node/leaf ID or the list key set by the component for custom data.
394
     * - 'napobject' This contains the original NAP object retrieved by the function.
395
     *   Just in case you need more information than is available directly.
396
     *
397
     * The entry of every level is indexed by its MIDCOM_NAV_ID, where custom keys preserve
398
     * their original key (as passed by the component) and prefixing it with 'custom-'. This
399
     * allows you to easily check if a given node/leave is within the current breadcrumb-line
400
     * by checking with array_key_exists.
401
     *
402
     * <b>Adding custom data</b>
403
     *
404
     * Custom elements are added to this array by using the MidCOM custom component context
405
     * at this time. You need to add a list with the same structure as above into the
406
     * custom component context key <i>midcom.helper.nav.breadcrumb</i>. (This needs
407
     * to be an array always, even if you return only one element.)
408
     *
409
     * Note, that the URL you pass in that list is always prepended with the current anchor
410
     * prefix. It is not possible to specify absolute URLs there. No leading slash is required.
411
     *
412
     * Example:
413
     *
414
     * <code>
415
     * $tmp = [
416
     *     [
417
     *         MIDCOM_NAV_URL => "list/{$this->_category}/{$this->_mode}/1/",
418
     *         MIDCOM_NAV_NAME => $this->_category_name,
419
     *     ],
420
     * ];
421
     * midcom_core_context::get()->set_custom_key('midcom.helper.nav.breadcrumb', $tmp);
422
     * </code>
423
     */
424 10
    public function get_breadcrumb_data($id = null) : array
425
    {
426 10
        $prefix = $this->context->get_key(MIDCOM_CONTEXT_ANCHORPREFIX);
427 10
        $result = [];
428
429 10
        if (!$id) {
430 10
            $curr_leaf = $this->get_current_leaf();
431 10
            $curr_node = $this->get_current_node();
432
        } else {
433
            $curr_leaf = false;
434
            $curr_node = -1;
435
436
            if ($leaf = $this->get_leaf($id)) {
437
                $curr_leaf = $leaf[MIDCOM_NAV_ID];
438
                $curr_node = $leaf[MIDCOM_NAV_NODEID];
439
            } elseif ($node = $this->get_node($id)) {
440
                $curr_node = $node[MIDCOM_NAV_ID];
441
            }
442
        }
443 10
        foreach ($this->get_node_path($curr_node) as $node_id) {
444 10
            $node = $this->get_node($node_id);
445 10
            $result[$node[MIDCOM_NAV_ID]] = [
446 10
                MIDCOM_NAV_URL => $node[MIDCOM_NAV_ABSOLUTEURL],
447 10
                MIDCOM_NAV_NAME => $node[MIDCOM_NAV_NAME],
448 10
                MIDCOM_NAV_TYPE => 'node',
449 10
                MIDCOM_NAV_ID => $node_id,
450 10
                'napobject' => $node,
451 10
            ];
452
        }
453 10
        if ($curr_leaf && $leaf = $this->get_leaf($curr_leaf)) {
454
            // Ignore Index Article Leaves
455
            if ($leaf[MIDCOM_NAV_URL] != '') {
456
                $result[$leaf[MIDCOM_NAV_ID]] = [
457
                    MIDCOM_NAV_URL => $leaf[MIDCOM_NAV_ABSOLUTEURL],
458
                    MIDCOM_NAV_NAME => $leaf[MIDCOM_NAV_NAME],
459
                    MIDCOM_NAV_TYPE => 'leaf',
460
                    MIDCOM_NAV_ID => $curr_leaf,
461
                    'napobject' => $leaf,
462
                ];
463
            }
464
        }
465
466 10
        if (midcom_core_context::get()->has_custom_key('midcom.helper.nav.breadcrumb')) {
467 7
            $customdata = midcom_core_context::get()->get_custom_key('midcom.helper.nav.breadcrumb');
468 7
            if (is_array($customdata)) {
469 7
                foreach ($customdata as $key => $entry) {
470 7
                    $id = "custom-{$key}";
471
472 7
                    $url = "{$prefix}{$entry[MIDCOM_NAV_URL]}";
473 7
                    if (   str_starts_with($entry[MIDCOM_NAV_URL], '/')
474 7
                        || preg_match('|^https?://|', $entry[MIDCOM_NAV_URL])) {
475 5
                        $url = $entry[MIDCOM_NAV_URL];
476
                    }
477
478 7
                    $result[$id] = [
479 7
                        MIDCOM_NAV_URL => $url,
480 7
                        MIDCOM_NAV_NAME => $entry[MIDCOM_NAV_NAME],
481 7
                        MIDCOM_NAV_TYPE => 'custom',
482 7
                        MIDCOM_NAV_ID => $id,
483 7
                        'napobject' => $entry,
484 7
                    ];
485
                }
486
            }
487
        }
488 10
        return $result;
489
    }
490
491
    /**
492
     * Retrieve the IDs of the nodes from the URL. First value at key 0 is
493
     * the root node ID, possible second value is the first subnode ID etc.
494
     * Contains only visible nodes (nodes which can be loaded).
495
     */
496 12
    public function get_node_path($node_id = null) : array
497
    {
498 12
        if ($node_id === null) {
499 9
            return $this->_backend->get_node_path();
500
        }
501 10
        $path = [];
502 10
        $node = $this->get_node($node_id);
503 10
        while ($node) {
504 10
            $path[] = $node[MIDCOM_NAV_ID];
505 10
            if ($node[MIDCOM_NAV_NODEID] === -1) {
506 10
                break;
507
            }
508 7
            $node = $this->get_node($node[MIDCOM_NAV_NODEID]);
509
        }
510 10
        return array_reverse($path);
511
    }
512
513
    /**
514
     * Retrieve the ID of the upper node of the currently displayed node.
515
     *
516
     * @return mixed    The ID of the node in question.
517
     */
518 1
    public function get_current_upper_node()
519
    {
520 1
        return $this->_backend->get_current_upper_node();
521
    }
522
}
523