[TikiWiki-commits] [Git][tikiwiki/tiki][master] [ENH][FIX] Trackers: Parse wiki link syntax in URL field output (optional)

Benoit Grégoire (@benoitg) via TikiWiki-cvs <[email protected]>
Newsgroups gmane.comp.cms.tiki.cvs
Message-ID <69a059225a342_3b1877701005ca@gitlab-sidekiq-low-urgency-cpu-bound-v2-5cc5d688f4-jhlfh.mail>

Benoit Grégoire pushed to branch master at Tiki Wiki CMS Groupware / Tiki


Commits:
71ce39ba by Sammy Ndabo at 2026-02-26T14:12:04+00:00
[ENH][FIX] Trackers: Parse wiki link syntax in URL field output (optional)
---
* [FIX] Url.php: simplify url field validation and client-side checks

* [ENH] Url.php: normalize stored value to url before rendering options

* [FIX] Url.php: include resolved href in invalid wiki-link URL error

* [FIX] Url.php: php lint issue

* [FIX] URL field: normalize wiki-link values to resolved href in CSV output

* [ENH] URL field: rename wiki-wrapper validation data attributes for clearer intent

* [ENH] URL field: align frontend wiki-link validation with backend

* [ENH] trackerFieldUrl: implement wiki syntax validation and feedback in URL input

* [ENH] Url.php: enhance URL validation and add wiki link handling; update url.tpl for wiki support syntax warnings

* [ENH] Url.php: add validation for wiki link syntax in URL fields

* [ENH] trackerFieldUrl: document limited wiki-syntax handling in tracker field URL field and add unit tests

* [REM] Url trackerfield: remove pref tracker_url_parse_wiki

* [ENH] tracker: rename and add tracker_url_parse_wiki pref to admin tpl

* [FIX] Url.php: fix php lints issues

* [ENH] tracker: Add preference to enable parsing of wiki syntax in URL fields

* [FIX] tracker: Parse wiki link syntax in URL field output

See merge request tikiwiki/tiki!9650

- - - - -


5 changed files:

- lib/core/Tracker/Field/Url.php
- lib/jquery_tiki/tiki-trackers.js
- lib/parser/parserlib.php
- + lib/test/Core/Tracker/Field/UrlTest.php
- templates/trackerinput/url.tpl


Changes:

=====================================
lib/core/Tracker/Field/Url.php
=====================================
@@ -74,9 +74,9 @@ class Tracker_Field_Url extends \Tracker\Field\AbstractItemField implements \Tra
     {
         $smarty = TikiLib::lib('smarty');
 
-        $url = $this->getConfiguration('value');
+        $url = self::normalizeStoredValueToUrl((string) $this->getConfiguration('value'));
 
-        if (empty($url) || $context['list_mode'] == 'csv' || $this->getOption('linkToURL') == 1) {
+        if ($url === '' || ($context['list_mode'] ?? '') === 'csv' || $this->getOption('linkToURL') == 1) {
             return $url;
         } elseif ($this->getOption('linkToURL') == 2) { // Site title as link
             return smarty_function_object_link(
@@ -117,9 +117,52 @@ class Tracker_Field_Url extends \Tracker\Field\AbstractItemField implements \Tra
         }
     }
 
+    protected static function isWikiSyntaxLink(string $value): bool
+    {
+        return (str_starts_with($value, '((') && str_ends_with($value, '))'))
+            || (str_starts_with($value, '[') && str_ends_with($value, ']'));
+    }
+
+    public function isValid($ins_fields_data)
+    {
+        $fieldId = $this->getFieldId();
+        $value = $ins_fields_data[$fieldId]['value'] ?? $this->getValue();
+        $trimmed = trim((string) $value);
+
+        if ($trimmed === '') {
+            return true;
+        }
+
+        if (self::isWikiSyntaxLink($trimmed)) {
+            $resolvedHref = self::extractFirstHrefFromParsedWikiLink($trimmed);
+            if ($resolvedHref === null) {
+                return tr('Invalid wiki syntax. The link target could not be resolved.');
+            }
+            if (! self::isSyntacticallyValidUrl($resolvedHref)) {
+                return tr('Invalid wiki syntax. The resolved link target "%0" is not a valid URL.', $resolvedHref);
+            }
+            // Non-blocking warning for internal non existing yet wiki page target
+            $target = self::extractWikiLinkTarget($trimmed);
+            if ($target !== null && ! self::looksLikeExternalUrl($target) && ! TikiLib::lib('tiki')->page_exists($target)) {
+                Feedback::warning(tr('Warning: Target wiki page "%0" does not exist yet.', $target));
+            }
+            return true;
+        }
+
+        if (! self::isSyntacticallyValidUrl($trimmed)) {
+            return tr('Invalid URL syntax.');
+        }
+
+        return true;
+    }
+
     public function renderInput($context = [])
     {
-        return $this->renderTemplate("trackerinput/url.tpl", $context);
+        $templateData = [
+            'wikiSyntaxInfo' => tr('You can also use complete wiki-link syntax: ((PageName)) or [url|text].'),
+        ];
+
+        return $this->renderTemplate("trackerinput/url.tpl", $context, $templateData);
     }
 
     public function importRemote($value)
@@ -155,4 +198,86 @@ class Tracker_Field_Url extends \Tracker\Field\AbstractItemField implements \Tra
 
         return $schema;
     }
+
+    // Keep wiki-syntax handling intentionally limited to full-value wrappers like ((PageName)) or [url|text].
+    // For full wiki parsing consistency (escaping, multilingual behavior, shared parsing path),
+    // consider refactoring URL to inherit Tracker_Field_Text.
+    protected static function normalizeStoredValueToUrl(string $value): string
+    {
+        $trimmed = trim($value);
+
+        if (! self::isWikiSyntaxLink($trimmed)) {
+            return $value;
+        }
+
+        $resolvedHref = self::extractFirstHrefFromParsedWikiLink($trimmed);
+        return $resolvedHref ?? $value;
+    }
+
+    protected static function extractFirstHrefFromParsedWikiLink(string $value): ?string
+    {
+        $parsed = TikiLib::lib('parser')->parse_data_simple($value);
+
+        if (! preg_match('/<a\b[^>]*\bhref=(["\'])(.*?)\1/i', $parsed, $matches)) {
+            return null;
+        }
+
+        return html_entity_decode($matches[2], ENT_QUOTES, 'UTF-8');
+    }
+
+    protected static function isSyntacticallyValidUrl(string $url): bool
+    {
+        if ($url === '') {
+            return true;
+        }
+
+        if (filter_var($url, FILTER_VALIDATE_URL)) {
+            return true;
+        }
+
+        if (str_starts_with($url, '/')) {
+            return ! preg_match('/\s/', $url);
+        }
+
+        $parsed = parse_url($url);
+        if ($parsed === false) {
+            return false;
+        }
+
+        if (isset($parsed['scheme'])) {
+            return (bool) preg_match('/^[a-z][a-z0-9+.-]*$/i', $parsed['scheme']) && ! preg_match('/\s/', $url);
+        }
+
+        return ! preg_match('/\s/', $url);
+    }
+
+    protected static function extractWikiLinkTarget(string $value): ?string
+    {
+        if (str_starts_with($value, '((') && str_ends_with($value, '))')) {
+            $inside = trim(substr($value, 2, -2));
+            if ($inside === '') {
+                return null;
+            }
+
+            $parts = preg_split('/[|#]/', $inside, 2);
+            return trim($parts[0] ?? '');
+        }
+
+        if (str_starts_with($value, '[') && str_ends_with($value, ']')) {
+            $inside = trim(substr($value, 1, -1));
+            if ($inside === '') {
+                return null;
+            }
+
+            $parts = explode('|', $inside, 2);
+            return trim($parts[0]);
+        }
+
+        return null;
+    }
+
+    protected static function looksLikeExternalUrl(string $value): bool
+    {
+        return (bool) preg_match('/^(https?:\/\/|ftp:\/\/|mailto:|news:)/i', $value);
+    }
 }


=====================================
lib/jquery_tiki/tiki-trackers.js
=====================================
@@ -683,6 +683,89 @@
     });
 
     // Global tracker field functions
+    function updateTrackerFormSubmitState($input) {
+        const $form = $input.closest('form');
+        if (! $form.length) {
+            return;
+        }
+
+        const hasInvalidUrlSyntax = $form.find('input[data-url-wiki-wrapper-validation-input]').filter(function () {
+            return !! $(this).data('urlWikiSyntaxInvalid');
+        }).length > 0;
+
+        $form.find('input[type="submit"], button[type="submit"], .item-submit-btn').prop('disabled', hasInvalidUrlSyntax);
+        $form.closest('.modal').find('.modal-footer .auto-btn').prop('disabled', hasInvalidUrlSyntax);
+    }
+
+    function validateUrlInput($input) {
+        const rawValue = ($input.val() || '');
+        const value = rawValue.trim();
+
+        const feedbackId = $input.data('url-wiki-wrapper-feedback-id');
+        const $feedback = feedbackId ? $('#' + feedbackId) : $();
+
+        if (! $feedback.length) {
+            return;
+        }
+
+        const defaultInfo = $feedback.data('default-info') || '';
+
+        if (value === '') {
+            $feedback
+                .removeClass('text-warning')
+                .addClass('text-muted')
+                .text(defaultInfo);
+            $input.data('urlWikiSyntaxInvalid', false);
+            updateTrackerFormSubmitState($input);
+            return;
+        }
+
+        const isWrappedDouble = value.startsWith('((') && value.endsWith('))');
+        const isWrappedBracket = value.startsWith('[') && value.endsWith(']');
+        const isWikiWrapped = isWrappedDouble || isWrappedBracket;
+        let message = '';
+
+        if (! isWikiWrapped) {
+            try {
+                new URL(value, window.location.origin);
+            } catch (err) {
+                message = tr('Invalid URL syntax.');
+            }
+        }
+
+        if (message) {
+            $feedback
+                .removeClass('text-muted')
+                .addClass('text-warning')
+                .text(message);
+            $input.data('urlWikiSyntaxInvalid', true);
+        } else {
+            $feedback
+                .removeClass('text-warning')
+                .addClass('text-muted')
+                .text(defaultInfo);
+            $input.data('urlWikiSyntaxInvalid', false);
+        }
+
+        updateTrackerFormSubmitState($input);
+    }
+
+    $(document).on('input blur', 'input[data-url-wiki-wrapper-validation-input]', function () {
+        validateUrlInput($(this));
+    });
+
+    $(document).on('tiki.modal.redraw', '.modal.fade', function () {
+        $(this).find('input[data-url-wiki-wrapper-validation-input]').each(function () {
+            validateUrlInput($(this));
+        });
+    });
+
+    $(function () {
+        $('input[data-url-wiki-wrapper-validation-input]').each(function () {
+            validateUrlInput($(this));
+        });
+    });
+
     $(document).on('mouseenter', '.currency_output', function(){
       $('.'+$(this).attr('id')).removeClass('d-none');
     });


=====================================
lib/parser/parserlib.php
=====================================
@@ -1585,6 +1585,8 @@ class ParserLib extends TikiDb_Bridge
 
     /** Simpler and faster parse than parse_data()
      * This is only called from the parse Smarty modifier, for preference definitions.
+     * Also called in Url.php when parsing the title of a page, to allow wikilinks in titles, but without
+     * allowing plugins or other complex syntax.
      */
     public function parse_data_simple($data)
     {


=====================================
lib/test/Core/Tracker/Field/UrlTest.php
=====================================
@@ -0,0 +1,57 @@
+<?php
+
+// (c) Copyright by authors of the Tiki Wiki CMS Groupware Project
+//
+// All Rights Reserved. See copyright.txt for details and a complete list of authors.
+// Licensed under the GNU LESSER GENERAL PUBLIC LICENSE. See license.txt for details.
+
+namespace TikiTests;
+
+class TrackerFieldUrlTest extends \PHPUnit\Framework\TestCase
+{
+    /**
+     * @dataProvider supportedWikiSyntaxProvider
+     */
+    public function testIsWikiSyntaxLinkSupportedInputs(string $value): void
+    {
+        $this->assertTrue($this->invokeIsWikiSyntaxLink(trim($value)));
+    }
+
+    /**
+     * @dataProvider unsupportedWikiSyntaxProvider
+     */
+    public function testIsWikiSyntaxLinkUnsupportedInputs(string $value): void
+    {
+        $this->assertFalse($this->invokeIsWikiSyntaxLink(trim($value)));
+    }
+
+    public static function supportedWikiSyntaxProvider(): array
+    {
+        return [
+            'wikilink' => ['((PageName))'],
+            'wikilink with spaces around input' => ['  ((PageName))  '],
+            'external link with label' => ['[https://example.org|Example]'],
+            'external link with spaces around input' => ['  [https://example.org|Example]  '],
+        ];
+    }
+
+    public static function unsupportedWikiSyntaxProvider(): array
+    {
+        return [
+            'plain url' => ['https://example.org'],
+            'single parenthesis syntax is unsupported' => ['(PageName)'],
+            'broken wikilink prefix only' => ['((PageName'],
+            'broken bracket syntax suffix only' => ['https://example.org|Example]'],
+            'escaped bracket syntax is out of scope' => ['\[https://example.org|Example]'],
+            'mixed content around syntax is out of scope' => ['prefix ((PageName)) suffix'],
+        ];
+    }
+
+    private function invokeIsWikiSyntaxLink(string $value): bool
+    {
+        $method = new \ReflectionMethod(\Tracker_Field_Url::class, 'isWikiSyntaxLink');
+        $method->setAccessible(true);
+
+        return (bool) $method->invoke(null, $value);
+    }
+}


=====================================
templates/trackerinput/url.tpl
=====================================
@@ -1,6 +1,8 @@
 <div{if !empty($field.options_map.labelasplaceholder)} class="input-group"{/if}>
     <input type="text" class="form-control{if !empty($field.options_map.labelasplaceholder)} labelasplaceholder{/if}"
            name="{$field.ins_id}" id="{$field.ins_id}" value="{$field.value|escape}" size="60"
+           data-url-wiki-wrapper-validation-input=""
+           data-url-wiki-wrapper-feedback-id="{$field.ins_id}_wikiSyntaxLive"
            {if !empty($field.options_map.labelasplaceholder)}placeholder="{$field.name}"{/if}
     >
     {if $field.options_map.labelasplaceholder and $field.isMandatory eq 'y'}
@@ -9,3 +11,7 @@
         </span>
     {/if}
 </div>
+<div id="{$field.ins_id}_wikiSyntaxLive"
+     class="form-text text-muted js-url-wiki-syntax-feedback"
+     data-default-info="{$data.wikiSyntaxInfo|escape:'htmlattr'}"
+>{$data.wikiSyntaxInfo|escape}</div>



View it on GitLab: https://gitlab.com/tikiwiki/tiki/-/commit/71ce39ba9ee39ebd1848c94925bb7e6d6e4009c1

-- 
View it on GitLab: https://gitlab.com/tikiwiki/tiki/-/commit/71ce39ba9ee39ebd1848c94925bb7e6d6e4009c1
You're receiving this email because of your account on gitlab.com. Manage all notifications: https://gitlab.com/-/profile/notifications | Help: https://gitlab.com/help

_______________________________________________
TikiWiki-cvs mailing list
[email protected]
https://lists.sourceforge.net/lists/listinfo/tikiwiki-cvs
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.