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