-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathTextformatterPageTitleLinks.module
397 lines (363 loc) · 16.1 KB
/
TextformatterPageTitleLinks.module
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
<?php
namespace Processwire;
class TextformatterPageTitleLinks extends Textformatter implements Module
{
/**
* The name of the field used for replacements.
*/
public const TITLE_FIELD_NAME = 'title';
/**
* The database column name (excluding language id).
*/
public const DB_COLUMN = 'data';
/**
* Get the module info.
*
* @return array
*/
public static function getModuleInfo(): array
{
return [
'title' => __('Automatically link page titles'),
'summary' => __('This converts all page titles to a link to the corresponding page.'),
'author' => "Moritz L'Hoest",
'version' => '4.1.0',
'href' => 'https://github.com/MoritzLost/TextformatterPageTitleLinks',
'icon' => 'link',
'requires' => [
'PHP>=7.1.0',
]
];
}
/**
* Automatically convert titles of pages in the passed text to links to
* those pages. Check out the module documentation for more information.
*
* @param Page $page The current page. Relevant to the 'include_current_page' option.
* @param Field $field Required by ProcessWire, can be an empty Field (`new Field()`).
* @param string $value The string to format.
* @return void
*/
public function formatValue(Page $page, Field $field, &$value): void
{
$this->doFormat($value, $this->modules->getModuleConfigData($this), $page);
}
/**
* Shortcut to $this->formatValue using the current page.
*
* @param string $value The string to format.
* @return void
*/
public function format(&$value): void
{
$this->formatValue($this->wire('page'), new Field(), $value);
}
/**
* Use the module with custom options, overwriting the default module configuration.
*
* @param string $value The string to format.
* @param array $options An associative array with options to overwrite the
* default module options. See the module documentation
* for available options.
* @param Page $page The page to use as current page.
* @return void
*/
public function formatWithOptions(&$value, array $options, ?Page $page = null): void
{
$page = $page ?? $this->wire('page');
// merge the default module options with the passed options (overwriting the defaults)
$merged_options = array_merge(
$this->modules->getModuleConfigData($this),
$options
);
$this->doFormat($value, $merged_options, $page);
}
/**
* Perform the formatting operation on the value using the passed options and page.
*
* @param string $value The value to format.
* @param array $options All required options.
* @param Page $page The page to use as current page.
* @return void
*/
protected function doFormat(&$value, array $options, Page $page): void {
// merge passed options with default options
$defaults = (new TextformatterPageTitleLinksConfig())->getDefaults();
$options = array_merge($defaults, $options);
$template_ids = $this->normalizeTemplateList($options['auto_link_templates']);
// if no target templates are set, no pages can be linked
if (empty($template_ids)) {
return;
}
// get all pages of the selected templates as an associative id => title array
$id_title_array = $this->getTitleIdArrayForCurrentLanguage($template_ids, $options);
// if this is a repeater (matrix) page, make sure
// $page references the "real" current page
if (
$page instanceof RepeaterPage ||
$page instanceof RepeaterMatrixPage
) {
$page = wire('page');
}
// exclude current id from this replacement if the option to include it is not active
if (!$options['include_current_page']) {
$current_id = (int) $page->id;
if (!empty($current_id) && array_key_exists($current_id, $id_title_array)) {
unset($id_title_array[$current_id]);
}
}
// abort if no linkable pages exist at this point
if (empty($id_title_array)) {
return;
}
// case insensitive mode toggle; PCRE modifier to append to the pattern
$case_insensitive = $options['case_insensitive_match'] ? 'i' : '';
// perform the replacement with a regex call
$value = preg_replace_callback(
// format the titles as regex patterns
array_map(function ($title) use ($case_insensitive) {
// explanation for lookaheads / lookbehinds:
// [\w] - don't match page titles within longer words
// [^<]*</a> - don't match words directly inside existing anchors
// [^<>]*> - don't match inside attributes of start tags (e.g. in title attributes)
return '/(?<![\w])' . preg_quote($title, '/') . '(?!([\w]|[^<]*<\/a>)|[^<>]*>)/u' . $case_insensitive;
}, $id_title_array),
// replace the titles with links to the url to the respective page
function (array $matches) use ($id_title_array, $options) {
$page_id = array_search($matches[0], $id_title_array);
// if the matched title doesn't exist in the array, it was
// probably a case-insensitive match
if (empty($page_id) && $options['case_insensitive_match']) {
$page_id = (int) array_search(strtolower($matches[0]), array_map('strtolower', $id_title_array));
}
$linked_page = $this->pages->get($page_id);
return $this->buildTitleMarkup($matches[0], $linked_page, $options);
},
$value
);
}
/**
* Turn an array of template arguments (string, id or Template object) into
* a list of template IDs.
*
* @param array $templates Templates specified as template name, id or Template object.
* @return array
*/
protected function normalizeTemplateList(array $templates): array
{
return array_map(function ($template) {
if (is_int($template) || is_string($template)) {
// if the argument is an ID or a string, we try to get the template object
$obj = $this->templates->get($template);
if ($obj && $obj->id) {
// return the ID if the template was found
return $obj->id;
} else {
throw new \InvalidArgumentException("The template with name or ID '{$template}' was not found.");
}
} elseif ($template instanceof Template && $template->id) {
// if the template is already a template object, return it's ID
return $template->id;
} else {
throw new \InvalidArgumentException("Invalid template '{$template}'. The template array must consist of template objects, IDs or names.");
}
}, $templates);
}
/**
* Gets an array of id => title pairs of all pages that are valid title
* replacement targets.
*
* @var array $template_ids An array of allowed templates to query.
* @var array $options The module options.
* @return array
*/
protected function getTitleIdArrayForCurrentLanguage(array $template_ids, array $options): array
{
// language id to append to the database column name, if any
$language_id = $this->getLanguageIdDatabaseSuffix();
// use the language specific row
$Q_VALUE_COLUMN = self::DB_COLUMN;
if ($language_id !== null) {
$Q_VALUE_COLUMN .= $language_id;
}
// allowed templates for IN statement
$Q_ALLOWED_TEMPLATES = implode(', ', $template_ids);
// exclude unpublished and trashed pages
$Q_STATUS_BITMASK = Page::statusUnpublished + Page::statusTrash;
// exclude hidden pages unless otherwise configured
if (!$options['include_hidden_pages']) {
$Q_STATUS_BITMASK += Page::statusHidden;
}
// the name of the table, usually "field_title"
$Q_DB_TABLE = Field::tablePrefix . self::TITLE_FIELD_NAME;
// for duplicate titles, we need to aggregate the id column, or we get
// an error depending on the mysql mode; we'll use the preference for
// older/newer pages, corresponding to the MIN/MAX aggregation function;
// this works in most cases since higher ids will correspond to pages
// that were created later
$Q_TITLE_AGGR_FUNC = !empty($options['same_title_order']) ? $options['same_title_order'] : 'MIN';
// minimum length for linkable titles
$Q_MIN_LENGTH = !empty($options['minimum_length']) ? $options['minimum_length'] : 0;
// status of the page in the current language
if ($language_id !== null) {
// status for the page in this language will be 1 if it's active
$Q_LANG_STATUS_CHECK = "AND pages.status{$language_id} & 1 = 1";
} else {
$Q_LANG_STATUS_CHECK = '';
}
// to force the case sensitive title query for a case insensitive collation,
// we group by binary representation of the title (if the setting is active)
$Q_GROUP_BY = $options['force_case_sensitive_query'] ? 'CAST(title AS BINARY)' : 'title';
// the main query to get all titles for matching templates
$query = "SELECT
{$Q_TITLE_AGGR_FUNC}({$Q_DB_TABLE}.pages_id),
{$Q_DB_TABLE}.{$Q_VALUE_COLUMN} AS title
FROM pages
LEFT JOIN {$Q_DB_TABLE} ON pages.id = {$Q_DB_TABLE}.pages_id
WHERE pages.templates_id IN ({$Q_ALLOWED_TEMPLATES})
AND pages.status & {$Q_STATUS_BITMASK} = 0
{$Q_LANG_STATUS_CHECK}
AND {$Q_DB_TABLE}.{$Q_VALUE_COLUMN} IS NOT NULL
AND CHAR_LENGTH({$Q_DB_TABLE}.{$Q_VALUE_COLUMN}) >= {$Q_MIN_LENGTH}
GROUP BY {$Q_GROUP_BY}
ORDER BY CHAR_LENGTH(title) DESC";
return $this
->wire('database')
->pdo()
->query($query)
->fetchAll(\PDO::FETCH_KEY_PAIR);
}
/**
* Get the ID of the current language, if the title field is a multilanguage
* field, and the current language is not the default one (the default
* language doesn't contain the language ID in the database column).
*
* @return ?int The language ID, or null if no suffix is required.
*/
protected function getLanguageIdDatabaseSuffix(): ?int
{
$current_lang = $this->wire('user')->language;
if (
!empty($current_lang) &&
$current_lang instanceof Language &&
!$current_lang->isDefault() &&
$this->fieldIsMultilanguage($this->fields->get(self::TITLE_FIELD_NAME))
) {
return $current_lang->id;
}
return null;
}
/**
* Checks if the passed field is a multilanguage field.
*
* @param Field $field The field to check.
* @return boolean
*/
protected function fieldIsMultilanguage(Field $field): bool
{
return $field->getFieldtype() instanceof FieldtypeLanguageInterface;
}
/**
* Build the markup for a title element. Creates a link by default, by the
* element can be changed using the $options argument. See the README for
* available options.
* May return the title as is if the user doesn't have access to view the
* page, unless this check is disabled through the $options.
*
* @param string $title The title to wrap with markup.
* @param Page $page The page the title belongs to.
* @param array $options The module options.
* @return string
*/
public function ___buildTitleMarkup(string $title, Page $page, array $options): string
{
// unless the visibility check is disabled, check if the user can view
// the page, otherwise just return the title as-is
if (!$options['disable_viewable_check'] && !$page->viewable()) {
return $title;
}
// build url and other attributes
$attributes = !empty($options['add_attributes'])
? $this->parseAttributeMultilineString($options['add_attributes'])
: [];
// add the href attribute, unless this is disabled through the options
if (!$options['disable_href']) {
$attributes['href'] = $page->url();
}
$attribute_string = $this->buildAttributesString($attributes, $page);
// the html tag may be overwritten to something other than a link
$html_tag = $options['html_tag'] ?: 'a';
return "<{$html_tag} {$attribute_string}>{$title}</{$html_tag}>";
}
/**
* Parse a multiline string as input on the module settings page into an
* associative attribute => value array.
*
* @var string $attributes_input The multiline string to parse.
*/
protected function parseAttributeMultilineString(string $attributes_input): array
{
// parse the multiline input into an array, ignoring empty lines
$attributes = preg_split('/[\n\r]+/', $attributes_input, -1, PREG_SPLIT_NO_EMPTY);
// extract the result into an associative attribute => value array
$attributes = array_reduce($attributes, function($new, $line) {
// for attributes without a value (standalone attributes), we
// include it as a name => boolean attribute
if (strpos($line, '=') === false) {
$new[trim($line)] = true;
} elseif (substr_count($line, '=') > 1) {
throw new \Exception('Each line in the attributes setting may contain only one equals sign (=).');
} else {
// extract attribute name and value from the current line
[$attr_name, $attr_value] = explode('=', $line);
$value = trim($attr_value);
// convert "true" and "false" strings into boolean
if ($value === 'true') $value = true;
if ($value === 'false') $value = false;
// set the attribute => value pair
$new[trim($attr_name)] = $value;
}
return $new;
}, []);
return $attributes;
}
/**
* Formats the passed attributes as a string, using optional markup replacements
* with the passed page.
*
* @param array $attributes The attributes as a associative array containing attribute => value pairs.
* @param ?Page $page The page to use for replacements.
* @return string
*/
public function ___buildAttributesString(array $attributes, ?Page $page = null): string
{
$attr_filtered = [];
foreach ($attributes as $name => $value) {
if (is_bool($value)) {
// bool attributes are included as standalone, without a value
if (true === $value) {
$attr_filtered[] = $name;
}
// bool false values are not included at all
continue;
}
// use the passed page for replacements, if any
$parsed_value = $page ? $page->getText($value, true, false) : $value;
// getText will return empty if there are no replacement tags or the
// token is not replaceable, so in this case we use the original value
if (empty($parsed_value)) {
$parsed_value = $value;
}
switch ($name) {
case 'class':
// special escaping for classes
$parsed_value = trim(preg_replace('/[^a-zA-Z0-9 _-]/', '_', $parsed_value), ' ');
break;
default:
break;
}
$attr_filtered[] = $name . '="' . htmlspecialchars($parsed_value, ENT_HTML5, 'UTF-8') . '"';
}
return implode(' ', $attr_filtered);
}
}