Proyectos de Subversion Moodle

Rev

Rev 11 | | Comparar con el anterior | Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 1
<?php
2
// This file is part of Moodle - http://moodle.org/
3
//
4
// Moodle is free software: you can redistribute it and/or modify
5
// it under the terms of the GNU General Public License as published by
6
// the Free Software Foundation, either version 3 of the License, or
7
// (at your option) any later version.
8
//
9
// Moodle is distributed in the hope that it will be useful,
10
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
// GNU General Public License for more details.
13
//
14
// You should have received a copy of the GNU General Public License
15
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
16
 
17
/**
18
 * Library functions for managing text filter plugins.
19
 *
20
 * @package   core
21
 * @copyright 1999 onwards Martin Dougiamas  {@link http://moodle.com}
22
 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23
 */
24
 
25
/** The states a filter can be in, stored in the filter_active table. */
26
define('TEXTFILTER_ON', 1);
27
/** The states a filter can be in, stored in the filter_active table. */
28
define('TEXTFILTER_INHERIT', 0);
29
/** The states a filter can be in, stored in the filter_active table. */
30
define('TEXTFILTER_OFF', -1);
31
/** The states a filter can be in, stored in the filter_active table. */
32
define('TEXTFILTER_DISABLED', -9999);
33
 
34
/**
35
 * Define one exclusive separator that we'll use in the temp saved tags
36
 *  keys. It must be something rare enough to avoid having matches with
37
 *  filterobjects. MDL-18165
38
 */
39
define('TEXTFILTER_EXCL_SEPARATOR', chr(0x1F) . '%' . chr(0x1F));
40
 
41
 
42
/**
43
 * Look up the name of this filter
44
 *
45
 * @param string $filter the filter name
46
 * @return string the human-readable name for this filter.
47
 */
48
function filter_get_name($filter) {
49
    if (strpos($filter, 'filter/') === 0) {
50
        debugging("Old '$filter'' parameter used in filter_get_name()");
51
        $filter = substr($filter, 7);
52
    } else if (strpos($filter, '/') !== false) {
53
        throw new coding_exception('Unknown filter type ' . $filter);
54
    }
55
 
56
    if (get_string_manager()->string_exists('filtername', 'filter_' . $filter)) {
57
        return get_string('filtername', 'filter_' . $filter);
58
    } else {
59
        return $filter;
60
    }
61
}
62
 
63
/**
64
 * Get the names of all the filters installed in this Moodle.
65
 *
66
 * @return array path => filter name from the appropriate lang file. e.g.
67
 * array('tex' => 'TeX Notation');
68
 * sorted in alphabetical order of name.
69
 */
70
function filter_get_all_installed() {
71
    $filternames = array();
72
    foreach (core_component::get_plugin_list('filter') as $filter => $fulldir) {
1441 ariadna 73
        if (class_exists("\\filter_{$filter}\\text_filter") || is_readable("$fulldir/filter.php")) {
1 efrain 74
            $filternames[$filter] = filter_get_name($filter);
75
        }
76
    }
77
    core_collator::asort($filternames);
78
    return $filternames;
79
}
80
 
81
/**
82
 * Set the global activated state for a text filter.
83
 *
84
 * @param string $filtername The filter name, for example 'tex'.
85
 * @param int $state One of the values TEXTFILTER_ON, TEXTFILTER_OFF or TEXTFILTER_DISABLED.
86
 * @param int $move -1 means up, 0 means the same, 1 means down
87
 */
88
function filter_set_global_state($filtername, $state, $move = 0) {
89
    global $DB;
90
 
91
    // Check requested state is valid.
92
    if (!in_array($state, array(TEXTFILTER_ON, TEXTFILTER_OFF, TEXTFILTER_DISABLED))) {
93
        throw new coding_exception("Illegal option '$state' passed to filter_set_global_state. " .
94
                "Must be one of TEXTFILTER_ON, TEXTFILTER_OFF or TEXTFILTER_DISABLED.");
95
    }
96
 
97
    if ($move > 0) {
98
        $move = 1;
99
    } else if ($move < 0) {
100
        $move = -1;
101
    }
102
 
103
    if (strpos($filtername, 'filter/') === 0) {
104
        $filtername = substr($filtername, 7);
105
    } else if (strpos($filtername, '/') !== false) {
106
        throw new coding_exception("Invalid filter name '$filtername' used in filter_set_global_state()");
107
    }
108
 
109
    $transaction = $DB->start_delegated_transaction();
110
 
111
    $syscontext = context_system::instance();
112
    $filters = $DB->get_records('filter_active', array('contextid' => $syscontext->id), 'sortorder ASC');
113
 
114
    $on = array();
115
    $off = array();
116
 
117
    foreach ($filters as $f) {
118
        if ($f->active == TEXTFILTER_DISABLED) {
119
            $off[$f->filter] = $f;
120
        } else {
121
            $on[$f->filter] = $f;
122
        }
123
    }
124
 
125
    // Update the state or add new record.
126
    if (isset($on[$filtername])) {
127
        $filter = $on[$filtername];
128
        if ($filter->active != $state) {
129
            add_to_config_log('filter_active', $filter->active, $state, $filtername);
130
 
131
            $filter->active = $state;
132
            $DB->update_record('filter_active', $filter);
133
            if ($filter->active == TEXTFILTER_DISABLED) {
134
                unset($on[$filtername]);
135
                $off = array($filter->filter => $filter) + $off;
136
            }
137
 
138
        }
139
 
140
    } else if (isset($off[$filtername])) {
141
        $filter = $off[$filtername];
142
        if ($filter->active != $state) {
143
            add_to_config_log('filter_active', $filter->active, $state, $filtername);
144
 
145
            $filter->active = $state;
146
            $DB->update_record('filter_active', $filter);
147
            if ($filter->active != TEXTFILTER_DISABLED) {
148
                unset($off[$filtername]);
149
                $on[$filter->filter] = $filter;
150
            }
151
        }
152
 
153
    } else {
154
        add_to_config_log('filter_active', '', $state, $filtername);
155
 
156
        $filter = new stdClass();
157
        $filter->filter    = $filtername;
158
        $filter->contextid = $syscontext->id;
159
        $filter->active    = $state;
160
        $filter->sortorder = 99999;
161
        $filter->id = $DB->insert_record('filter_active', $filter);
162
 
163
        $filters[$filter->id] = $filter;
164
        if ($state == TEXTFILTER_DISABLED) {
165
            $off[$filter->filter] = $filter;
166
        } else {
167
            $on[$filter->filter] = $filter;
168
        }
169
    }
170
 
171
    // Move only active.
172
    if ($move != 0 and isset($on[$filter->filter])) {
173
        // Capture the old order for logging.
174
        $oldorder = implode(', ', array_map(
175
                function($f) {
176
                    return $f->filter;
177
                }, $on));
178
 
179
        // Work out the new order.
180
        $i = 1;
181
        foreach ($on as $f) {
182
            $f->newsortorder = $i;
183
            $i++;
184
        }
185
 
186
        $filter->newsortorder = $filter->newsortorder + $move;
187
 
188
        foreach ($on as $f) {
189
            if ($f->id == $filter->id) {
190
                continue;
191
            }
192
            if ($f->newsortorder == $filter->newsortorder) {
193
                if ($move == 1) {
194
                    $f->newsortorder = $f->newsortorder - 1;
195
                } else {
196
                    $f->newsortorder = $f->newsortorder + 1;
197
                }
198
            }
199
        }
200
 
201
        core_collator::asort_objects_by_property($on, 'newsortorder', core_collator::SORT_NUMERIC);
202
 
203
        // Log in config_log.
204
        $neworder = implode(', ', array_map(
205
                function($f) {
206
                    return $f->filter;
207
                }, $on));
208
        add_to_config_log('order', $oldorder, $neworder, 'core_filter');
209
    }
210
 
211
    // Inactive are sorted by filter name.
212
    core_collator::asort_objects_by_property($off, 'filter', core_collator::SORT_NATURAL);
213
 
214
    // Update records if necessary.
215
    $i = 1;
216
    foreach ($on as $f) {
217
        if ($f->sortorder != $i) {
218
            $DB->set_field('filter_active', 'sortorder', $i, array('id' => $f->id));
219
        }
220
        $i++;
221
    }
222
    foreach ($off as $f) {
223
        if ($f->sortorder != $i) {
224
            $DB->set_field('filter_active', 'sortorder', $i, array('id' => $f->id));
225
        }
226
        $i++;
227
    }
228
 
229
    $transaction->allow_commit();
230
}
231
 
232
/**
233
 * Returns the active state for a filter in the given context.
234
 *
235
 * @param string $filtername The filter name, for example 'tex'.
236
 * @param integer $contextid The id of the context to get the data for.
237
 * @return int value of active field for the given filter.
238
 */
239
function filter_get_active_state(string $filtername, $contextid = null): int {
240
    global $DB;
241
 
242
    if ($contextid === null) {
243
        $contextid = context_system::instance()->id;
244
    }
245
    if (is_object($contextid)) {
246
        $contextid = $contextid->id;
247
    }
248
 
249
    if (strpos($filtername, 'filter/') === 0) {
250
        $filtername = substr($filtername, 7);
251
    } else if (strpos($filtername, '/') !== false) {
252
        throw new coding_exception("Invalid filter name '$filtername' used in filter_is_enabled()");
253
    }
254
    if ($active = $DB->get_field('filter_active', 'active', array('filter' => $filtername, 'contextid' => $contextid))) {
255
        return $active;
256
    }
257
 
258
    return TEXTFILTER_DISABLED;
259
}
260
 
261
/**
262
 * @param string $filtername The filter name, for example 'tex'.
263
 * @return boolean is this filter allowed to be used on this site. That is, the
264
 *      admin has set the global 'active' setting to On, or Off, but available.
265
 */
266
function filter_is_enabled($filtername) {
267
    if (strpos($filtername, 'filter/') === 0) {
268
        $filtername = substr($filtername, 7);
269
    } else if (strpos($filtername, '/') !== false) {
270
        throw new coding_exception("Invalid filter name '$filtername' used in filter_is_enabled()");
271
    }
272
    return array_key_exists($filtername, filter_get_globally_enabled());
273
}
274
 
275
/**
276
 * Return a list of all the filters that may be in use somewhere.
277
 *
278
 * @return array where the keys and values are both the filter name, like 'tex'.
279
 */
280
function filter_get_globally_enabled() {
281
    $cache = \cache::make_from_params(\cache_store::MODE_REQUEST, 'core_filter', 'global_filters');
282
    $enabledfilters = $cache->get('enabled');
283
    if ($enabledfilters !== false) {
284
        return $enabledfilters;
285
    }
286
 
287
    $filters = filter_get_global_states();
288
    $enabledfilters = array();
289
    foreach ($filters as $filter => $filerinfo) {
290
        if ($filerinfo->active != TEXTFILTER_DISABLED) {
291
            $enabledfilters[$filter] = $filter;
292
        }
293
    }
294
 
295
    $cache->set('enabled', $enabledfilters);
296
    return $enabledfilters;
297
}
298
 
299
/**
300
 * Get the globally enabled filters.
301
 *
302
 * This returns the filters which could be used in any context. Essentially
303
 * the filters which are not disabled for the entire site.
304
 *
305
 * @return array Keys are filter names, and values the config.
306
 */
307
function filter_get_globally_enabled_filters_with_config() {
308
    global $DB;
309
 
310
    $sql = "SELECT f.filter, fc.name, fc.value
311
              FROM {filter_active} f
312
         LEFT JOIN {filter_config} fc
313
                ON fc.filter = f.filter
314
               AND fc.contextid = f.contextid
315
             WHERE f.contextid = :contextid
316
               AND f.active != :disabled
317
          ORDER BY f.sortorder";
318
 
319
    $rs = $DB->get_recordset_sql($sql, [
320
        'contextid' => context_system::instance()->id,
321
        'disabled' => TEXTFILTER_DISABLED
322
    ]);
323
 
324
    // Massage the data into the specified format to return.
325
    $filters = array();
326
    foreach ($rs as $row) {
327
        if (!isset($filters[$row->filter])) {
328
            $filters[$row->filter] = array();
329
        }
330
        if ($row->name !== null) {
331
            $filters[$row->filter][$row->name] = $row->value;
332
        }
333
    }
334
    $rs->close();
335
 
336
    return $filters;
337
}
338
 
339
/**
340
 * Return the names of the filters that should also be applied to strings
341
 * (when they are enabled).
342
 *
343
 * @return array where the keys and values are both the filter name, like 'tex'.
344
 */
345
function filter_get_string_filters() {
346
    global $CFG;
347
    $stringfilters = array();
348
    if (!empty($CFG->filterall) && !empty($CFG->stringfilters)) {
349
        $stringfilters = explode(',', $CFG->stringfilters);
350
        $stringfilters = array_combine($stringfilters, $stringfilters);
351
    }
352
    return $stringfilters;
353
}
354
 
355
/**
356
 * Sets whether a particular active filter should be applied to all strings by
357
 * format_string, or just used by format_text.
358
 *
359
 * @param string $filter The filter name, for example 'tex'.
360
 * @param boolean $applytostrings if true, this filter will apply to format_string
361
 *      and format_text, when it is enabled.
362
 */
363
function filter_set_applies_to_strings($filter, $applytostrings) {
364
    $stringfilters = filter_get_string_filters();
365
    $prevfilters = $stringfilters;
366
    $allfilters = core_component::get_plugin_list('filter');
367
 
368
    if ($applytostrings) {
369
        $stringfilters[$filter] = $filter;
370
    } else {
371
        unset($stringfilters[$filter]);
372
    }
373
 
374
    // Remove missing filters.
375
    foreach ($stringfilters as $filter) {
376
        if (!isset($allfilters[$filter])) {
377
            unset($stringfilters[$filter]);
378
        }
379
    }
380
 
381
    if ($prevfilters != $stringfilters) {
382
        set_config('stringfilters', implode(',', $stringfilters));
383
        set_config('filterall', !empty($stringfilters));
384
    }
385
}
386
 
387
/**
388
 * Set the local activated state for a text filter.
389
 *
390
 * @param string $filter The filter name, for example 'tex'.
391
 * @param integer $contextid The id of the context to get the local config for.
392
 * @param integer $state One of the values TEXTFILTER_ON, TEXTFILTER_OFF or TEXTFILTER_INHERIT.
393
 * @return void
394
 */
395
function filter_set_local_state($filter, $contextid, $state) {
396
    global $DB;
397
 
398
    // Check requested state is valid.
399
    if (!in_array($state, array(TEXTFILTER_ON, TEXTFILTER_OFF, TEXTFILTER_INHERIT))) {
400
        throw new coding_exception("Illegal option '$state' passed to filter_set_local_state. " .
401
                "Must be one of TEXTFILTER_ON, TEXTFILTER_OFF or TEXTFILTER_INHERIT.");
402
    }
403
 
404
    if ($contextid == context_system::instance()->id) {
405
        throw new coding_exception('You cannot use filter_set_local_state ' .
406
                'with $contextid equal to the system context id.');
407
    }
408
 
409
    if ($state == TEXTFILTER_INHERIT) {
410
        $DB->delete_records('filter_active', array('filter' => $filter, 'contextid' => $contextid));
411
        return;
412
    }
413
 
414
    $rec = $DB->get_record('filter_active', array('filter' => $filter, 'contextid' => $contextid));
415
    $insert = false;
416
    if (empty($rec)) {
417
        $insert = true;
418
        $rec = new stdClass;
419
        $rec->filter = $filter;
420
        $rec->contextid = $contextid;
421
    }
422
 
423
    $rec->active = $state;
424
 
425
    if ($insert) {
426
        $DB->insert_record('filter_active', $rec);
427
    } else {
428
        $DB->update_record('filter_active', $rec);
429
    }
430
}
431
 
432
/**
433
 * Set a particular local config variable for a filter in a context.
434
 *
435
 * @param string $filter The filter name, for example 'tex'.
436
 * @param integer $contextid The id of the context to get the local config for.
437
 * @param string $name the setting name.
438
 * @param string $value the corresponding value.
439
 */
440
function filter_set_local_config($filter, $contextid, $name, $value) {
441
    global $DB;
442
    $rec = $DB->get_record('filter_config', array('filter' => $filter, 'contextid' => $contextid, 'name' => $name));
443
    $insert = false;
444
    if (empty($rec)) {
445
        $insert = true;
446
        $rec = new stdClass;
447
        $rec->filter = $filter;
448
        $rec->contextid = $contextid;
449
        $rec->name = $name;
450
    }
451
 
452
    $rec->value = $value;
453
 
454
    if ($insert) {
455
        $DB->insert_record('filter_config', $rec);
456
    } else {
457
        $DB->update_record('filter_config', $rec);
458
    }
459
}
460
 
461
/**
462
 * Remove a particular local config variable for a filter in a context.
463
 *
464
 * @param string $filter The filter name, for example 'tex'.
465
 * @param integer $contextid The id of the context to get the local config for.
466
 * @param string $name the setting name.
467
 */
468
function filter_unset_local_config($filter, $contextid, $name) {
469
    global $DB;
470
    $DB->delete_records('filter_config', array('filter' => $filter, 'contextid' => $contextid, 'name' => $name));
471
}
472
 
473
/**
474
 * Get local config variables for a filter in a context. Normally (when your
475
 * filter is running) you don't need to call this, becuase the config is fetched
476
 * for you automatically. You only need this, for example, when you are getting
477
 * the config so you can show the user an editing from.
478
 *
479
 * @param string $filter The filter name, for example 'tex'.
480
 * @param integer $contextid The ID of the context to get the local config for.
481
 * @return array of name => value pairs.
482
 */
483
function filter_get_local_config($filter, $contextid) {
484
    global $DB;
485
    return $DB->get_records_menu('filter_config', array('filter' => $filter, 'contextid' => $contextid), '', 'name,value');
486
}
487
 
488
/**
489
 * This function is for use by backup. Gets all the filter information specific
490
 * to one context.
491
 *
492
 * @param int $contextid
493
 * @return array Array with two elements. The first element is an array of objects with
494
 *      fields filter and active. These come from the filter_active table. The
495
 *      second element is an array of objects with fields filter, name and value
496
 *      from the filter_config table.
497
 */
498
function filter_get_all_local_settings($contextid) {
499
    global $DB;
500
    return array(
501
        $DB->get_records('filter_active', array('contextid' => $contextid), 'filter', 'filter,active'),
502
        $DB->get_records('filter_config', array('contextid' => $contextid), 'filter,name', 'filter,name,value'),
503
    );
504
}
505
 
506
/**
507
 * Get the list of active filters, in the order that they should be used
508
 * for a particular context, along with any local configuration variables.
509
 *
510
 * @param context $context a context
511
 * @return array an array where the keys are the filter names, for example
512
 *      'tex' and the values are any local
513
 *      configuration for that filter, as an array of name => value pairs
514
 *      from the filter_config table. In a lot of cases, this will be an
515
 *      empty array. So, an example return value for this function might be
516
 *      array(tex' => array())
517
 */
518
function filter_get_active_in_context($context) {
519
    global $DB, $FILTERLIB_PRIVATE;
520
 
521
    if (!isset($FILTERLIB_PRIVATE)) {
522
        $FILTERLIB_PRIVATE = new stdClass();
523
    }
524
 
525
    // Use cache (this is a within-request cache only) if available. See
526
    // function filter_preload_activities.
527
    if (isset($FILTERLIB_PRIVATE->active) &&
528
            array_key_exists($context->id, $FILTERLIB_PRIVATE->active)) {
529
        return $FILTERLIB_PRIVATE->active[$context->id];
530
    }
531
 
532
    $contextids = str_replace('/', ',', trim($context->path, '/'));
533
 
11 efrain 534
    // Postgres recordset performance is much better with a limit.
535
    // This should be much larger than anything needed in practice. The code below checks we don't hit this limit.
536
    $maxpossiblerows = 10000;
537
    // The key line in the following query is the HAVING clause.
538
    // If a filter is disabled at system context, then there is a row with active -9999 and depth 1,
539
    // so the -MIN is always large, and the MAX will be smaller than that and this filter won't be returned.
540
    // Otherwise, there will be a bunch of +/-1s at various depths,
541
    // and this clause verifies there is a +1 that deeper than any -1.
542
    $rows = $DB->get_recordset_sql("
543
            SELECT active.filter, fc.name, fc.value
1 efrain 544
 
11 efrain 545
              FROM (
546
                    SELECT fa.filter, MAX(fa.sortorder) AS sortorder
547
                      FROM {filter_active} fa
548
                      JOIN {context} ctx ON fa.contextid = ctx.id
549
                     WHERE ctx.id IN ($contextids)
550
                  GROUP BY fa.filter
551
                    HAVING MAX(fa.active * ctx.depth) > -MIN(fa.active * ctx.depth)
552
                   ) active
553
         LEFT JOIN {filter_config} fc ON fc.filter = active.filter AND fc.contextid = ?
554
 
555
          ORDER BY active.sortorder
556
        ", [$context->id], 0, $maxpossiblerows);
557
 
1 efrain 558
    // Massage the data into the specified format to return.
11 efrain 559
    $filters = [];
560
    $rowcount = 0;
561
    foreach ($rows as $row) {
562
        $rowcount += 1;
1 efrain 563
        if (!isset($filters[$row->filter])) {
11 efrain 564
            $filters[$row->filter] = [];
1 efrain 565
        }
566
        if (!is_null($row->name)) {
567
            $filters[$row->filter][$row->name] = $row->value;
568
        }
569
    }
11 efrain 570
    $rows->close();
1 efrain 571
 
11 efrain 572
    if ($rowcount >= $maxpossiblerows) {
573
        // If this ever did happen, which seems essentially impossible, then it would lead to very subtle and
574
        // hard to understand bugs, so ensure it leads to an unmissable error.
575
        throw new coding_exception('Hit the row limit that should never be hit in filter_get_active_in_context.');
576
    }
1 efrain 577
 
578
    return $filters;
579
}
580
 
581
/**
582
 * Preloads the list of active filters for all activities (modules) on the course
583
 * using two database queries.
584
 *
585
 * @param course_modinfo $modinfo Course object from get_fast_modinfo
586
 */
587
function filter_preload_activities(course_modinfo $modinfo) {
588
    global $DB, $FILTERLIB_PRIVATE;
589
 
590
    if (!isset($FILTERLIB_PRIVATE)) {
591
        $FILTERLIB_PRIVATE = new stdClass();
592
    }
593
 
594
    // Don't repeat preload.
595
    if (!isset($FILTERLIB_PRIVATE->preloaded)) {
596
        $FILTERLIB_PRIVATE->preloaded = array();
597
    }
598
    if (!empty($FILTERLIB_PRIVATE->preloaded[$modinfo->get_course_id()])) {
599
        return;
600
    }
601
    $FILTERLIB_PRIVATE->preloaded[$modinfo->get_course_id()] = true;
602
 
603
    // Get contexts for all CMs.
604
    $cmcontexts = array();
605
    $cmcontextids = array();
606
    foreach ($modinfo->get_cms() as $cm) {
607
        $modulecontext = context_module::instance($cm->id);
608
        $cmcontextids[] = $modulecontext->id;
609
        $cmcontexts[] = $modulecontext;
610
    }
611
 
612
    // Get course context and all other parents.
613
    $coursecontext = context_course::instance($modinfo->get_course_id());
614
    $parentcontextids = explode('/', substr($coursecontext->path, 1));
615
    $allcontextids = array_merge($cmcontextids, $parentcontextids);
616
 
617
    // Get all filter_active rows relating to all these contexts.
618
    list ($sql, $params) = $DB->get_in_or_equal($allcontextids);
619
    $filteractives = $DB->get_records_select('filter_active', "contextid $sql", $params, 'sortorder');
620
 
621
    // Get all filter_config only for the cm contexts.
622
    list ($sql, $params) = $DB->get_in_or_equal($cmcontextids);
623
    $filterconfigs = $DB->get_records_select('filter_config', "contextid $sql", $params);
624
 
625
    // Note: I was a bit surprised that filter_config only works for the
626
    // most specific context (i.e. it does not need to be checked for course
627
    // context if we only care about CMs) however basede on code in
628
    // filter_get_active_in_context, this does seem to be correct.
629
 
630
    // Build course default active list. Initially this will be an array of
631
    // filter name => active score (where an active score >0 means it's active).
632
    $courseactive = array();
633
 
634
    // Also build list of filter_active rows below course level, by contextid.
635
    $remainingactives = array();
636
 
637
    // Array lists filters that are banned at top level.
638
    $banned = array();
639
 
640
    // Add any active filters in parent contexts to the array.
641
    foreach ($filteractives as $row) {
642
        $depth = array_search($row->contextid, $parentcontextids);
643
        if ($depth !== false) {
644
            // Find entry.
645
            if (!array_key_exists($row->filter, $courseactive)) {
646
                $courseactive[$row->filter] = 0;
647
            }
648
            // This maths copes with reading rows in any order. Turning on/off
649
            // at site level counts 1, at next level down 4, at next level 9,
650
            // then 16, etc. This means the deepest level always wins, except
651
            // against the -9999 at top level.
652
            $courseactive[$row->filter] +=
653
                ($depth + 1) * ($depth + 1) * $row->active;
654
 
655
            if ($row->active == TEXTFILTER_DISABLED) {
656
                $banned[$row->filter] = true;
657
            }
658
        } else {
659
            // Build list of other rows indexed by contextid.
660
            if (!array_key_exists($row->contextid, $remainingactives)) {
661
                $remainingactives[$row->contextid] = array();
662
            }
663
            $remainingactives[$row->contextid][] = $row;
664
        }
665
    }
666
 
667
    // Chuck away the ones that aren't active.
668
    foreach ($courseactive as $filter => $score) {
669
        if ($score <= 0) {
670
            unset($courseactive[$filter]);
671
        } else {
672
            $courseactive[$filter] = array();
673
        }
674
    }
675
 
676
    // Loop through the contexts to reconstruct filter_active lists for each
677
    // cm on the course.
678
    if (!isset($FILTERLIB_PRIVATE->active)) {
679
        $FILTERLIB_PRIVATE->active = array();
680
    }
681
    foreach ($cmcontextids as $contextid) {
682
        // Copy course list.
683
        $FILTERLIB_PRIVATE->active[$contextid] = $courseactive;
684
 
685
        // Are there any changes to the active list?
686
        if (array_key_exists($contextid, $remainingactives)) {
687
            foreach ($remainingactives[$contextid] as $row) {
688
                if ($row->active > 0 && empty($banned[$row->filter])) {
689
                    // If it's marked active for specific context, add entry
690
                    // (doesn't matter if one exists already).
691
                    $FILTERLIB_PRIVATE->active[$contextid][$row->filter] = array();
692
                } else {
693
                    // If it's marked inactive, remove entry (doesn't matter
694
                    // if it doesn't exist).
695
                    unset($FILTERLIB_PRIVATE->active[$contextid][$row->filter]);
696
                }
697
            }
698
        }
699
    }
700
 
701
    // Process all config rows to add config data to these entries.
702
    foreach ($filterconfigs as $row) {
703
        if (isset($FILTERLIB_PRIVATE->active[$row->contextid][$row->filter])) {
704
            $FILTERLIB_PRIVATE->active[$row->contextid][$row->filter][$row->name] = $row->value;
705
        }
706
    }
707
}
708
 
709
/**
710
 * List all of the filters that are available in this context, and what the
711
 * local and inherited states of that filter are.
712
 *
713
 * @param context $context a context that is not the system context.
714
 * @return array an array with filter names, for example 'tex'
715
 *      as keys. and and the values are objects with fields:
716
 *      ->filter filter name, same as the key.
717
 *      ->localstate TEXTFILTER_ON/OFF/INHERIT
718
 *      ->inheritedstate TEXTFILTER_ON/OFF - the state that will be used if localstate is set to TEXTFILTER_INHERIT.
719
 */
720
function filter_get_available_in_context($context) {
721
    global $DB;
722
 
723
    // The complex logic is working out the active state in the parent context,
724
    // so strip the current context from the list.
725
    $contextids = explode('/', trim($context->path, '/'));
726
    array_pop($contextids);
727
    $contextids = implode(',', $contextids);
728
    if (empty($contextids)) {
729
        throw new coding_exception('filter_get_available_in_context cannot be called with the system context.');
730
    }
731
 
732
    // The following SQL is tricky, in the same way at the SQL in filter_get_active_in_context.
733
    $sql = "SELECT parent_states.filter,
734
                CASE WHEN fa.active IS NULL THEN " . TEXTFILTER_INHERIT . "
735
                ELSE fa.active END AS localstate,
736
             parent_states.inheritedstate
737
         FROM (SELECT f.filter, MAX(f.sortorder) AS sortorder,
738
                    CASE WHEN MAX(f.active * ctx.depth) > -MIN(f.active * ctx.depth) THEN " . TEXTFILTER_ON . "
739
                    ELSE " . TEXTFILTER_OFF . " END AS inheritedstate
740
             FROM {filter_active} f
741
             JOIN {context} ctx ON f.contextid = ctx.id
742
             WHERE ctx.id IN ($contextids)
743
             GROUP BY f.filter
744
             HAVING MIN(f.active) > " . TEXTFILTER_DISABLED . "
745
         ) parent_states
746
         LEFT JOIN {filter_active} fa ON fa.filter = parent_states.filter AND fa.contextid = $context->id
747
         ORDER BY parent_states.sortorder";
748
    return $DB->get_records_sql($sql);
749
}
750
 
751
/**
752
 * This function is for use by the filter administration page.
753
 *
754
 * @return array 'filtername' => object with fields 'filter' (=filtername), 'active' and 'sortorder'
755
 */
756
function filter_get_global_states() {
757
    global $DB;
758
    $context = context_system::instance();
759
    return $DB->get_records('filter_active', array('contextid' => $context->id), 'sortorder', 'filter,active,sortorder');
760
}
761
 
762
/**
763
 * Retrieve all the filters and their states (including overridden ones in any context).
764
 *
765
 * @return array filters objects containing filter name, context, active state and sort order.
766
 */
767
function filter_get_all_states(): array {
768
    global $DB;
769
    return $DB->get_records('filter_active');
770
}
771
 
772
/**
773
 * Delete all the data in the database relating to a filter, prior to deleting it.
774
 *
775
 * @param string $filter The filter name, for example 'tex'.
776
 */
777
function filter_delete_all_for_filter($filter) {
778
    global $DB;
779
 
780
    unset_all_config_for_plugin('filter_' . $filter);
781
    $DB->delete_records('filter_active', array('filter' => $filter));
782
    $DB->delete_records('filter_config', array('filter' => $filter));
783
}
784
 
785
/**
786
 * Delete all the data in the database relating to a context, used when contexts are deleted.
787
 *
788
 * @param integer $contextid The id of the context being deleted.
789
 */
790
function filter_delete_all_for_context($contextid) {
791
    global $DB;
792
    $DB->delete_records('filter_active', array('contextid' => $contextid));
793
    $DB->delete_records('filter_config', array('contextid' => $contextid));
794
}
795
 
796
/**
797
 * Does this filter have a global settings page in the admin tree?
798
 * (The settings page for a filter must be called, for example, filtersettingfiltertex.)
799
 *
800
 * @param string $filter The filter name, for example 'tex'.
801
 * @return boolean Whether there should be a 'Settings' link on the config page.
802
 */
803
function filter_has_global_settings($filter) {
804
    global $CFG;
805
    $settingspath = $CFG->dirroot . '/filter/' . $filter . '/settings.php';
806
    if (is_readable($settingspath)) {
807
        return true;
808
    }
809
    $settingspath = $CFG->dirroot . '/filter/' . $filter . '/filtersettings.php';
810
    return is_readable($settingspath);
811
}
812
 
813
/**
814
 * Does this filter have local (per-context) settings?
815
 *
816
 * @param string $filter The filter name, for example 'tex'.
817
 * @return boolean Whether there should be a 'Settings' link on the manage filters in context page.
818
 */
819
function filter_has_local_settings($filter) {
820
    global $CFG;
821
    $settingspath = $CFG->dirroot . '/filter/' . $filter . '/filterlocalsettings.php';
822
    return is_readable($settingspath);
823
}
824
 
825
/**
826
 * Certain types of context (block and user) may not have local filter settings.
827
 * the function checks a context to see whether it may have local config.
828
 *
829
 * @param object $context a context.
830
 * @return boolean whether this context may have local filter settings.
831
 */
832
function filter_context_may_have_filter_settings($context) {
833
    return $context->contextlevel != CONTEXT_BLOCK && $context->contextlevel != CONTEXT_USER;
834
}
835
 
836
/**
837
 * Process phrases intelligently found within a HTML text (such as adding links).
838
 *
839
 * @param string $text            the text that we are filtering
840
 * @param filterobject[] $linkarray an array of filterobjects
841
 * @param array $ignoretagsopen   an array of opening tags that we should ignore while filtering
842
 * @param array $ignoretagsclose  an array of corresponding closing tags
843
 * @param bool $overridedefaultignore True to only use tags provided by arguments
844
 * @param bool $linkarrayalreadyprepared True to say that filter_prepare_phrases_for_filtering
845
 *      has already been called for $linkarray. Default false.
846
 * @return string
847
 */
848
function filter_phrases($text, $linkarray, $ignoretagsopen = null, $ignoretagsclose = null,
849
        $overridedefaultignore = false, $linkarrayalreadyprepared = false) {
850
 
851
    global $CFG;
852
 
853
    // Used if $CFG->filtermatchoneperpage is on. Array with keys being the workregexp
854
    // for things that have already been matched on this page.
855
    static $usedphrases = [];
856
 
857
    $ignoretags = array();  // To store all the enclosing tags to be completely ignored.
858
    $tags = array();        // To store all the simple tags to be ignored.
859
 
860
    if (!$linkarrayalreadyprepared) {
861
        $linkarray = filter_prepare_phrases_for_filtering($linkarray);
862
    }
863
 
864
    if (!$overridedefaultignore) {
865
        // A list of open/close tags that we should not replace within.
866
        // Extended to include <script>, <textarea>, <select> and <a> tags.
867
        // Regular expression allows tags with or without attributes.
868
        $filterignoretagsopen  = array('<head>', '<nolink>', '<span(\s[^>]*?)?class="nolink"(\s[^>]*?)?>',
869
                '<script(\s[^>]*?)?>', '<textarea(\s[^>]*?)?>',
870
                '<select(\s[^>]*?)?>', '<a(\s[^>]*?)?>');
871
        $filterignoretagsclose = array('</head>', '</nolink>', '</span>',
872
                 '</script>', '</textarea>', '</select>', '</a>');
873
    } else {
874
        // Set an empty default list.
875
        $filterignoretagsopen = array();
876
        $filterignoretagsclose = array();
877
    }
878
 
879
    // Add the user defined ignore tags to the default list.
880
    if ( is_array($ignoretagsopen) ) {
881
        foreach ($ignoretagsopen as $open) {
882
            $filterignoretagsopen[] = $open;
883
        }
884
        foreach ($ignoretagsclose as $close) {
885
            $filterignoretagsclose[] = $close;
886
        }
887
    }
888
 
889
    // Double up some magic chars to avoid "accidental matches".
890
    $text = preg_replace('/([#*%])/', '\1\1', $text);
891
 
892
    // Remove everything enclosed by the ignore tags from $text.
893
    filter_save_ignore_tags($text, $filterignoretagsopen, $filterignoretagsclose, $ignoretags);
894
 
895
    // Remove tags from $text.
896
    filter_save_tags($text, $tags);
897
 
898
    // Prepare the limit for preg_match calls.
899
    if (!empty($CFG->filtermatchonepertext) || !empty($CFG->filtermatchoneperpage)) {
900
        $pregreplacelimit = 1;
901
    } else {
902
        $pregreplacelimit = -1; // No limit.
903
    }
904
 
905
    // Time to cycle through each phrase to be linked.
906
    foreach ($linkarray as $key => $linkobject) {
907
        if ($linkobject->workregexp === null) {
908
            // This is the case if, when preparing the phrases for filtering,
909
            // we decided that this was not a suitable phrase to match.
910
            continue;
911
        }
912
 
913
        // If $CFG->filtermatchoneperpage, avoid previously matched linked phrases.
914
        if (!empty($CFG->filtermatchoneperpage) && isset($usedphrases[$linkobject->workregexp])) {
915
            continue;
916
        }
917
 
918
        // Do our highlighting.
919
        $resulttext = preg_replace_callback($linkobject->workregexp,
920
                function ($matches) use ($linkobject) {
921
                    if ($linkobject->workreplacementphrase === null) {
922
                        filter_prepare_phrase_for_replacement($linkobject);
923
                    }
924
 
925
                    return str_replace('$1', $matches[1], $linkobject->workreplacementphrase);
926
                }, $text, $pregreplacelimit);
927
 
928
        // If the text has changed we have to look for links again.
929
        if ($resulttext != $text) {
930
            $text = $resulttext;
931
            // Remove everything enclosed by the ignore tags from $text.
932
            filter_save_ignore_tags($text, $filterignoretagsopen, $filterignoretagsclose, $ignoretags);
933
            // Remove tags from $text.
934
            filter_save_tags($text, $tags);
935
            // If $CFG->filtermatchoneperpage, save linked phrases to request.
936
            if (!empty($CFG->filtermatchoneperpage)) {
937
                $usedphrases[$linkobject->workregexp] = 1;
938
            }
939
        }
940
    }
941
 
942
    // Rebuild the text with all the excluded areas.
943
    if (!empty($tags)) {
944
        $text = str_replace(array_keys($tags), $tags, $text);
945
    }
946
 
947
    if (!empty($ignoretags)) {
948
        $ignoretags = array_reverse($ignoretags);     // Reversed so "progressive" str_replace() will solve some nesting problems.
949
        $text = str_replace(array_keys($ignoretags), $ignoretags, $text);
950
    }
951
 
952
    // Remove the protective doubleups.
953
    $text = preg_replace('/([#*%])(\1)/', '\1', $text);
954
 
955
    // Add missing javascript for popus.
956
    $text = filter_add_javascript($text);
957
 
958
    return $text;
959
}
960
 
961
/**
962
 * Prepare a list of link for processing with {@link filter_phrases()}.
963
 *
964
 * @param filterobject[] $linkarray the links that will be passed to filter_phrases().
965
 * @return filterobject[] the updated list of links with necessary pre-processing done.
966
 */
967
function filter_prepare_phrases_for_filtering(array $linkarray) {
968
    // Time to cycle through each phrase to be linked.
969
    foreach ($linkarray as $linkobject) {
970
 
971
        // Set some defaults if certain properties are missing.
972
        // Properties may be missing if the filterobject class has not been used to construct the object.
973
        if (empty($linkobject->phrase)) {
974
            continue;
975
        }
976
 
977
        // Avoid integers < 1000 to be linked. See bug 1446.
978
        $intcurrent = intval($linkobject->phrase);
979
        if (!empty($intcurrent) && strval($intcurrent) == $linkobject->phrase && $intcurrent < 1000) {
980
            continue;
981
        }
982
 
983
        // Strip tags out of the phrase.
984
        $linkobject->workregexp = strip_tags($linkobject->phrase);
985
 
986
        if (!$linkobject->casesensitive) {
987
            $linkobject->workregexp = core_text::strtolower($linkobject->workregexp);
988
        }
989
 
990
        // Double up chars that might cause a false match -- the duplicates will
991
        // be cleared up before returning to the user.
992
        $linkobject->workregexp = preg_replace('/([#*%])/', '\1\1', $linkobject->workregexp);
993
 
994
        // Quote any regular expression characters and the delimiter in the work phrase to be searched.
995
        $linkobject->workregexp = preg_quote($linkobject->workregexp, '/');
996
 
997
        // If we ony want to match entire words then add \b assertions. However, only
998
        // do this if the first or last thing in the phrase to match is a word character.
999
        if ($linkobject->fullmatch) {
1000
            if (preg_match('~^\w~', $linkobject->workregexp)) {
1001
                $linkobject->workregexp = '\b' . $linkobject->workregexp;
1002
            }
1003
            if (preg_match('~\w$~', $linkobject->workregexp)) {
1004
                $linkobject->workregexp = $linkobject->workregexp . '\b';
1005
            }
1006
        }
1007
 
1008
        $linkobject->workregexp = '/(' . $linkobject->workregexp . ')/s';
1009
 
1010
        if (!$linkobject->casesensitive) {
1011
            $linkobject->workregexp .= 'iu';
1012
        }
1013
    }
1014
 
1015
    return $linkarray;
1016
}
1017
 
1018
/**
1019
 * Fill in the remaining ->work... fields, that would be needed to replace the phrase.
1020
 *
1021
 * @param filterobject $linkobject the link object on which to set additional fields.
1022
 */
1023
function filter_prepare_phrase_for_replacement(filterobject $linkobject) {
1024
    if ($linkobject->replacementcallback !== null) {
1025
        list($linkobject->hreftagbegin, $linkobject->hreftagend, $linkobject->replacementphrase) =
1026
                call_user_func_array($linkobject->replacementcallback, $linkobject->replacementcallbackdata);
1027
    }
1028
 
1029
    if (!isset($linkobject->hreftagbegin) or !isset($linkobject->hreftagend)) {
1030
        $linkobject->hreftagbegin = '<span class="highlight"';
1031
        $linkobject->hreftagend   = '</span>';
1032
    }
1033
 
1034
    // Double up chars to protect true duplicates
1035
    // be cleared up before returning to the user.
1036
    $hreftagbeginmangled = preg_replace('/([#*%])/', '\1\1', $linkobject->hreftagbegin);
1037
 
1038
    // Set the replacement phrase properly.
1039
    if ($linkobject->replacementphrase) {    // We have specified a replacement phrase.
1040
        $linkobject->workreplacementphrase = strip_tags($linkobject->replacementphrase);
1041
    } else {                                 // The replacement is the original phrase as matched below.
1042
        $linkobject->workreplacementphrase = '$1';
1043
    }
1044
 
1045
    $linkobject->workreplacementphrase = $hreftagbeginmangled .
1046
            $linkobject->workreplacementphrase . $linkobject->hreftagend;
1047
}
1048
 
1049
/**
1050
 * Remove duplicate from a list of {@link filterobject}.
1051
 *
1052
 * @param filterobject[] $linkarray a list of filterobject.
1053
 * @return filterobject[] the same list, but with dupicates removed.
1054
 */
1055
function filter_remove_duplicates($linkarray) {
1056
 
1057
    $concepts  = array(); // Keep a record of concepts as we cycle through.
1058
    $lconcepts = array(); // A lower case version for case insensitive.
1059
 
1060
    $cleanlinks = array();
1061
 
1062
    foreach ($linkarray as $key => $filterobject) {
1063
        if ($filterobject->casesensitive) {
1064
            $exists = in_array($filterobject->phrase, $concepts);
1065
        } else {
1066
            $exists = in_array(core_text::strtolower($filterobject->phrase), $lconcepts);
1067
        }
1068
 
1069
        if (!$exists) {
1070
            $cleanlinks[] = $filterobject;
1071
            $concepts[] = $filterobject->phrase;
1072
            $lconcepts[] = core_text::strtolower($filterobject->phrase);
1073
        }
1074
    }
1075
 
1076
    return $cleanlinks;
1077
}
1078
 
1079
/**
1080
 * Extract open/lose tags and their contents to avoid being processed by filters.
1081
 * Useful to extract pieces of code like <a>...</a> tags. It returns the text
1082
 * converted with some <#xTEXTFILTER_EXCL_SEPARATORx#> codes replacing the extracted text. Such extracted
1083
 * texts are returned in the ignoretags array (as values), with codes as keys.
1084
 *
1085
 * @param string $text                  the text that we are filtering (in/out)
1086
 * @param array $filterignoretagsopen  an array of open tags to start searching
1087
 * @param array $filterignoretagsclose an array of close tags to end searching
1088
 * @param array $ignoretags            an array of saved strings useful to rebuild the original text (in/out)
1089
 **/
1090
function filter_save_ignore_tags(&$text, $filterignoretagsopen, $filterignoretagsclose, &$ignoretags) {
1091
 
1092
    // Remove everything enclosed by the ignore tags from $text.
1093
    foreach ($filterignoretagsopen as $ikey => $opentag) {
1094
        $closetag = $filterignoretagsclose[$ikey];
1095
        // Form regular expression.
1096
        $opentag  = str_replace('/', '\/', $opentag); // Delimit forward slashes.
1097
        $closetag = str_replace('/', '\/', $closetag); // Delimit forward slashes.
1098
        $pregexp = '/'.$opentag.'(.*?)'.$closetag.'/is';
1099
 
1100
        preg_match_all($pregexp, $text, $listofignores);
1101
        foreach (array_unique($listofignores[0]) as $key => $value) {
1102
            $prefix = (string) (count($ignoretags) + 1);
1103
            $ignoretags['<#'.$prefix.TEXTFILTER_EXCL_SEPARATOR.$key.'#>'] = $value;
1104
        }
1105
        if (!empty($ignoretags)) {
1106
            $text = str_replace($ignoretags, array_keys($ignoretags), $text);
1107
        }
1108
    }
1109
}
1110
 
1111
/**
1112
 * Extract tags (any text enclosed by < and > to avoid being processed by filters.
1113
 * It returns the text converted with some <%xTEXTFILTER_EXCL_SEPARATORx%> codes replacing the extracted text. Such extracted
1114
 * texts are returned in the tags array (as values), with codes as keys.
1115
 *
1116
 * @param string $text   the text that we are filtering (in/out)
1117
 * @param array $tags   an array of saved strings useful to rebuild the original text (in/out)
1118
 **/
1119
function filter_save_tags(&$text, &$tags) {
1120
 
1121
    preg_match_all('/<([^#%*].*?)>/is', $text, $listofnewtags);
1122
    foreach (array_unique($listofnewtags[0]) as $ntkey => $value) {
1123
        $prefix = (string)(count($tags) + 1);
1124
        $tags['<%'.$prefix.TEXTFILTER_EXCL_SEPARATOR.$ntkey.'%>'] = $value;
1125
    }
1126
    if (!empty($tags)) {
1127
        $text = str_replace($tags, array_keys($tags), $text);
1128
    }
1129
}
1130
 
1131
/**
1132
 * Add missing openpopup javascript to HTML files.
1133
 *
1134
 * @param string $text
1135
 * @return string
1136
 */
1137
function filter_add_javascript($text) {
1138
    global $CFG;
1139
 
1140
    if (stripos($text, '</html>') === false) {
1141
        return $text; // This is not a html file.
1142
    }
1143
    if (strpos($text, 'onclick="return openpopup') === false) {
1144
        return $text; // No popup - no need to add javascript.
1145
    }
1146
    $js = "
1147
    <script type=\"text/javascript\">
1148
    <!--
1149
        function openpopup(url,name,options,fullscreen) {
1150
          fullurl = \"".$CFG->wwwroot."\" + url;
1151
          windowobj = window.open(fullurl,name,options);
1152
          if (fullscreen) {
1153
            windowobj.moveTo(0,0);
1154
            windowobj.resizeTo(screen.availWidth,screen.availHeight);
1155
          }
1156
          windowobj.focus();
1157
          return false;
1158
        }
1159
    // -->
1160
    </script>";
1161
    if (stripos($text, '</head>') !== false) {
1162
        // Try to add it into the head element.
1163
        $text = str_ireplace('</head>', $js.'</head>', $text);
1164
        return $text;
1165
    }
1166
 
1167
    // Last chance - try adding head element.
1168
    return preg_replace("/<html.*?>/is", "\\0<head>".$js.'</head>', $text);
1169
}