xref: /plugin/combo/ComboStrap/Web/Url.php (revision 4fbd4ae271d755979dab8bbc9a664893487d9d34)
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         * $_REQUEST is a merge between get and post property
193         * Shared check between post and get HTTP method
194         * managed and encapsultaed by {@link Input}.
195         * They add users and other
196         * {@link \TestRequest} is using it
197         */
198        $url = Url::createEmpty();
199        foreach ($_REQUEST as $key => $value) {
200            if (is_array($value)) {
201                foreach ($value as $subkey => $subval) {
202                    if (is_array($subval)) {
203                        if ($key !== "config") {
204                            // dokuwiki things
205                            LogUtility::warning("The key ($key) is an array of an array and was not taken into account in the request url.");
206                        }
207                        continue;
208                    }
209
210                    if ($key == "do") {
211                        // for whatever reason, dokuwiki puts the value in the key
212                        $url->addQueryParameter($key, $subkey);
213                        continue;
214                    }
215                    $url->addQueryParameter($key, $subval);
216
217                }
218            } else {
219                /**
220                 * Bad URL format test
221                 * In the `src` attribute of `script`, the url should not be encoded
222                 * with {@link Url::AMPERSAND_URL_ENCODED_FOR_HTML}
223                 * otherwise we get `amp;` as prefix
224                 * in Chrome
225                 */
226                if (strpos($key, "amp;") === 0) {
227                    /**
228                     * We don't advertise this error, it should not happen
229                     * and there is nothing to do to get back on its feet
230                     */
231                    $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);
232                    if (PluginUtility::isDevOrTest()) {
233                        throw new ExceptionRuntimeInternal($message);
234                    } else {
235                        LogUtility::warning($message, "url");
236                    }
237                }
238                /**
239                 * Added in {@link auth_setup}
240                 * Used by dokuwiki
241                 */
242                if (in_array($key, ['u', 'p', 'http_credentials', 'r'])) {
243                    continue;
244                }
245                $url->addQueryParameter($key, $value);
246            }
247        }
248        return $url;
249    }
250
251    /**
252     * Utility class to transform windows separator to url path separator
253     * @param string $pathString
254     * @return array|string|string[]
255     */
256    public static function toUrlSeparator(string $pathString)
257    {
258        return str_replace('\\', '/', $pathString);
259    }
260
261
262    function getQueryProperties(): array
263    {
264        return $this->query->getOriginalArray();
265    }
266
267    /**
268     * @throws ExceptionNotFound
269     */
270    function getQueryPropertyValue($key)
271    {
272        $value = $this->query[$key];
273        if ($value === null) {
274            throw new ExceptionNotFound("The key ($key) was not found");
275        }
276        return $value;
277    }
278
279    /**
280     * Extract the value of a property
281     * @param $propertyName
282     * @return string - the value of the property
283     * @throws ExceptionNotFound
284     */
285    public function getPropertyValue($propertyName): string
286    {
287        if (!isset($this->query[$propertyName])) {
288            throw new ExceptionNotFound("The property ($propertyName) was not found", self::CANONICAL);
289        }
290        return $this->query[$propertyName];
291    }
292
293
294    /**
295     * @throws ExceptionBadSyntax|ExceptionBadArgument
296     */
297    public static function createFromString(string $url): Url
298    {
299        return new Url($url);
300    }
301
302    /**
303     * @throws ExceptionNotFound
304     */
305    public function getScheme(): string
306    {
307        if ($this->scheme === null) {
308            throw new ExceptionNotFound("The scheme was not found");
309        }
310        return $this->scheme;
311    }
312
313    /**
314     * @param string $path
315     * @return $this
316     * in a https scheme: Not the path has a leading `/` that makes the path absolute
317     * in a email scheme: the path is the email (without /) then
318     */
319    public function setPath(string $path): Url
320    {
321
322        /**
323         * Normalization hack
324         */
325        if (strpos($path, "/./") === 0) {
326            $path = substr($path, 2);
327        }
328        $this->path = $path;
329        return $this;
330    }
331
332    /**
333     * @return bool - true if http, https scheme
334     */
335    public function isHttpUrl(): bool
336    {
337        try {
338            return in_array($this->getScheme(), ["http", "https"]);
339        } catch (ExceptionNotFound $e) {
340            return false;
341        }
342    }
343
344    /**
345     * Multiple parameter can be set to form an array
346     *
347     * Example: s=word1&s=word2
348     *
349     * https://stackoverflow.com/questions/24059773/correct-way-to-pass-multiple-values-for-same-parameter-name-in-get-request
350     */
351    public function addQueryParameter(string $key, ?string $value = null): Url
352    {
353        /**
354         * Php Array syntax
355         */
356        if (substr($key, -2) === "[]") {
357            $key = substr($key, 0, -2);
358            $actualValue = $this->query[$key];
359            if ($actualValue === null || is_array($actualValue)) {
360                $this->query[$key] = [$value];
361            } else {
362                $actualValue[] = $value;
363                $this->query[$key] = $actualValue;
364            }
365            return $this;
366        }
367        if (isset($this->query[$key])) {
368            $actualValue = $this->query[$key];
369            if (is_array($actualValue)) {
370                $this->query[$key][] = $value;
371            } else {
372                $this->query[$key] = [$actualValue, $value];
373            }
374        } else {
375            $this->query[$key] = $value;
376        }
377        return $this;
378    }
379
380
381    public function hasProperty(string $key): bool
382    {
383        if (isset($this->query[$key])) {
384            return true;
385        }
386        return false;
387    }
388
389    /**
390     * @return Url - add the scheme and the host based on the request if not present
391     */
392    public function toAbsoluteUrl(): Url
393    {
394        /**
395         * Do we have a path information
396         * If not, this is a local url (ie #id)
397         * We don't make it absolute
398         */
399        if ($this->isLocal()) {
400            return $this;
401        }
402        try {
403            $this->getScheme();
404        } catch (ExceptionNotFound $e) {
405            /**
406             * See {@link getBaseURL()}
407             */
408            $https = $_SERVER['HTTPS'] ?? null;
409            if (empty($https)) {
410                $this->setScheme("http");
411            } else {
412                $this->setScheme("https");
413            }
414        }
415        try {
416            $this->getHost();
417        } catch (ExceptionNotFound $e) {
418            $remoteHost = Site::getServerHost();
419            $this->setHost($remoteHost);
420
421        }
422        return $this;
423    }
424
425    /**
426     * @return string - utility function that call {@link Url::toAbsoluteUrl()} absolute and {@link Url::toString()}
427     */
428    public function toAbsoluteUrlString(): string
429    {
430        $this->toAbsoluteUrl();
431        return $this->toString();
432    }
433
434    /**
435     * @throws ExceptionNotFound
436     */
437    public function getHost(): string
438    {
439        if ($this->host === null) {
440            throw new ExceptionNotFound("No host");
441        }
442        return $this->host;
443    }
444
445    /**
446     * @throws ExceptionNotFound
447     */
448    public function getPath(): string
449    {
450        if ($this->path === null || $this->path === '/') {
451            throw new ExceptionNotFound("The path was not found");
452        }
453        return $this->path;
454    }
455
456    /**
457     * @throws ExceptionNotFound
458     */
459    public function getFragment(): string
460    {
461        if ($this->fragment === null) {
462            throw new ExceptionNotFound("The fragment was not set");
463        }
464        return $this->fragment;
465    }
466
467
468    public function __toString()
469    {
470        return $this->toString();
471    }
472
473    public function getQueryPropertyValueOrDefault(string $key, string $defaultIfNull)
474    {
475        try {
476            return $this->getQueryPropertyValue($key);
477        } catch (ExceptionNotFound $e) {
478            return $defaultIfNull;
479        }
480    }
481
482    /**
483     * Actual vs expected
484     *
485     * We use this vocabulary (actual/expected) and not (internal/external or left/right) because this function
486     * is mostly used in a test framework.
487     *
488     * @throws ExceptionNotEquals
489     */
490    public function equals(Url $expectedUrl)
491    {
492        /**
493         * Scheme
494         */
495        try {
496            $actualScheme = $this->getScheme();
497        } catch (ExceptionNotFound $e) {
498            $actualScheme = "";
499        }
500        try {
501            $expectedScheme = $expectedUrl->getScheme();
502        } catch (ExceptionNotFound $e) {
503            $expectedScheme = "";
504        }
505        if ($actualScheme !== $expectedScheme) {
506            throw new ExceptionNotEquals("The scheme are not equals ($actualScheme vs $expectedScheme)");
507        }
508        /**
509         * Host
510         */
511        try {
512            $actualHost = $this->getHost();
513        } catch (ExceptionNotFound $e) {
514            $actualHost = "";
515        }
516        try {
517            $expectedHost = $expectedUrl->getHost();
518        } catch (ExceptionNotFound $e) {
519            $expectedHost = "";
520        }
521        if ($actualHost !== $expectedHost) {
522            throw new ExceptionNotEquals("The host are not equals ($actualHost vs $expectedHost)");
523        }
524        /**
525         * Query
526         */
527        $actualQuery = $this->getQueryProperties();
528        $expectedQuery = $expectedUrl->getQueryProperties();
529        foreach ($actualQuery as $key => $value) {
530            $expectedValue = $expectedQuery[$key];
531            if ($expectedValue === null) {
532                throw new ExceptionNotEquals("The expected url does not have the $key property");
533            }
534            if ($expectedValue !== $value) {
535                throw new ExceptionNotEquals("The $key property does not have the same value ($value vs $expectedValue)");
536            }
537            unset($expectedQuery[$key]);
538        }
539        foreach ($expectedQuery as $key => $value) {
540            throw new ExceptionNotEquals("The expected URL has an extra property ($key=$value)");
541        }
542
543        /**
544         * Fragment
545         */
546        try {
547            $actualFragment = $this->getFragment();
548        } catch (ExceptionNotFound $e) {
549            $actualFragment = "";
550        }
551        try {
552            $expectedFragment = $expectedUrl->getFragment();
553        } catch (ExceptionNotFound $e) {
554            $expectedFragment = "";
555        }
556        if ($actualFragment !== $expectedFragment) {
557            throw new ExceptionNotEquals("The fragment are not equals ($actualFragment vs $expectedFragment)");
558        }
559
560    }
561
562    public function setScheme(string $scheme): Url
563    {
564        $this->scheme = $scheme;
565        return $this;
566    }
567
568    public function setHost($host): Url
569    {
570        $this->host = $host;
571        return $this;
572    }
573
574    /**
575     * @param string $fragment
576     * @return $this
577     * Example `#step:11:24728`, this fragment is valid!
578     */
579    public function setFragment(string $fragment): Url
580    {
581        $this->fragment = $fragment;
582        return $this;
583    }
584
585    /**
586     * @throws ExceptionNotFound
587     */
588    public function getQueryString($ampersand = Url::AMPERSAND_CHARACTER): string
589    {
590        if (sizeof($this->query) === 0) {
591            throw new ExceptionNotFound("No Query string");
592        }
593        /**
594         * To be able to diff them
595         */
596        $originalArray = $this->query->getOriginalArray();
597        ksort($originalArray);
598
599        /**
600         * We don't use {@link http_build_query} because:
601         *   * it does not the follow the array format (ie s[]=searchword1+seachword2)
602         *   * it output 'key=' instead of `key` when the value is null
603         */
604        $queryString = null;
605        foreach ($originalArray as $key => $value) {
606            if ($queryString !== null) {
607                /**
608                 * HTML encoding (ie {@link self::AMPERSAND_URL_ENCODED_FOR_HTML}
609                 * happens only when outputing to HTML
610                 * The url may also be used elsewhere where &amp; is unknown or not wanted such as css ...
611                 *
612                 * In test, we may ask the url HTML encoded
613                 */
614                $queryString .= $ampersand;
615            }
616            if ($value === null) {
617                $queryString .= urlencode($key);
618            } else {
619                if (is_array($value)) {
620                    for ($i = 0; $i < sizeof($value); $i++) {
621                        $val = $value[$i];
622                        if ($i > 0) {
623                            $queryString .= self::AMPERSAND_CHARACTER;
624                        }
625                        $queryString .= urlencode($key) . "[]=" . urlencode($val);
626                    }
627                } else {
628                    $queryString .= urlencode($key) . "=" . urlencode($value);
629                }
630            }
631        }
632        return $queryString;
633
634
635    }
636
637    /**
638     * @throws ExceptionNotFound
639     */
640    public function getQueryPropertyValueAndRemoveIfPresent(string $key)
641    {
642        $value = $this->getQueryPropertyValue($key);
643        unset($this->query[$key]);
644        return $value;
645    }
646
647
648    /**
649     * @throws ExceptionNotFound
650     */
651    function getLastName(): string
652    {
653        $names = $this->getNames();
654        $namesCount = count($names);
655        if ($namesCount === 0) {
656            throw new ExceptionNotFound("No last name");
657        }
658        return $names[$namesCount - 1];
659
660    }
661
662    /**
663     * @return string
664     * @throws ExceptionNotFound
665     */
666    public function getExtension(): string
667    {
668        if ($this->hasProperty(MediaMarkup::$MEDIA_QUERY_PARAMETER)) {
669
670            try {
671                return FetcherSystem::createPathFetcherFromUrl($this)->getMime()->getExtension();
672            } catch (ExceptionCompile $e) {
673                LogUtility::internalError("Build error from a Media Fetch URL. We were unable to get the mime. Error: {$e->getMessage()}");
674            }
675
676        }
677        return parent::getExtension();
678    }
679
680
681    function getNames()
682    {
683
684        try {
685            $names = explode(self::PATH_SEP, $this->getPath());
686            return array_slice($names, 1);
687        } catch (ExceptionNotFound $e) {
688            return [];
689        }
690
691    }
692
693    /**
694     * @throws ExceptionNotFound
695     */
696    function getParent(): Url
697    {
698        $names = $this->getNames();
699        $count = count($names);
700        if ($count === 0) {
701            throw new ExceptionNotFound("No Parent");
702        }
703        $parentPath = implode(self::PATH_SEP, array_splice($names, 0, $count - 1));
704        return $this->setPath($parentPath);
705    }
706
707    function toAbsoluteId(): string
708    {
709        try {
710            return $this->getPath();
711        } catch (ExceptionNotFound $e) {
712            return "";
713        }
714    }
715
716    function toAbsolutePath(): Url
717    {
718        return $this->toAbsoluteUrl();
719    }
720
721    function resolve(string $name): Url
722    {
723        try {
724            $path = $this->getPath();
725            if ($this->path[strlen($path) - 1] === URL::PATH_SEP) {
726                $this->path .= $name;
727            } else {
728                $this->path .= URL::PATH_SEP . $name;
729            }
730            return $this;
731        } catch (ExceptionNotFound $e) {
732            $this->setPath($name);
733            return $this;
734        }
735
736    }
737
738    /**
739     * @param string $ampersand
740     * @return string
741     */
742    public function toString(string $ampersand = Url::AMPERSAND_CHARACTER): string
743    {
744
745        try {
746            $scheme = $this->getScheme();
747        } catch (ExceptionNotFound $e) {
748            $scheme = null;
749        }
750
751
752        switch ($scheme) {
753            case LocalFileSystem::SCHEME:
754                /**
755                 * file://host/path
756                 */
757                $base = "$scheme://";
758                try {
759                    $base = "$base{$this->getHost()}";
760                } catch (ExceptionNotFound $e) {
761                    // no host
762                }
763                try {
764                    $path = $this->getAbsolutePath();
765                    // linux, network share (file://host/path)
766                    $base = "$base{$path}";
767                } catch (ExceptionNotFound $e) {
768                    // no path
769                }
770                return $base;
771            case "mailto":
772            case "whatsapp":
773            case "skype":
774                /**
775                 * Skype. Example: skype:echo123?call
776                 * https://docs.microsoft.com/en-us/skype-sdk/skypeuris/skypeuris
777                 * Mailto: Example: mailto:java-net@java.sun.com?subject=yolo
778                 * https://datacadamia.com/marketing/email/mailto
779                 */
780                $base = "$scheme:";
781                try {
782                    $base = "$base{$this->getPath()}";
783                } catch (ExceptionNotFound $e) {
784                    // no path
785                }
786                try {
787                    $base = "$base?{$this->getQueryString()}";
788                } catch (ExceptionNotFound $e) {
789                    // no query string
790                }
791                try {
792                    $base = "$base#{$this->getFragment()}";
793                } catch (ExceptionNotFound $e) {
794                    // no fragment
795                }
796                return $base;
797            case "http":
798            case "https":
799            case "ftp":
800            default:
801                /**
802                 * Url Rewrite
803                 * Absolute vs Relative, __media, ...
804                 */
805                if ($this->withRewrite) {
806                    UrlRewrite::rewrite($this);
807                }
808                /**
809                 * Rewrite may have set a default scheme
810                 * We read it again
811                 */
812                try {
813                    $scheme = $this->getScheme();
814                } catch (ExceptionNotFound $e) {
815                    $scheme = null;
816                }
817                try {
818                    $host = $this->getHost();
819                } catch (ExceptionNotFound $e) {
820                    $host = null;
821                }
822                /**
823                 * Absolute/Relative Uri
824                 */
825                $base = "";
826                if ($host !== null) {
827                    if ($scheme !== null) {
828                        $base = "{$scheme}://";
829                    }
830                    $base = "$base{$host}";
831                    try {
832                        $base = "$base:{$this->getPort()}";
833                    } catch (ExceptionNotFound $e) {
834                        // no port
835                    }
836                } else {
837                    if (!in_array($scheme, self::RELATIVE_URL_SCHEMES) && $scheme !== null) {
838                        $base = "{$scheme}:";
839                    }
840                }
841
842                try {
843                    $base = "$base{$this->getAbsolutePath()}";
844                } catch (ExceptionNotFound $e) {
845                    // ok
846                }
847
848                try {
849                    $base = "$base?{$this->getQueryString($ampersand)}";
850                } catch (ExceptionNotFound $e) {
851                    // ok
852                }
853
854                try {
855                    $base = "$base#{$this->getFragment()}";
856                } catch (ExceptionNotFound $e) {
857                    // ok
858                }
859                return $base;
860        }
861
862
863    }
864
865    /**
866     * Query parameter can have several values
867     * This function makes sure that there is only one value for one key
868     * if the value are different, the value will be added
869     * @param string $key
870     * @param string $value
871     * @return Url
872     */
873    public function addQueryParameterIfNotActualSameValue(string $key, string $value): Url
874    {
875        try {
876            $actualValue = $this->getQueryPropertyValue($key);
877            if ($actualValue !== $value) {
878                $this->addQueryParameter($key, $value);
879            }
880        } catch (ExceptionNotFound $e) {
881            $this->addQueryParameter($key, $value);
882        }
883
884        return $this;
885
886    }
887
888    function getUrl(): Url
889    {
890        return $this;
891    }
892
893    public function toHtmlString(): string
894    {
895        return $this->toString(Url::AMPERSAND_URL_ENCODED_FOR_HTML);
896    }
897
898    /**
899     * @throws ExceptionNotFound
900     */
901    private function getPort(): int
902    {
903        if ($this->port === null) {
904            throw new ExceptionNotFound("No port specified");
905        }
906        return $this->port;
907    }
908
909    public function addQueryParameterIfNotPresent(string $key, string $value)
910    {
911        if (!$this->hasProperty($key)) {
912            $this->addQueryParameterIfNotActualSameValue($key, $value);
913        }
914    }
915
916    /**
917     * Set/replace a query parameter with the new value
918     * @param string $key
919     * @param string $value
920     * @return Url
921     */
922    public function setQueryParameter(string $key, string $value): Url
923    {
924        $this->deleteQueryParameter($key);
925        $this->addQueryParameter($key, $value);
926        return $this;
927    }
928
929    public function deleteQueryParameter(string $key)
930    {
931        unset($this->query[$key]);
932    }
933
934    /**
935     * @return string - An url in the DOM use the ampersand character
936     * If you want to check the value of a DOM attribute, you need to check it with this value
937     */
938    public function toDomString(): string
939    {
940        // ampersand for dom string
941        return $this->toString();
942    }
943
944    public function toCssString(): string
945    {
946        // ampersand for css
947        return $this->toString();
948    }
949
950    /**
951     * @return bool - if the url points to the same website than the host
952     */
953    public function isExternal(): bool
954    {
955        try {
956            // We set the path, otherwise it's seen as a local url
957            $localHost = Url::createEmpty()->setPath("/")->toAbsoluteUrl()->getHost();
958            return $localHost !== $this->getHost();
959        } catch (ExceptionNotFound $e) {
960            // no host meaning that the url is relative and then local
961            return false;
962        }
963    }
964
965    /**
966     * In a url, in a case, the path should be absolute
967     * This function makes it absolute if not.
968     * In case of messaging scheme (mailto, whatsapp, ...), this is not the case
969     * @throws ExceptionNotFound
970     */
971    private function getAbsolutePath(): string
972    {
973        $pathString = $this->getPath();
974        if ($pathString[0] !== "/") {
975            return "/{$pathString}";
976        }
977        return $pathString;
978    }
979
980
981    /**
982     * @throws ExceptionBadSyntax
983     * @throws ExceptionBadArgument
984     */
985    public static function createFromUri(string $uri): Path
986    {
987        return new Url($uri);
988    }
989
990    public function deleteQueryProperties(): Url
991    {
992        $this->query = new ArrayCaseInsensitive();;
993        return $this;
994    }
995
996    public function withoutRewrite(): Url
997    {
998        $this->withRewrite = false;
999        return $this;
1000    }
1001
1002    /**
1003     * Dokuwiki utility to check if the URL is local
1004     * (ie has not path, only a fragment such as #id)
1005     * @return bool
1006     */
1007    public function isLocal(): bool
1008    {
1009        if ($this->path !== null) {
1010            return false;
1011        }
1012        /**
1013         * The path paramater of Dokuwiki
1014         */
1015        if ($this->hasProperty(DokuwikiId::DOKUWIKI_ID_ATTRIBUTE)) {
1016            return false;
1017        }
1018        if ($this->hasProperty(MediaMarkup::$MEDIA_QUERY_PARAMETER)) {
1019            return false;
1020        }
1021        if ($this->hasProperty(FetcherRawLocalPath::SRC_QUERY_PARAMETER)) {
1022            return false;
1023        }
1024        return true;
1025    }
1026
1027
1028}
1029