xref: /template/strap/ComboStrap/Web/Url.php (revision 313de40a7a81adb8606d463d69a8d40c7499c8f8)
1<?php
2
3
4namespace ComboStrap\Web;
5
6use ComboStrap\ArrayCaseInsensitive;
7use ComboStrap\DataType;
8use ComboStrap\DokuwikiId;
9use ComboStrap\ExceptionBadArgument;
10use ComboStrap\ExceptionBadSyntax;
11use ComboStrap\ExceptionCompile;
12use ComboStrap\ExceptionNotEquals;
13use ComboStrap\ExceptionNotFound;
14use ComboStrap\ExceptionRuntimeInternal;
15use ComboStrap\FetcherRawLocalPath;
16use ComboStrap\FetcherSystem;
17use ComboStrap\LocalFileSystem;
18use ComboStrap\LogUtility;
19use ComboStrap\MediaMarkup;
20use ComboStrap\Path;
21use ComboStrap\PathAbs;
22use ComboStrap\PluginUtility;
23use ComboStrap\Site;
24use ComboStrap\WikiPath;
25use dokuwiki\Input\Input;
26
27/**
28 * Class Url
29 * @package ComboStrap
30 * There is no URL class in php
31 * Only function
32 * https://www.php.net/manual/en/ref.url.php
33 */
34class Url extends PathAbs
35{
36
37
38    public const PATH_SEP = "/";
39    /**
40     * In HTML (not in css)
41     *
42     * Because ampersands are used to denote HTML entities,
43     * if you want to use them as literal characters, you must escape them as entities,
44     * e.g.  &amp;.
45     *
46     * In HTML, Browser will do the translation for you if you give an URL
47     * not encoded but testing library may not and refuse them
48     *
49     * This URL encoding is mandatory for the {@link ml} function
50     * when there is a width and use them not otherwise
51     *
52     * Thus, if you want to link to:
53     * http://images.google.com/images?num=30&q=larry+bird
54     * you need to encode (ie pass this parameter to the {@link ml} function:
55     * http://images.google.com/images?num=30&amp;q=larry+bird
56     *
57     * https://daringfireball.net/projects/markdown/syntax#autoescape
58     *
59     */
60    public const AMPERSAND_URL_ENCODED_FOR_HTML = '&amp;';
61    /**
62     * Used in dokuwiki syntax & in CSS attribute
63     * (Css attribute value are then HTML encoded as value of the attribute)
64     */
65    public const AMPERSAND_CHARACTER = "&";
66
67    const CANONICAL = "url";
68    /**
69     * The schemes that are relative (normallu only URL ? ie http, https)
70     * This class is much more an URI
71     */
72    const RELATIVE_URL_SCHEMES = ["http", "https"];
73
74
75    private ArrayCaseInsensitive $query;
76    private ?string $path = null;
77    private ?string $scheme = null;
78    private ?string $host = null;
79    private ?string $fragment = null;
80    /**
81     * @var string - original url string
82     */
83    private $url;
84    private ?int $port = null;
85    /**
86     * @var bool - does the URL rewrite occurs
87     */
88    private bool $withRewrite = true;
89
90
91    /**
92     * UrlUtility constructor.
93     * @throws ExceptionBadSyntax
94     * @throws ExceptionBadArgument
95     */
96    public function __construct(string $url = null)
97    {
98
99        $this->url = $url;
100        $this->query = new ArrayCaseInsensitive();
101        if ($this->url !== null) {
102            /**
103             *
104             * @var false
105             *
106             * Note: Url validation is hard with regexp
107             * for instance:
108             *  - http://example.lan/utility/a-combostrap-component-to-render-web-code-in-a-web-page-javascript-html-...-u8fe6ahw
109             *  - does not pass return preg_match('|^http(s)?://[a-z0-9-]+(.[a-z0-9-]+)*(:[0-9]+)?(/.*)?$|i', $url);
110             * of preg_match('/^https?:\/\//',$url) ? from redirect plugin
111             *
112             * We try to create the object, the object use the {@link parse_url()}
113             * method to validate or send an exception if it can be parsed
114             */
115            $urlComponents = parse_url($url);
116            if ($urlComponents === false) {
117                throw new ExceptionBadSyntax("The url ($url) is not valid");
118            }
119            $queryKeys = [];
120            $queryString = $urlComponents['query'] ?? null;
121            if ($queryString !== null) {
122                parse_str($queryString, $queryKeys);
123            }
124            $this->query = new ArrayCaseInsensitive($queryKeys);
125            $this->scheme = $urlComponents["scheme"] ?? null;
126            $this->host = $urlComponents["host"] ?? null;
127            $port = $urlComponents["port"] ?? null;
128            try {
129                if ($port !== null) {
130                    $this->port = DataType::toInteger($port);
131                }
132            } catch (ExceptionBadArgument $e) {
133                throw new ExceptionBadArgument("The port ($port) in ($url) is not an integer. Error: {$e->getMessage()}");
134            }
135            $pathUrlComponent = $urlComponents["path"] ?? null;
136            if ($pathUrlComponent !== null) {
137                $this->setPath($pathUrlComponent);
138            }
139            $this->fragment = $urlComponents["fragment"] ?? null;
140
141            /**
142             * Rewrite occurs only on Dokuwiki Request
143             * Not on CDN
144             * We use a negation because otherwise the router redirect for now if the value is false by default
145             *
146             * Rewrite is only allowed on
147             *   * relative url
148             *   * first party url
149             */
150            $requestHost = $_SERVER['HTTP_HOST'] ?? null;
151            if (!(
152                // relative url
153                $this->host == null
154                ||
155                // first party url
156                ($requestHost != null && $this->host == $requestHost))
157            ) {
158                $this->withRewrite = false;
159            }
160
161        }
162    }
163
164
165    const RESERVED_WORDS = [':', '!', '#', '$', '&', '\'', '(', ')', '*', '+', ',', '/', ';', '=', '?', '@', '[', ']'];
166
167    /**
168     * A text to an encoded url
169     * @param $string -  a string
170     * @param string $separator - the path separator in the string
171     */
172    public static function encodeToUrlPath($string, string $separator = WikiPath::NAMESPACE_SEPARATOR_DOUBLE_POINT): string
173    {
174        $parts = explode($separator, $string);
175        $encodedParts = array_map(function ($e) {
176            return urlencode($e);
177        }, $parts);
178        return implode("/", $encodedParts);
179    }
180
181    public static function createEmpty(): Url
182    {
183        return new Url();
184    }
185
186    /**
187     *
188     */
189    public static function createFromGetOrPostGlobalVariable(): Url
190    {
191        /**
192         * May be Just ???
193         * Url::createFromString($_SERVER['REQUEST_URI']);
194         */
195        /**
196         * $_REQUEST is a merge between:
197         *   * $_GET: the URL parameters (aka. query string)
198         *   * $_POST: the array of variables when using a POST application/x-www-form-urlencoded or multipart/form-data
199         * Shared check between post and get HTTP method
200         * managed and encapsulated by {@link Input}.
201         * They add users and other
202         * {@link \TestRequest} is using it
203         */
204        $url = Url::createEmpty();
205        foreach ($_REQUEST as $key => $value) {
206            if (is_array($value)) {
207                foreach ($value as $subkey => $subval) {
208                    if (is_array($subval)) {
209                        if ($key !== "config") {
210                            // dokuwiki things
211                            LogUtility::warning("The key ($key) is an array of an array and was not taken into account in the request url.");
212                        }
213                        continue;
214                    }
215
216                    if ($key == "do") {
217                        // for whatever reason, dokuwiki puts the value in the key
218                        $url->addQueryParameter($key, $subkey);
219                        continue;
220                    }
221                    $url->addQueryParameter($key, $subval);
222
223                }
224            } else {
225                /**
226                 * Bad URL format test
227                 * In the `src` attribute of `script`, the url should not be encoded
228                 * with {@link Url::AMPERSAND_URL_ENCODED_FOR_HTML}
229                 * otherwise we get `amp;` as prefix
230                 * in Chrome
231                 */
232                if (strpos($key, "amp;") === 0) {
233                    /**
234                     * We don't advertise this error, it should not happen
235                     * and there is nothing to do to get back on its feet
236                     */
237                    $message = "The url in src has a bad encoding (the attribute ($key) has a amp; prefix. Infinite cache will not work. Request: " . DataType::toString($_REQUEST);
238                    if (PluginUtility::isDevOrTest()) {
239                        throw new ExceptionRuntimeInternal($message);
240                    } else {
241                        LogUtility::warning($message, "url");
242                    }
243                }
244                /**
245                 * Added in {@link auth_setup}
246                 * Used by dokuwiki
247                 */
248                if (in_array($key, ['u', 'p', 'http_credentials', 'r'])) {
249                    continue;
250                }
251                $url->addQueryParameter($key, $value);
252            }
253        }
254        return $url;
255    }
256
257    /**
258     * Utility class to transform windows separator to url path separator
259     * @param string $pathString
260     * @return array|string|string[]
261     */
262    public static function toUrlSeparator(string $pathString)
263    {
264        return str_replace('\\', '/', $pathString);
265    }
266
267
268    function getQueryProperties(): array
269    {
270        return $this->query->getOriginalArray();
271    }
272
273    /**
274     * @throws ExceptionNotFound
275     */
276    function getQueryPropertyValue($key)
277    {
278        $value = $this->query[$key];
279        if ($value === null) {
280            throw new ExceptionNotFound("The key ($key) was not found");
281        }
282        return $value;
283    }
284
285    /**
286     * Extract the value of a property
287     * @param $propertyName
288     * @return string - the value of the property
289     * @throws ExceptionNotFound
290     */
291    public function getPropertyValue($propertyName): string
292    {
293        if (!isset($this->query[$propertyName])) {
294            throw new ExceptionNotFound("The property ($propertyName) was not found", self::CANONICAL);
295        }
296        return $this->query[$propertyName];
297    }
298
299
300    /**
301     * @throws ExceptionBadSyntax|ExceptionBadArgument
302     */
303    public static function createFromString(string $url): Url
304    {
305        return new Url($url);
306    }
307
308
309    public function getScheme(): string
310    {
311        if ($this->scheme === null) {
312            return "";
313        }
314        return $this->scheme;
315    }
316
317    /**
318     * @param string $path
319     * @return $this
320     * in a https scheme: Not the path has a leading `/` that makes the path absolute
321     * in a email scheme: the path is the email (without /) then
322     */
323    public function setPath(string $path): Url
324    {
325
326        /**
327         * Normalization hack
328         */
329        if (strpos($path, "/./") === 0) {
330            $path = substr($path, 2);
331        }
332        $this->path = $path;
333        return $this;
334    }
335
336    /**
337     * @return bool - true if http, https scheme
338     */
339    public function isHttpUrl(): bool
340    {
341        try {
342            return in_array($this->getScheme(), ["http", "https"]);
343        } catch (ExceptionNotFound $e) {
344            return false;
345        }
346    }
347
348    /**
349     * Multiple parameter can be set to form an array
350     *
351     * Example: s=word1&s=word2
352     *
353     * https://stackoverflow.com/questions/24059773/correct-way-to-pass-multiple-values-for-same-parameter-name-in-get-request
354     */
355    public function addQueryParameter(string $key, ?string $value = null): Url
356    {
357        /**
358         * Php Array syntax
359         */
360        if (substr($key, -2) === "[]") {
361            $key = substr($key, 0, -2);
362            $actualValue = $this->query[$key];
363            if ($actualValue === null || is_array($actualValue)) {
364                $this->query[$key] = [$value];
365            } else {
366                $actualValue[] = $value;
367                $this->query[$key] = $actualValue;
368            }
369            return $this;
370        }
371        if (isset($this->query[$key])) {
372            $actualValue = $this->query[$key];
373            if (is_array($actualValue)) {
374                $this->query[$key][] = $value;
375            } else {
376                $this->query[$key] = [$actualValue, $value];
377            }
378        } else {
379            $this->query[$key] = $value;
380        }
381        return $this;
382    }
383
384
385    public function hasProperty(string $key): bool
386    {
387        if (isset($this->query[$key])) {
388            return true;
389        }
390        return false;
391    }
392
393    /**
394     * @return Url - add the scheme and the host based on the request if not present
395     */
396    public function toAbsoluteUrl(): Url
397    {
398        /**
399         * Do we have a path information
400         * If not, this is a local url (ie #id)
401         * We don't make it absolute
402         */
403        if ($this->isLocal()) {
404            return $this;
405        }
406        if ($this->getScheme() == "") {
407            /**
408             * See {@link getBaseURL()}
409             */
410            if (!is_ssl()) {
411                $this->setScheme("http");
412            } else {
413                $this->setScheme("https");
414            }
415        }
416        try {
417            $this->getHost();
418        } catch (ExceptionNotFound $e) {
419            $remoteHost = Site::getServerHost();
420            $this->setHost($remoteHost);
421
422        }
423        return $this;
424    }
425
426    /**
427     * @return string - utility function that call {@link Url::toAbsoluteUrl()} absolute and {@link Url::toString()}
428     */
429    public function toAbsoluteUrlString(): string
430    {
431        $this->toAbsoluteUrl();
432        return $this->toString();
433    }
434
435    /**
436     * @throws ExceptionNotFound
437     */
438    public function getHost(): string
439    {
440        if ($this->host === null) {
441            throw new ExceptionNotFound("No host");
442        }
443        return $this->host;
444    }
445
446    /**
447     * @throws ExceptionNotFound
448     */
449    public function getPath(): string
450    {
451        if ($this->path === null || $this->path === '/') {
452            throw new ExceptionNotFound("The path was not found");
453        }
454        return $this->path;
455    }
456
457    /**
458     * @throws ExceptionNotFound
459     */
460    public function getFragment(): string
461    {
462        if ($this->fragment === null) {
463            throw new ExceptionNotFound("The fragment was not set");
464        }
465        return $this->fragment;
466    }
467
468
469    public function __toString()
470    {
471        return $this->toString();
472    }
473
474    public function getQueryPropertyValueOrDefault(string $key, string $defaultIfNull)
475    {
476        try {
477            return $this->getQueryPropertyValue($key);
478        } catch (ExceptionNotFound $e) {
479            return $defaultIfNull;
480        }
481    }
482
483    /**
484     * Actual vs expected
485     *
486     * We use this vocabulary (actual/expected) and not (internal/external or left/right) because this function
487     * is mostly used in a test framework.
488     *
489     * @throws ExceptionNotEquals
490     */
491    public function equals(Url $expectedUrl)
492    {
493        /**
494         * Scheme
495         */
496        try {
497            $actualScheme = $this->getScheme();
498        } catch (ExceptionNotFound $e) {
499            $actualScheme = "";
500        }
501        try {
502            $expectedScheme = $expectedUrl->getScheme();
503        } catch (ExceptionNotFound $e) {
504            $expectedScheme = "";
505        }
506        if ($actualScheme !== $expectedScheme) {
507            throw new ExceptionNotEquals("The scheme are not equals ($actualScheme vs $expectedScheme)");
508        }
509        /**
510         * Host
511         */
512        try {
513            $actualHost = $this->getHost();
514        } catch (ExceptionNotFound $e) {
515            $actualHost = "";
516        }
517        try {
518            $expectedHost = $expectedUrl->getHost();
519        } catch (ExceptionNotFound $e) {
520            $expectedHost = "";
521        }
522        if ($actualHost !== $expectedHost) {
523            throw new ExceptionNotEquals("The host are not equals ($actualHost vs $expectedHost)");
524        }
525        /**
526         * Query
527         */
528        $actualQuery = $this->getQueryProperties();
529        $expectedQuery = $expectedUrl->getQueryProperties();
530        foreach ($actualQuery as $key => $value) {
531            $expectedValue = $expectedQuery[$key];
532            if ($expectedValue === null) {
533                throw new ExceptionNotEquals("The expected url does not have the $key property");
534            }
535            if ($expectedValue !== $value) {
536                throw new ExceptionNotEquals("The $key property does not have the same value ($value vs $expectedValue)");
537            }
538            unset($expectedQuery[$key]);
539        }
540        foreach ($expectedQuery as $key => $value) {
541            throw new ExceptionNotEquals("The expected URL has an extra property ($key=$value)");
542        }
543
544        /**
545         * Fragment
546         */
547        try {
548            $actualFragment = $this->getFragment();
549        } catch (ExceptionNotFound $e) {
550            $actualFragment = "";
551        }
552        try {
553            $expectedFragment = $expectedUrl->getFragment();
554        } catch (ExceptionNotFound $e) {
555            $expectedFragment = "";
556        }
557        if ($actualFragment !== $expectedFragment) {
558            throw new ExceptionNotEquals("The fragment are not equals ($actualFragment vs $expectedFragment)");
559        }
560
561    }
562
563    public function setScheme(string $scheme): Url
564    {
565        $this->scheme = $scheme;
566        return $this;
567    }
568
569    public function setHost($host): Url
570    {
571        $this->host = $host;
572        return $this;
573    }
574
575    /**
576     * @param string $fragment
577     * @return $this
578     * Example `#step:11:24728`, this fragment is valid!
579     */
580    public function setFragment(string $fragment): Url
581    {
582        $this->fragment = $fragment;
583        return $this;
584    }
585
586    /**
587     * @throws ExceptionNotFound
588     */
589    public function getQueryString($ampersand = Url::AMPERSAND_CHARACTER): string
590    {
591        if (sizeof($this->query) === 0) {
592            throw new ExceptionNotFound("No Query string");
593        }
594        /**
595         * To be able to diff them
596         */
597        $originalArray = $this->query->getOriginalArray();
598        ksort($originalArray);
599
600        /**
601         * We don't use {@link http_build_query} because:
602         *   * it does not the follow the array format (ie s[]=searchword1+seachword2)
603         *   * it output 'key=' instead of `key` when the value is null
604         */
605        $queryString = null;
606        foreach ($originalArray as $key => $value) {
607            if ($queryString !== null) {
608                /**
609                 * HTML encoding (ie {@link self::AMPERSAND_URL_ENCODED_FOR_HTML}
610                 * happens only when outputing to HTML
611                 * The url may also be used elsewhere where &amp; is unknown or not wanted such as css ...
612                 *
613                 * In test, we may ask the url HTML encoded
614                 */
615                $queryString .= $ampersand;
616            }
617            if ($value === null) {
618                $queryString .= urlencode($key);
619            } else {
620                if (is_array($value)) {
621                    for ($i = 0; $i < sizeof($value); $i++) {
622                        $val = $value[$i];
623                        if ($i > 0) {
624                            $queryString .= self::AMPERSAND_CHARACTER;
625                        }
626                        $queryString .= urlencode($key) . "[]=" . urlencode($val);
627                    }
628                } else {
629                    $queryString .= urlencode($key) . "=" . urlencode($value);
630                }
631            }
632        }
633        return $queryString;
634
635
636    }
637
638    /**
639     * @throws ExceptionNotFound
640     */
641    public function getQueryPropertyValueAndRemoveIfPresent(string $key)
642    {
643        $value = $this->getQueryPropertyValue($key);
644        unset($this->query[$key]);
645        return $value;
646    }
647
648
649    /**
650     * @throws ExceptionNotFound
651     */
652    function getLastName(): string
653    {
654        $names = $this->getNames();
655        $namesCount = count($names);
656        if ($namesCount === 0) {
657            throw new ExceptionNotFound("No last name");
658        }
659        return $names[$namesCount - 1];
660
661    }
662
663    /**
664     * @return string
665     * @throws ExceptionNotFound
666     */
667    public function getExtension(): string
668    {
669        if ($this->hasProperty(MediaMarkup::$MEDIA_QUERY_PARAMETER)) {
670
671            try {
672                return FetcherSystem::createPathFetcherFromUrl($this)->getMime()->getExtension();
673            } catch (ExceptionCompile $e) {
674                LogUtility::internalError("Build error from a Media Fetch URL. We were unable to get the mime. Error: {$e->getMessage()}");
675            }
676
677        }
678        return parent::getExtension();
679    }
680
681
682    function getNames()
683    {
684
685        try {
686            $names = explode(self::PATH_SEP, $this->getPath());
687            return array_slice($names, 1);
688        } catch (ExceptionNotFound $e) {
689            return [];
690        }
691
692    }
693
694    /**
695     * @throws ExceptionNotFound
696     */
697    function getParent(): Url
698    {
699        $names = $this->getNames();
700        $count = count($names);
701        if ($count === 0) {
702            throw new ExceptionNotFound("No Parent");
703        }
704        $parentPath = implode(self::PATH_SEP, array_splice($names, 0, $count - 1));
705        return $this->setPath($parentPath);
706    }
707
708    function toAbsoluteId(): string
709    {
710        try {
711            return $this->getPath();
712        } catch (ExceptionNotFound $e) {
713            return "";
714        }
715    }
716
717    function toAbsolutePath(): Url
718    {
719        return $this->toAbsoluteUrl();
720    }
721
722    function resolve(string $name): Url
723    {
724        try {
725            $path = $this->getPath();
726            if ($this->path[strlen($path) - 1] === URL::PATH_SEP) {
727                $this->path .= $name;
728            } else {
729                $this->path .= URL::PATH_SEP . $name;
730            }
731            return $this;
732        } catch (ExceptionNotFound $e) {
733            $this->setPath($name);
734            return $this;
735        }
736
737    }
738
739    /**
740     * @param string $ampersand
741     * @return string
742     */
743    public function toString(string $ampersand = Url::AMPERSAND_CHARACTER): string
744    {
745
746        try {
747            $scheme = $this->getScheme();
748        } catch (ExceptionNotFound $e) {
749            $scheme = null;
750        }
751
752
753        switch ($scheme) {
754            case LocalFileSystem::SCHEME:
755                /**
756                 * file://host/path
757                 */
758                $base = "$scheme://";
759                try {
760                    $base = "$base{$this->getHost()}";
761                } catch (ExceptionNotFound $e) {
762                    // no host
763                }
764                try {
765                    $path = $this->getAbsolutePath();
766                    // linux, network share (file://host/path)
767                    $base = "$base{$path}";
768                } catch (ExceptionNotFound $e) {
769                    // no path
770                }
771                return $base;
772            case "mailto":
773            case "whatsapp":
774            case "skype":
775                /**
776                 * Skype. Example: skype:echo123?call
777                 * https://docs.microsoft.com/en-us/skype-sdk/skypeuris/skypeuris
778                 * Mailto: Example: mailto:java-net@java.sun.com?subject=yolo
779                 * https://datacadamia.com/marketing/email/mailto
780                 */
781                $base = "$scheme:";
782                try {
783                    $base = "$base{$this->getPath()}";
784                } catch (ExceptionNotFound $e) {
785                    // no path
786                }
787                try {
788                    $base = "$base?{$this->getQueryString()}";
789                } catch (ExceptionNotFound $e) {
790                    // no query string
791                }
792                try {
793                    $base = "$base#{$this->getFragment()}";
794                } catch (ExceptionNotFound $e) {
795                    // no fragment
796                }
797                return $base;
798            case "http":
799            case "https":
800            case "ftp":
801            default:
802                /**
803                 * Url Rewrite
804                 * Absolute vs Relative, __media, ...
805                 */
806                if ($this->withRewrite) {
807                    UrlRewrite::rewrite($this);
808                }
809                /**
810                 * Rewrite may have set a default scheme
811                 * We read it again
812                 */
813                try {
814                    $scheme = $this->getScheme();
815                } catch (ExceptionNotFound $e) {
816                    $scheme = null;
817                }
818                try {
819                    $host = $this->getHost();
820                } catch (ExceptionNotFound $e) {
821                    $host = null;
822                }
823                /**
824                 * Absolute/Relative Uri
825                 */
826                $base = "";
827                if ($host !== null) {
828                    if ($scheme !== null) {
829                        $base = "{$scheme}://";
830                    }
831                    $base = "$base{$host}";
832                    try {
833                        $base = "$base:{$this->getPort()}";
834                    } catch (ExceptionNotFound $e) {
835                        // no port
836                    }
837                } else {
838                    if (!in_array($scheme, self::RELATIVE_URL_SCHEMES) && $scheme !== null) {
839                        $base = "{$scheme}:";
840                    }
841                }
842
843                try {
844                    $base = "$base{$this->getAbsolutePath()}";
845                } catch (ExceptionNotFound $e) {
846                    // ok
847                }
848
849                try {
850                    $base = "$base?{$this->getQueryString($ampersand)}";
851                } catch (ExceptionNotFound $e) {
852                    // ok
853                }
854
855                try {
856                    $base = "$base#{$this->getFragment()}";
857                } catch (ExceptionNotFound $e) {
858                    // ok
859                }
860                return $base;
861        }
862
863
864    }
865
866    /**
867     * Query parameter can have several values
868     * This function makes sure that there is only one value for one key
869     * if the value are different, the value will be added
870     * @param string $key
871     * @param string $value
872     * @return Url
873     */
874    public function addQueryParameterIfNotActualSameValue(string $key, string $value): Url
875    {
876        try {
877            $actualValue = $this->getQueryPropertyValue($key);
878            if ($actualValue !== $value) {
879                $this->addQueryParameter($key, $value);
880            }
881        } catch (ExceptionNotFound $e) {
882            $this->addQueryParameter($key, $value);
883        }
884
885        return $this;
886
887    }
888
889    function getUrl(): Url
890    {
891        return $this;
892    }
893
894    public function toHtmlString(): string
895    {
896        return $this->toString(Url::AMPERSAND_URL_ENCODED_FOR_HTML);
897    }
898
899    /**
900     * @throws ExceptionNotFound
901     */
902    private function getPort(): int
903    {
904        if ($this->port === null) {
905            throw new ExceptionNotFound("No port specified");
906        }
907        return $this->port;
908    }
909
910    public function addQueryParameterIfNotPresent(string $key, string $value)
911    {
912        if (!$this->hasProperty($key)) {
913            $this->addQueryParameterIfNotActualSameValue($key, $value);
914        }
915    }
916
917    /**
918     * Set/replace a query parameter with the new value
919     * @param string $key
920     * @param string $value
921     * @return Url
922     */
923    public function setQueryParameter(string $key, string $value): Url
924    {
925        $this->deleteQueryParameter($key);
926        $this->addQueryParameter($key, $value);
927        return $this;
928    }
929
930    public function deleteQueryParameter(string $key)
931    {
932        unset($this->query[$key]);
933    }
934
935    /**
936     * @return string - An url in the DOM use the ampersand character
937     * If you want to check the value of a DOM attribute, you need to check it with this value
938     */
939    public function toDomString(): string
940    {
941        // ampersand for dom string
942        return $this->toString();
943    }
944
945    public function toCssString(): string
946    {
947        // ampersand for css
948        return $this->toString();
949    }
950
951    /**
952     * @return bool - if the url points to the same website than the host
953     */
954    public function isExternal(): bool
955    {
956        try {
957            // We set the path, otherwise it's seen as a local url
958            $localHost = Url::createEmpty()->setPath("/")->toAbsoluteUrl()->getHost();
959            return $localHost !== $this->getHost();
960        } catch (ExceptionNotFound $e) {
961            // no host meaning that the url is relative and then local
962            return false;
963        }
964    }
965
966    /**
967     * In a url, in a case, the path should be absolute
968     * This function makes it absolute if not.
969     * In case of messaging scheme (mailto, whatsapp, ...), this is not the case
970     * @throws ExceptionNotFound
971     */
972    private function getAbsolutePath(): string
973    {
974        $pathString = $this->getPath();
975        if ($pathString[0] !== "/") {
976            return "/{$pathString}";
977        }
978        return $pathString;
979    }
980
981
982    /**
983     * @throws ExceptionBadSyntax
984     * @throws ExceptionBadArgument
985     */
986    public static function createFromUri(string $uri): Path
987    {
988        return new Url($uri);
989    }
990
991    public function deleteQueryProperties(): Url
992    {
993        $this->query = new ArrayCaseInsensitive();;
994        return $this;
995    }
996
997    public function withoutRewrite(): Url
998    {
999        $this->withRewrite = false;
1000        return $this;
1001    }
1002
1003    /**
1004     * Dokuwiki utility to check if the URL is local
1005     * (ie has not path, only a fragment such as #id)
1006     * @return bool
1007     */
1008    public function isLocal(): bool
1009    {
1010        if ($this->path !== null) {
1011            return false;
1012        }
1013        /**
1014         * The path paramater of Dokuwiki
1015         */
1016        if ($this->hasProperty(DokuwikiId::DOKUWIKI_ID_ATTRIBUTE)) {
1017            return false;
1018        }
1019        if ($this->hasProperty(MediaMarkup::$MEDIA_QUERY_PARAMETER)) {
1020            return false;
1021        }
1022        if ($this->hasProperty(FetcherRawLocalPath::SRC_QUERY_PARAMETER)) {
1023            return false;
1024        }
1025        return true;
1026    }
1027
1028
1029}
1030