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