diff --git a/CHANGELOG.md b/CHANGELOG.md index bb2ecc89d..852b08233 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,32 @@ # Changelog -## master +## 3.2.0 (upcoming) + +* Enhancement: add hook support for event changes+deletion hooks (initial support: "rabbitmq") +* Dependency: pika >= 1.1.0 +* Enhancement: add support for webcal subscriptions +* Enhancement: major update of WebUI (design+features) + +## 3.1.9 + +* Add: support for Python 3.11 + 3.12 +* Drop: support for Python 3.6 +* Fix: MOVE in case listen on non-standard ports or behind reverse proxy +* Fix: stricter requirements of Python 3.11 +* Fix: HTML pages +* Fix: Main Component is missing when only recurrence id exists +* Fix: passlib don't support bcrypt>=4.1 +* Fix: web login now proper encodes passwords containing %XX (hexdigits) +* Enhancement: user-selectable log formats +* Enhancement: autodetect logging to systemd journal +* Enhancement: test code +* Enhancement: option for global permit to delete collection +* Enhancement: auth type 'htpasswd' supports now 'htpasswd_encryption' sha256/sha512 and "autodetect" for smooth transition +* Improve: Dockerfiles +* Improve: server socket listen code + address format in log +* Update: documentations + examples +* Dependency: limit typegard version < 3 +* General: code cosmetics * Adjust: change default loglevel to "info" diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 8b10744da..ccd301ef0 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -906,7 +906,41 @@ An example to relax the same-origin policy: Access-Control-Allow-Origin = * ``` -### Supported Clients +#### hook +##### type + +Hook binding for event changes and deletion notifications. + +Available types: + +`none` +: Disabled. Nothing will be notified. + +`rabbitmq` +: Push the message to the rabbitmq server. + +Default: `none` + +#### rabbitmq_endpoint + +End-point address for rabbitmq server. +Ex: amqp://user:password@localhost:5672/ + +Default: + +#### rabbitmq_topic + +RabbitMQ topic to publish message. + +Default: + +#### rabbitmq_queue_type + +RabbitMQ queue type for the topic. + +Default: classic + +## Supported Clients Radicale has been tested with: diff --git a/config b/config index 00343d927..58afc5e4a 100644 --- a/config +++ b/config @@ -122,3 +122,12 @@ # Additional HTTP headers #Access-Control-Allow-Origin = * + +[hook] + +# Hook types +# Value: none | rabbitmq +#type = none +#rabbitmq_endpoint = +#rabbitmq_topic = +#rabbitmq_queue_type = classic \ No newline at end of file diff --git a/radicale/app/base.py b/radicale/app/base.py index 9330ad1d6..e946bffc6 100644 --- a/radicale/app/base.py +++ b/radicale/app/base.py @@ -21,8 +21,8 @@ import xml.etree.ElementTree as ET from typing import Optional -from radicale import (auth, config, httputils, pathutils, rights, storage, - types, web, xmlutils) +from radicale import (auth, config, hook, httputils, pathutils, rights, + storage, types, web, xmlutils) from radicale.log import logger # HACK: https://github.com/tiran/defusedxml/issues/54 @@ -39,6 +39,7 @@ class ApplicationBase: _web: web.BaseWeb _encoding: str _permit_delete_collection: bool + _hook: hook.BaseHook def __init__(self, configuration: config.Configuration) -> None: self.configuration = configuration @@ -47,6 +48,7 @@ def __init__(self, configuration: config.Configuration) -> None: self._rights = rights.load(configuration) self._web = web.load(configuration) self._encoding = configuration.get("encoding", "request") + self._hook = hook.load(configuration) def _read_xml_request_body(self, environ: types.WSGIEnviron ) -> Optional[ET.Element]: diff --git a/radicale/app/delete.py b/radicale/app/delete.py index 313dedf73..53d9bfd36 100644 --- a/radicale/app/delete.py +++ b/radicale/app/delete.py @@ -23,6 +23,7 @@ from radicale import httputils, storage, types, xmlutils from radicale.app.base import Access, ApplicationBase +from radicale.hook import HookNotificationItem, HookNotificationItemTypes def xml_delete(base_prefix: str, path: str, collection: storage.BaseCollection, @@ -67,15 +68,33 @@ def do_DELETE(self, environ: types.WSGIEnviron, base_prefix: str, if if_match not in ("*", item.etag): # ETag precondition not verified, do not delete item return httputils.PRECONDITION_FAILED + hook_notification_item_list = [] if isinstance(item, storage.BaseCollection): if self._permit_delete_collection: + for i in item.get_all(): + hook_notification_item_list.append( + HookNotificationItem( + HookNotificationItemTypes.DELETE, + access.path, + i.uid + ) + ) xml_answer = xml_delete(base_prefix, path, item) else: return httputils.NOT_ALLOWED else: assert item.collection is not None assert item.href is not None + hook_notification_item_list.append( + HookNotificationItem( + HookNotificationItemTypes.DELETE, + access.path, + item.uid + ) + ) xml_answer = xml_delete( base_prefix, path, item.collection, item.href) + for notification_item in hook_notification_item_list: + self._hook.notify(notification_item) headers = {"Content-Type": "text/xml; charset=%s" % self._encoding} return client.OK, headers, self._xml_response(xml_answer) diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index 52d0b00b3..b1cfc197f 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -85,7 +85,7 @@ def xml_propfind_response( if isinstance(item, storage.BaseCollection): is_collection = True - is_leaf = item.tag in ("VADDRESSBOOK", "VCALENDAR") + is_leaf = item.tag in ("VADDRESSBOOK", "VCALENDAR", "VSUBSCRIBED") collection = item # Some clients expect collections to end with `/` uri = pathutils.unstrip_path(item.path, True) @@ -259,6 +259,10 @@ def xml_propfind_response( child_element = ET.Element( xmlutils.make_clark("C:calendar")) element.append(child_element) + elif collection.tag == "VSUBSCRIBED": + child_element = ET.Element( + xmlutils.make_clark("CS:subscribed")) + element.append(child_element) child_element = ET.Element(xmlutils.make_clark("D:collection")) element.append(child_element) elif tag == xmlutils.make_clark("RADICALE:displayname"): @@ -268,6 +272,12 @@ def xml_propfind_response( element.text = displayname else: is404 = True + elif tag == xmlutils.make_clark("RADICALE:getcontentcount"): + # Only for internal use by the web interface + if isinstance(item, storage.BaseCollection) and not collection.is_principal: + element.text = str(sum(1 for x in item.get_all())) + else: + is404 = True elif tag == xmlutils.make_clark("D:displayname"): displayname = collection.get_meta("D:displayname") if not displayname and is_leaf: @@ -286,6 +296,13 @@ def xml_propfind_response( element.text, _ = collection.sync() else: is404 = True + elif tag == xmlutils.make_clark("CS:source"): + if is_leaf: + child_element = ET.Element(xmlutils.make_clark("D:href")) + child_element.text = collection.get_meta('CS:source') + element.append(child_element) + else: + is404 = True else: human_tag = xmlutils.make_human_tag(tag) tag_text = collection.get_meta(human_tag) diff --git a/radicale/app/proppatch.py b/radicale/app/proppatch.py index 934f53b71..c15fddfe1 100644 --- a/radicale/app/proppatch.py +++ b/radicale/app/proppatch.py @@ -22,9 +22,12 @@ from http import client from typing import Dict, Optional, cast +import defusedxml.ElementTree as DefusedET + import radicale.item as radicale_item from radicale import httputils, storage, types, xmlutils from radicale.app.base import Access, ApplicationBase +from radicale.hook import HookNotificationItem, HookNotificationItemTypes from radicale.log import logger @@ -93,6 +96,16 @@ def do_PROPPATCH(self, environ: types.WSGIEnviron, base_prefix: str, try: xml_answer = xml_proppatch(base_prefix, path, xml_content, item) + if xml_content is not None: + hook_notification_item = HookNotificationItem( + HookNotificationItemTypes.CPATCH, + access.path, + DefusedET.tostring( + xml_content, + encoding=self._encoding + ).decode(encoding=self._encoding) + ) + self._hook.notify(hook_notification_item) except ValueError as e: logger.warning( "Bad PROPPATCH request on %r: %s", path, e, exc_info=True) diff --git a/radicale/app/put.py b/radicale/app/put.py index ec495878b..cf2a15fb6 100644 --- a/radicale/app/put.py +++ b/radicale/app/put.py @@ -30,6 +30,7 @@ import radicale.item as radicale_item from radicale import httputils, pathutils, rights, storage, types, xmlutils from radicale.app.base import Access, ApplicationBase +from radicale.hook import HookNotificationItem, HookNotificationItemTypes from radicale.log import logger MIMETYPE_TAGS: Mapping[str, str] = {value: key for key, value in @@ -206,6 +207,13 @@ def do_PUT(self, environ: types.WSGIEnviron, base_prefix: str, try: etag = self._storage.create_collection( path, prepared_items, props).etag + for item in prepared_items: + hook_notification_item = HookNotificationItem( + HookNotificationItemTypes.UPSERT, + access.path, + item.serialize() + ) + self._hook.notify(hook_notification_item) except ValueError as e: logger.warning( "Bad PUT request on %r: %s", path, e, exc_info=True) @@ -222,6 +230,12 @@ def do_PUT(self, environ: types.WSGIEnviron, base_prefix: str, href = posixpath.basename(pathutils.strip_path(path)) try: etag = parent_item.upload(href, prepared_item).etag + hook_notification_item = HookNotificationItem( + HookNotificationItemTypes.UPSERT, + access.path, + prepared_item.serialize() + ) + self._hook.notify(hook_notification_item) except ValueError as e: logger.warning( "Bad PUT request on %r: %s", path, e, exc_info=True) diff --git a/radicale/config.py b/radicale/config.py index bb2a0653a..19c0624d0 100644 --- a/radicale/config.py +++ b/radicale/config.py @@ -35,7 +35,7 @@ from typing import (Any, Callable, ClassVar, Iterable, List, Optional, Sequence, Tuple, TypeVar, Union) -from radicale import auth, rights, storage, types, web +from radicale import auth, hook, rights, storage, types, web DEFAULT_CONFIG_PATH: str = os.pathsep.join([ "?/etc/radicale/config", @@ -214,6 +214,24 @@ def _convert_to_bool(value: Any) -> bool: "value": "True", "help": "sync all changes to filesystem during requests", "type": bool})])), + ("hook", OrderedDict([ + ("type", { + "value": "none", + "help": "hook backend", + "type": str, + "internal": hook.INTERNAL_TYPES}), + ("rabbitmq_endpoint", { + "value": "", + "help": "endpoint where rabbitmq server is running", + "type": str}), + ("rabbitmq_topic", { + "value": "", + "help": "topic to declare queue", + "type": str}), + ("rabbitmq_queue_type", { + "value": "", + "help": "queue type for topic declaration", + "type": str})])), ("web", OrderedDict([ ("type", { "value": "internal", diff --git a/radicale/hook/__init__.py b/radicale/hook/__init__.py new file mode 100644 index 000000000..dc6b74c57 --- /dev/null +++ b/radicale/hook/__init__.py @@ -0,0 +1,60 @@ +import json +from enum import Enum +from typing import Sequence + +from radicale import pathutils, utils + +INTERNAL_TYPES: Sequence[str] = ("none", "rabbitmq") + + +def load(configuration): + """Load the storage module chosen in configuration.""" + return utils.load_plugin( + INTERNAL_TYPES, "hook", "Hook", BaseHook, configuration) + + +class BaseHook: + def __init__(self, configuration): + """Initialize BaseHook. + + ``configuration`` see ``radicale.config`` module. + The ``configuration`` must not change during the lifetime of + this object, it is kept as an internal reference. + + """ + self.configuration = configuration + + def notify(self, notification_item): + """Upload a new or replace an existing item.""" + raise NotImplementedError + + +class HookNotificationItemTypes(Enum): + CPATCH = "cpatch" + UPSERT = "upsert" + DELETE = "delete" + + +def _cleanup(path): + sane_path = pathutils.strip_path(path) + attributes = sane_path.split("/") if sane_path else [] + + if len(attributes) < 2: + return "" + return attributes[0] + "/" + attributes[1] + + +class HookNotificationItem: + + def __init__(self, notification_item_type, path, content): + self.type = notification_item_type.value + self.point = _cleanup(path) + self.content = content + + def to_json(self): + return json.dumps( + self, + default=lambda o: o.__dict__, + sort_keys=True, + indent=4 + ) diff --git a/radicale/hook/none.py b/radicale/hook/none.py new file mode 100644 index 000000000..b770ab67b --- /dev/null +++ b/radicale/hook/none.py @@ -0,0 +1,6 @@ +from radicale import hook + + +class Hook(hook.BaseHook): + def notify(self, notification_item): + """Notify nothing. Empty hook.""" diff --git a/radicale/hook/rabbitmq/__init__.py b/radicale/hook/rabbitmq/__init__.py new file mode 100644 index 000000000..2323ed43c --- /dev/null +++ b/radicale/hook/rabbitmq/__init__.py @@ -0,0 +1,50 @@ +import pika +from pika.exceptions import ChannelWrongStateError, StreamLostError + +from radicale import hook +from radicale.hook import HookNotificationItem +from radicale.log import logger + + +class Hook(hook.BaseHook): + + def __init__(self, configuration): + super().__init__(configuration) + self._endpoint = configuration.get("hook", "rabbitmq_endpoint") + self._topic = configuration.get("hook", "rabbitmq_topic") + self._queue_type = configuration.get("hook", "rabbitmq_queue_type") + self._encoding = configuration.get("encoding", "stock") + + self._make_connection_synced() + self._make_declare_queue_synced() + + def _make_connection_synced(self): + parameters = pika.URLParameters(self._endpoint) + connection = pika.BlockingConnection(parameters) + self._channel = connection.channel() + + def _make_declare_queue_synced(self): + self._channel.queue_declare(queue=self._topic, durable=True, arguments={"x-queue-type": self._queue_type}) + + def notify(self, notification_item): + if isinstance(notification_item, HookNotificationItem): + self._notify(notification_item, True) + + def _notify(self, notification_item, recall): + try: + self._channel.basic_publish( + exchange='', + routing_key=self._topic, + body=notification_item.to_json().encode( + encoding=self._encoding + ) + ) + except Exception as e: + if (isinstance(e, ChannelWrongStateError) or + isinstance(e, StreamLostError)) and recall: + self._make_connection_synced() + self._notify(notification_item, False) + return + logger.error("An exception occurred during " + "publishing hook notification item: %s", + e, exc_info=True) diff --git a/radicale/item/__init__.py b/radicale/item/__init__.py index b0cef2222..600a28da7 100644 --- a/radicale/item/__init__.py +++ b/radicale/item/__init__.py @@ -91,7 +91,7 @@ def check_and_sanitize_items( The ``tag`` of the collection. """ - if tag and tag not in ("VCALENDAR", "VADDRESSBOOK"): + if tag and tag not in ("VCALENDAR", "VADDRESSBOOK", "VSUBSCRIBED"): raise ValueError("Unsupported collection tag: %r" % tag) if not is_collection and len(vobject_items) != 1: raise ValueError("Item contains %d components" % len(vobject_items)) @@ -230,7 +230,7 @@ def check_and_sanitize_props(props: MutableMapping[Any, Any] raise ValueError("Value of %r must be %r not %r: %r" % ( k, str.__name__, type(v).__name__, v)) if k == "tag": - if v not in ("", "VCALENDAR", "VADDRESSBOOK"): + if v not in ("", "VCALENDAR", "VADDRESSBOOK", "VSUBSCRIBED"): raise ValueError("Unsupported collection tag: %r" % v) return props diff --git a/radicale/web/internal_data/css/icons/delete.svg b/radicale/web/internal_data/css/icons/delete.svg new file mode 100644 index 000000000..f8aa78561 --- /dev/null +++ b/radicale/web/internal_data/css/icons/delete.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/radicale/web/internal_data/css/icons/download.svg b/radicale/web/internal_data/css/icons/download.svg new file mode 100644 index 000000000..1ee311b51 --- /dev/null +++ b/radicale/web/internal_data/css/icons/download.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/radicale/web/internal_data/css/icons/edit.svg b/radicale/web/internal_data/css/icons/edit.svg new file mode 100644 index 000000000..0cfe935e1 --- /dev/null +++ b/radicale/web/internal_data/css/icons/edit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/radicale/web/internal_data/css/icons/new.svg b/radicale/web/internal_data/css/icons/new.svg new file mode 100644 index 000000000..d8448b8e6 --- /dev/null +++ b/radicale/web/internal_data/css/icons/new.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/radicale/web/internal_data/css/icons/upload.svg b/radicale/web/internal_data/css/icons/upload.svg new file mode 100644 index 000000000..2e05b18ca --- /dev/null +++ b/radicale/web/internal_data/css/icons/upload.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/radicale/web/internal_data/css/loading.svg b/radicale/web/internal_data/css/loading.svg new file mode 100644 index 000000000..3513ff672 --- /dev/null +++ b/radicale/web/internal_data/css/loading.svg @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/radicale/web/internal_data/css/logo.svg b/radicale/web/internal_data/css/logo.svg new file mode 100644 index 000000000..546d3d10d --- /dev/null +++ b/radicale/web/internal_data/css/logo.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/radicale/web/internal_data/css/main.css b/radicale/web/internal_data/css/main.css index 726b9a19b..a6d7da72c 100644 --- a/radicale/web/internal_data/css/main.css +++ b/radicale/web/internal_data/css/main.css @@ -1 +1,428 @@ -body{background:#e4e9f6;color:#424247;display:flex;flex-direction:column;font-family:sans;font-size:14pt;line-height:1.4;margin:0;min-height:100vh}a{color:inherit}nav,footer{background:#a40000;color:#fff;padding:0 20%}nav ul,footer ul{display:flex;flex-wrap:wrap;margin:0;padding:0}nav ul li,footer ul li{display:block;padding:0 1em 0 0}nav ul li a,footer ul li a{color:inherit;display:block;padding:1em .5em 1em 0;text-decoration:inherit;transition:.2s}nav ul li a:hover,nav ul li a:focus,footer ul li a:hover,footer ul li a:focus{color:#000;outline:none}header{background:url(logo.svg),linear-gradient(to bottom right, #050a02, #000);background-position:22% 45%;background-repeat:no-repeat;color:#efdddd;font-size:1.5em;min-height:250px;overflow:auto;padding:3em 22%;text-shadow:.2em .2em .2em rgba(0,0,0,0.5)}header>*{padding-left:220px}header h1{font-size:2.5em;font-weight:lighter;margin:.5em 0}main{flex:1}section{padding:0 20% 2em}section:not(:last-child){border-bottom:1px dashed #ccc}section h1{background:linear-gradient(to bottom right, #050a02, #000);color:#e5dddd;font-size:2.5em;margin:0 -33.33% 1em;padding:1em 33.33%}section h2,section h3,section h4{font-weight:lighter;margin:1.5em 0 1em}article{border-top:1px solid transparent;position:relative;margin:3em 0}article aside{box-sizing:border-box;color:#aaa;font-size:.8em;right:-30%;top:.5em;position:absolute}article:before{border-top:1px dashed #ccc;content:"";display:block;left:-33.33%;position:absolute;right:-33.33%}pre{border-radius:3px;background:#000;color:#d3d5db;margin:0 -1em;overflow-x:auto;padding:1em}table{border-collapse:collapse;font-size:.8em;margin:auto}table td{border:1px solid #ccc;padding:.5em}dl dt{margin-bottom:.5em;margin-top:1em}p>code,li>code,dt>code{background:#d1daf0}@media (max-width: 800px){body{font-size:12pt}header,section{padding-left:2em;padding-right:2em}nav,footer{padding-left:0;padding-right:0}nav ul,footer ul{justify-content:center}nav ul li,footer ul li{padding:0 .5em}nav ul li a,footer ul li a{padding:1em 0}header{background-position:50% 30px,0 0;padding-bottom:0;padding-top:330px;text-align:center}header>*{margin:0;padding-left:0}section h1{margin:0 -.8em 1.3em;padding:.5em 0;text-align:center}article aside{top:.5em;right:-1.5em}article:before{left:-2em;right:-2em}} +body{ + background: #ffffff; + color: #424247; + font-family: sans-serif; + font-size: 14pt; + margin: 0; + min-height: 100vh; + display: flex; + flex-wrap: wrap; + flex-direction: row; + align-content: center; + align-items: flex-start; + justify-content: space-around; +} + +main{ + width: 100%; +} + +.container{ + height: auto; + min-height: 450px; + width: 350px; + transition: .2s; + overflow: hidden; + padding: 20px 40px; + background: #fff; + border: 1px solid #dadce0; + border-radius: 8px; + display: block; + flex-shrink: 0; + margin: 0 auto; +} + +.container h1{ + margin: 0; + width: 100%; + text-align: center; + color: #484848; +} + +#loginscene input{ +} + + +#loginscene .logocontainer{ + width: 100%; + text-align: center; +} + +#loginscene .logocontainer img{ + width: 75px; +} + +#loginscene h1{ + text-align: center; + font-family: sans-serif; + font-weight: normal; +} + +#loginscene button{ + float: right; +} + +#loadingscene{ + width: 100%; + height: 100%; + background: rgb(237 237 237); + position: absolute; + top: 0; + left: 0; + display: flex; + flex-wrap: nowrap; + justify-content: center; + align-items: center; + flex-direction: column; + overflow: hidden; + z-index: 999; +} + +#loadingscene h2{ + font-size: 2em; + font-weight: bold; +} + +#logoutview{ + width: 100%; + display: block; + background: white; + text-align: center; + padding: 10px 0px; + color: #666; + border-bottom: 2px solid #dadce0; + position: fixed; +} + +#logoutview span{ + width: calc(100% - 60px); + display: inline-block; +} + +#logoutview a{ + color: white; + text-decoration: none; + padding: 3px 10px; + position: relative; + border-radius: 4px; +} + +#logoutview a[data-name=logout]{ + right: 25px; + float: right; +} + +#logoutview a[data-name=refresh]{ + left: 25px; + float: left; +} + +#collectionsscene{ + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-content: flex-start; + align-items: center; + margin-top: 50px; + width: 100%; + height: 100vh; +} + +#collectionsscene article{ + width: 275px; + background: rgb(250, 250, 250); + border-radius: 8px; + box-shadow: 2px 2px 3px #0000001a; + border: 1px solid #dadce0; + padding: 5px 10px; + padding-top: 0; + margin: 10px; + float: left; + min-height: 375px; + overflow: hidden; +} + +#collectionsscene article .colorbar{ + width: 500%; + height: 15px; + margin: 0px -100%; + background: #000000; +} + +#collectionsscene article .title{ + width: 100%; + text-align: center; + font-size: 1.5em; + display: block; + padding: 10px 0; + margin: 0; +} + +#collectionsscene article small{ + font-size: 15px; + float: left; + font-weight: normal; + font-style: italic; + padding-bottom: 10px; + width: 100%; + text-align: center; +} + +#collectionsscene article input[type=text]{ + margin-bottom: 0 !important; +} + +#collectionsscene article p{ + font-size: 1em; + max-height: 130px; + overflow: overlay; +} + +#collectionsscene article:hover ul{ + visibility: visible; +} + +#collectionsscene ul{ + visibility: hidden; + display: flex; + justify-content: space-evenly; + width: 60%; + margin: 0 20%; + padding: 0; +} + +#collectionsscene li{ + list-style: none; + display: block; +} + +#collectionsscene li a{ + text-decoration: none !important; + padding: 5px; + float: left; + border-radius: 5px; + width: 25px; + height: 25px; + text-align: center; +} + +#collectionsscene article small[data-name=contentcount]{ + font-weight: bold; + font-style: normal; +} + +#editcollectionscene p span{ + word-wrap:break-word; + font-weight: bold; + color: #4e9a06; +} + +#deletecollectionscene p span{ + word-wrap:break-word; + font-weight: bold; + color: #a40000; +} + +#uploadcollectionscene ul{ + margin: 10px -30px; + max-height: 600px; + overflow-y: scroll; +} + +#uploadcollectionscene li{ + border-bottom: 1px dashed #d5d5d5; + margin-bottom: 10px; + padding-bottom: 10px; +} + +#uploadcollectionscene div[data-name=pending]{ + width: 100%; + text-align: center; +} + +#uploadcollectionscene .successmessage{ + color: #4e9a06; + width: 100%; + text-align: center; + display: block; + margin-top: 15px; +} + +.deleteconfirmationtxt{ + text-align: center; + font-size: 1em; + font-weight: bold; +} + +.fabcontainer{ + display: flex; + flex-direction: column-reverse; + position: fixed; + bottom: 5px; + right: 0; +} + +.fabcontainer a{ + width: 30px; + height: 30px; + text-decoration: none; + color: white; + border: none !important; + border-radius: 100%; + margin: 5px 10px; + background: black; + text-align: center; + display: flex; + align-content: center; + justify-content: center; + align-items: center; + font-size: 30px; + padding: 10px; + box-shadow: 2px 2px 7px #000000d6; +} + +.title{ + word-wrap: break-word; + font-weight: bold; +} + +.icon{ + width: 100%; + height: 100%; + filter: invert(1); +} + +.smalltext{ + font-size: 75% !important; +} + +.error{ + width: 100%; + display: block; + text-align: center; + color: rgb(217,48,37); + font-family: sans-serif; + clear: both; + padding-top: 15px; +} + +img.loading{ + width: 150px; + height: 150px; +} + +.error::before{ + content: "!"; + height: 1em; + color: white; + background: rgb(217,48,37); + font-weight: bold; + border-radius: 100%; + display: inline-block; + width: 1.1em; + margin-right: 5px; + font-size: 1em; + text-align: center; +} + +button{ + font-size: 1em; + padding: 7px 21px; + color: white; + border-radius: 4px; + float: right; + margin-left: 10px; + background: black; + cursor: pointer; +} + +input, select{ + width: 100%; + height: 3em; + border-style: solid; + border-color: #e6e6e6; + border-width: 1px; + border-radius: 7px; + margin-bottom: 25px; + padding-left: 15px; + padding-right: 15px; + outline: none !important; +} + +input[type=text], input[type=password]{ + width: calc(100% - 30px); +} + +input:active, input:focus, input:focus-visible{ + border-color: #2494fe !important; + border-width: 1px !important; +} + +p.red, span.red{ + color: #b50202; +} + +button.red, a.red{ + background: #b50202; + border: 1px solid #a40000; +} + +button.red:hover, a.red:hover{ + background: #a40000; +} + +button.red:active, a.red:active{ + background: #8f0000; +} + +button.green, a.green{ + background: #4e9a06; + border: 1px solid #377200; +} + +button.green:hover, a.green:hover{ + background: #377200; +} + +button.green:active, a.green:active{ + background: #285200; +} + +button.blue, a.blue{ + background: #2494fe; + border: 1px solid #055fb5; +} + +button.blue:hover, a.blue:hover{ + background: #1578d6; + cursor: pointer !important; +} + +button.blue:active, a.blue:active{ + background: #055fb5; + cursor: pointer !important; +} + +@media only screen and (max-width: 600px) { + #collectionsscene{ + flex-direction: column !important; + flex-wrap: nowrap; + } + + #collectionsscene article{ + height: auto; + min-height: 375px; + } + + .container{ + max-width: 280px !important; + } + + #collectionsscene ul{ + visibility: visible !important; + } + + #logoutview span{ + padding: 0 5px; + } +} diff --git a/radicale/web/internal_data/fn.js b/radicale/web/internal_data/fn.js index 241f5a515..c297f9ac5 100644 --- a/radicale/web/internal_data/fn.js +++ b/radicale/web/internal_data/fn.js @@ -1,6 +1,6 @@ /** * This file is part of Radicale Server - Calendar Server - * Copyright © 2017-2018 Unrud + * Copyright © 2017-2024 Unrud * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -28,7 +28,7 @@ const SERVER = location.origin; * @const * @type {string} */ -const ROOT_PATH = (new URL("..", location.href)).pathname; +const ROOT_PATH = location.pathname.replace(new RegExp("/+[^/]+/*(/index\\.html?)?$"), "") + '/'; /** * Regex to match and normalize color @@ -36,6 +36,13 @@ const ROOT_PATH = (new URL("..", location.href)).pathname; */ const COLOR_RE = new RegExp("^(#[0-9A-Fa-f]{6})(?:[0-9A-Fa-f]{2})?$"); + +/** + * The text needed to confirm deleting a collection + * @const + */ +const DELETE_CONFIRMATION_TEXT = "DELETE"; + /** * Escape string for usage in XML * @param {string} s @@ -63,6 +70,7 @@ const CollectionType = { CALENDAR: "CALENDAR", JOURNAL: "JOURNAL", TASKS: "TASKS", + WEBCAL: "WEBCAL", is_subset: function(a, b) { let components = a.split("_"); for (let i = 0; i < components.length; i++) { @@ -89,7 +97,27 @@ const CollectionType = { if (a.search(this.TASKS) !== -1 || b.search(this.TASKS) !== -1) { union.push(this.TASKS); } + if (a.search(this.WEBCAL) !== -1 || b.search(this.WEBCAL) !== -1) { + union.push(this.WEBCAL); + } return union.join("_"); + }, + valid_options_for_type: function(a){ + a = a.trim().toUpperCase(); + switch(a){ + case CollectionType.CALENDAR_JOURNAL_TASKS: + case CollectionType.CALENDAR_JOURNAL: + case CollectionType.CALENDAR_TASKS: + case CollectionType.JOURNAL_TASKS: + case CollectionType.CALENDAR: + case CollectionType.JOURNAL: + case CollectionType.TASKS: + return [CollectionType.CALENDAR_JOURNAL_TASKS, CollectionType.CALENDAR_JOURNAL, CollectionType.CALENDAR_TASKS, CollectionType.JOURNAL_TASKS, CollectionType.CALENDAR, CollectionType.JOURNAL, CollectionType.TASKS]; + case CollectionType.ADDRESSBOOK: + case CollectionType.WEBCAL: + default: + return [a]; + } } }; @@ -102,12 +130,15 @@ const CollectionType = { * @param {string} description * @param {string} color */ -function Collection(href, type, displayname, description, color) { +function Collection(href, type, displayname, description, color, contentcount, size, source) { this.href = href; this.type = type; this.displayname = displayname; this.color = color; this.description = description; + this.source = source; + this.contentcount = contentcount; + this.size = size; } /** @@ -134,6 +165,7 @@ function get_principal(user, password, callback) { CollectionType.PRINCIPAL, displayname_element ? displayname_element.textContent : "", "", + 0, ""), null); } else { callback(null, "Internal error"); @@ -183,6 +215,9 @@ function get_collections(user, password, collection, callback) { let addressbookcolor_element = response.querySelector(response_query + " > *|propstat > *|prop > *|addressbook-color"); let calendardesc_element = response.querySelector(response_query + " > *|propstat > *|prop > *|calendar-description"); let addressbookdesc_element = response.querySelector(response_query + " > *|propstat > *|prop > *|addressbook-description"); + let contentcount_element = response.querySelector(response_query + " > *|propstat > *|prop > *|getcontentcount"); + let contentlength_element = response.querySelector(response_query + " > *|propstat > *|prop > *|getcontentlength"); + let webcalsource_element = response.querySelector(response_query + " > *|propstat > *|prop > *|source"); let components_query = response_query + " > *|propstat > *|prop > *|supported-calendar-component-set"; let components_element = response.querySelector(components_query); let href = href_element ? href_element.textContent : ""; @@ -190,11 +225,21 @@ function get_collections(user, password, collection, callback) { let type = ""; let color = ""; let description = ""; + let source = ""; + let count = 0; + let size = 0; if (resourcetype_element) { if (resourcetype_element.querySelector(resourcetype_query + " > *|addressbook")) { type = CollectionType.ADDRESSBOOK; color = addressbookcolor_element ? addressbookcolor_element.textContent : ""; description = addressbookdesc_element ? addressbookdesc_element.textContent : ""; + count = contentcount_element ? parseInt(contentcount_element.textContent) : 0; + size = contentlength_element ? parseInt(contentlength_element.textContent) : 0; + } else if (resourcetype_element.querySelector(resourcetype_query + " > *|subscribed")) { + type = CollectionType.WEBCAL; + source = webcalsource_element ? webcalsource_element.textContent : ""; + color = calendarcolor_element ? calendarcolor_element.textContent : ""; + description = calendardesc_element ? calendardesc_element.textContent : ""; } else if (resourcetype_element.querySelector(resourcetype_query + " > *|calendar")) { if (components_element) { if (components_element.querySelector(components_query + " > *|comp[name=VEVENT]")) { @@ -209,6 +254,8 @@ function get_collections(user, password, collection, callback) { } color = calendarcolor_element ? calendarcolor_element.textContent : ""; description = calendardesc_element ? calendardesc_element.textContent : ""; + count = contentcount_element ? parseInt(contentcount_element.textContent) : 0; + size = contentlength_element ? parseInt(contentlength_element.textContent) : 0; } } let sane_color = color.trim(); @@ -221,7 +268,7 @@ function get_collections(user, password, collection, callback) { } } if (href.substr(-1) === "/" && href !== collection.href && type) { - collections.push(new Collection(href, type, displayname, description, sane_color)); + collections.push(new Collection(href, type, displayname, description, sane_color, count, size, source)); } } collections.sort(function(a, b) { @@ -235,11 +282,15 @@ function get_collections(user, password, collection, callback) { } }; request.send('' + - '' + + 'xmlns:RADICALE="http://radicale.org/ns/"' + + '>' + '' + '' + '' + @@ -248,6 +299,9 @@ function get_collections(user, password, collection, callback) { '' + '' + '' + + '' + + '' + + '' + '' + ''); return request; @@ -329,12 +383,18 @@ function create_edit_collection(user, password, collection, create, callback) { let addressbook_color = ""; let calendar_description = ""; let addressbook_description = ""; + let calendar_source = ""; let resourcetype; let components = ""; if (collection.type === CollectionType.ADDRESSBOOK) { addressbook_color = escape_xml(collection.color + (collection.color ? "ff" : "")); addressbook_description = escape_xml(collection.description); resourcetype = ''; + } else if (collection.type === CollectionType.WEBCAL) { + calendar_color = escape_xml(collection.color + (collection.color ? "ff" : "")); + calendar_description = escape_xml(collection.description); + resourcetype = ''; + calendar_source = collection.source; } else { calendar_color = escape_xml(collection.color + (collection.color ? "ff" : "")); calendar_description = escape_xml(collection.description); @@ -351,7 +411,7 @@ function create_edit_collection(user, password, collection, create, callback) { } let xml_request = create ? "mkcol" : "propertyupdate"; request.send('' + - '<' + xml_request + ' xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CR="urn:ietf:params:xml:ns:carddav" xmlns:I="http://apple.com/ns/ical/" xmlns:INF="http://inf-it.com/ns/ab/">' + + '<' + xml_request + ' xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CR="urn:ietf:params:xml:ns:carddav" xmlns:CS="http://calendarserver.org/ns/" xmlns:I="http://apple.com/ns/ical/" xmlns:INF="http://inf-it.com/ns/ab/">' + '' + '' + (create ? '' + resourcetype + '' : '') + @@ -361,6 +421,7 @@ function create_edit_collection(user, password, collection, create, callback) { (addressbook_color ? '' + addressbook_color + '' : '') + (addressbook_description ? '' + addressbook_description + '' : '') + (calendar_description ? '' + calendar_description + '' : '') + + (calendar_source ? '' + calendar_source + '' : '') + '' + '' + (!create ? ('' + @@ -481,7 +542,8 @@ function LoginScene() { let error_form = html_scene.querySelector("[data-name=error]"); let logout_view = document.getElementById("logoutview"); let logout_user_form = logout_view.querySelector("[data-name=user]"); - let logout_btn = logout_view.querySelector("[data-name=link]"); + let logout_btn = logout_view.querySelector("[data-name=logout]"); + let refresh_btn = logout_view.querySelector("[data-name=refresh]"); /** @type {?number} */ let scene_index = null; let user = ""; @@ -495,7 +557,12 @@ function LoginScene() { function fill_form() { user_form.value = user; password_form.value = ""; - error_form.textContent = error ? "Error: " + error : ""; + if(error){ + error_form.textContent = "Error: " + error; + error_form.classList.remove("hidden"); + }else{ + error_form.classList.add("hidden"); + } } function onlogin() { @@ -507,7 +574,8 @@ function LoginScene() { // setup logout logout_view.classList.remove("hidden"); logout_btn.onclick = onlogout; - logout_user_form.textContent = user; + refresh_btn.onclick = refresh; + logout_user_form.textContent = user + "'s Collections"; // Fetch principal let loading_scene = new LoadingScene(); push_scene(loading_scene, false); @@ -557,9 +625,17 @@ function LoginScene() { function remove_logout() { logout_view.classList.add("hidden"); logout_btn.onclick = null; + refresh_btn.onclick = null; logout_user_form.textContent = ""; } + function refresh(){ + //The easiest way to refresh is to push a LoadingScene onto the stack and then pop it + //forcing the scene below it, the Collections Scene to refresh itself. + push_scene(new LoadingScene(), false); + pop_scene(scene_stack.length-2); + } + this.show = function() { remove_logout(); fill_form(); @@ -618,12 +694,6 @@ function CollectionsScene(user, password, collection, onerror) { /** @type {?XMLHttpRequest} */ let collections_req = null; /** @type {?Array} */ let collections = null; /** @type {Array} */ let nodes = []; - let filesInput = document.createElement("input"); - filesInput.setAttribute("type", "file"); - filesInput.setAttribute("accept", ".ics, .vcf"); - filesInput.setAttribute("multiple", ""); - let filesInputForm = document.createElement("form"); - filesInputForm.appendChild(filesInput); function onnew() { try { @@ -636,17 +706,9 @@ function CollectionsScene(user, password, collection, onerror) { } function onupload() { - filesInput.click(); - return false; - } - - function onfileschange() { try { - let files = filesInput.files; - if (files.length > 0) { - let upload_scene = new UploadCollectionScene(user, password, collection, files); - push_scene(upload_scene); - } + let upload_scene = new UploadCollectionScene(user, password, collection); + push_scene(upload_scene); } catch(err) { console.error(err); } @@ -674,21 +736,24 @@ function CollectionsScene(user, password, collection, onerror) { } function show_collections(collections) { + let heightOfNavBar = document.querySelector("#logoutview").offsetHeight + "px"; + html_scene.style.marginTop = heightOfNavBar; + html_scene.style.height = "calc(100vh - " + heightOfNavBar +")"; collections.forEach(function (collection) { let node = template.cloneNode(true); node.classList.remove("hidden"); let title_form = node.querySelector("[data-name=title]"); let description_form = node.querySelector("[data-name=description]"); + let contentcount_form = node.querySelector("[data-name=contentcount]"); let url_form = node.querySelector("[data-name=url]"); let color_form = node.querySelector("[data-name=color]"); let delete_btn = node.querySelector("[data-name=delete]"); let edit_btn = node.querySelector("[data-name=edit]"); + let download_btn = node.querySelector("[data-name=download]"); if (collection.color) { - color_form.style.color = collection.color; - } else { - color_form.classList.add("hidden"); + color_form.style.background = collection.color; } - let possible_types = [CollectionType.ADDRESSBOOK]; + let possible_types = [CollectionType.ADDRESSBOOK, CollectionType.WEBCAL]; [CollectionType.CALENDAR, ""].forEach(function(e) { [CollectionType.union(e, CollectionType.JOURNAL), e].forEach(function(e) { [CollectionType.union(e, CollectionType.TASKS), e].forEach(function(e) { @@ -704,10 +769,26 @@ function CollectionsScene(user, password, collection, onerror) { } }); title_form.textContent = collection.displayname || collection.href; + if(title_form.textContent.length > 30){ + title_form.classList.add("smalltext"); + } description_form.textContent = collection.description; + if(description_form.textContent.length > 150){ + description_form.classList.add("smalltext"); + } + if(collection.type != CollectionType.WEBCAL){ + let contentcount_form_txt = (collection.contentcount > 0 ? Number(collection.contentcount).toLocaleString() : "No") + " item" + (collection.contentcount == 1 ? "" : "s") + " in collection"; + if(collection.contentcount > 0){ + contentcount_form_txt += " (" + bytesToHumanReadable(collection.size) + ")"; + } + contentcount_form.textContent = contentcount_form_txt; + } let href = SERVER + collection.href; - url_form.href = href; - url_form.textContent = href; + url_form.value = href; + download_btn.href = href; + if(collection.type == CollectionType.WEBCAL){ + download_btn.parentElement.classList.add("hidden"); + } delete_btn.onclick = function() {return ondelete(collection);}; edit_btn.onclick = function() {return onedit(collection);}; node.classList.remove("hidden"); @@ -738,8 +819,6 @@ function CollectionsScene(user, password, collection, onerror) { html_scene.classList.remove("hidden"); new_btn.onclick = onnew; upload_btn.onclick = onupload; - filesInputForm.reset(); - filesInput.onchange = onfileschange; if (collections === null) { update(); } else { @@ -752,7 +831,6 @@ function CollectionsScene(user, password, collection, onerror) { scene_index = scene_stack.length - 1; new_btn.onclick = null; upload_btn.onclick = null; - filesInput.onchange = null; collections = null; // remove collection nodes.forEach(function(node) { @@ -767,7 +845,6 @@ function CollectionsScene(user, password, collection, onerror) { collections_req = null; } collections = null; - filesInputForm.reset(); }; } @@ -779,41 +856,87 @@ function CollectionsScene(user, password, collection, onerror) { * @param {Collection} collection parent collection * @param {Array} files */ -function UploadCollectionScene(user, password, collection, files) { +function UploadCollectionScene(user, password, collection) { let html_scene = document.getElementById("uploadcollectionscene"); let template = html_scene.querySelector("[data-name=filetemplate]"); + let upload_btn = html_scene.querySelector("[data-name=submit]"); let close_btn = html_scene.querySelector("[data-name=close]"); + let uploadfile_form = html_scene.querySelector("[data-name=uploadfile]"); + let uploadfile_lbl = html_scene.querySelector("label[for=uploadfile]"); + let href_form = html_scene.querySelector("[data-name=href]"); + let href_label = html_scene.querySelector("label[for=href]"); + let hreflimitmsg_html = html_scene.querySelector("[data-name=hreflimitmsg]"); + let pending_html = html_scene.querySelector("[data-name=pending]"); + + let files = uploadfile_form.files; + href_form.addEventListener("keydown", cleanHREFinput); + upload_btn.onclick = upload_start; + uploadfile_form.onchange = onfileschange; + + let href = random_uuid(); + href_form.value = href; /** @type {?number} */ let scene_index = null; /** @type {?XMLHttpRequest} */ let upload_req = null; - /** @type {Array} */ let errors = []; + /** @type {Array} */ let results = []; /** @type {?Array} */ let nodes = null; - function upload_next() { + function upload_start() { try { - if (files.length === errors.length) { - if (errors.every(error => error === null)) { - pop_scene(scene_index - 1); - } else { - close_btn.classList.remove("hidden"); - } + if(!read_form()){ + return false; + } + uploadfile_form.classList.add("hidden"); + uploadfile_lbl.classList.add("hidden"); + href_form.classList.add("hidden"); + href_label.classList.add("hidden"); + hreflimitmsg_html.classList.add("hidden"); + upload_btn.classList.add("hidden"); + close_btn.classList.add("hidden"); + + pending_html.classList.remove("hidden"); + + nodes = []; + for (let i = 0; i < files.length; i++) { + let file = files[i]; + let node = template.cloneNode(true); + node.classList.remove("hidden"); + let name_form = node.querySelector("[data-name=name]"); + name_form.textContent = file.name; + node.classList.remove("hidden"); + nodes.push(node); + updateFileStatus(i); + template.parentNode.insertBefore(node, template); + } + upload_next(); + } catch(err) { + console.error(err); + } + return false; + } + + function upload_next(){ + try{ + if (files.length === results.length) { + pending_html.classList.add("hidden"); + close_btn.classList.remove("hidden"); + return; } else { - let file = files[errors.length]; - let upload_href = collection.href + random_uuid() + "/"; - upload_req = upload_collection(user, password, upload_href, file, function(error) { - if (scene_index === null) { - return; - } + let file = files[results.length]; + if(files.length > 1 || href.length == 0){ + href = random_uuid(); + } + let upload_href = collection.href + "/" + href + "/"; + upload_req = upload_collection(user, password, upload_href, file, function(result) { upload_req = null; - errors.push(error); - updateFileStatus(errors.length - 1); + results.push(result); + updateFileStatus(results.length - 1); upload_next(); }); } - } catch(err) { + }catch(err){ console.error(err); } - return false; } function onclose() { @@ -829,54 +952,77 @@ function UploadCollectionScene(user, password, collection, files) { if (nodes === null) { return; } - let pending_form = nodes[i].querySelector("[data-name=pending]"); let success_form = nodes[i].querySelector("[data-name=success]"); let error_form = nodes[i].querySelector("[data-name=error]"); - if (errors.length > i) { - pending_form.classList.add("hidden"); - if (errors[i]) { + if (results.length > i) { + if (results[i]) { success_form.classList.add("hidden"); - error_form.textContent = "Error: " + errors[i]; + error_form.textContent = "Error: " + results[i]; error_form.classList.remove("hidden"); } else { success_form.classList.remove("hidden"); error_form.classList.add("hidden"); } } else { - pending_form.classList.remove("hidden"); success_form.classList.add("hidden"); error_form.classList.add("hidden"); } } - this.show = function() { - html_scene.classList.remove("hidden"); - if (errors.length < files.length) { - close_btn.classList.add("hidden"); + function read_form() { + cleanHREFinput(href_form); + let newhreftxtvalue = href_form.value.trim().toLowerCase(); + if(!isValidHREF(newhreftxtvalue)){ + alert("You must enter a valid HREF"); + return false; } - close_btn.onclick = onclose; - nodes = []; - for (let i = 0; i < files.length; i++) { - let file = files[i]; - let node = template.cloneNode(true); - node.classList.remove("hidden"); - let name_form = node.querySelector("[data-name=name]"); - name_form.textContent = file.name; - node.classList.remove("hidden"); - nodes.push(node); - updateFileStatus(i); - template.parentNode.insertBefore(node, template); + href = newhreftxtvalue; + + if(uploadfile_form.files.length == 0){ + alert("You must select at least one file to upload"); + return false; } - if (scene_index === null) { - scene_index = scene_stack.length - 1; - upload_next(); + files = uploadfile_form.files; + return true; + } + + function onfileschange() { + files = uploadfile_form.files; + if(files.length > 1){ + hreflimitmsg_html.classList.remove("hidden"); + href_form.classList.add("hidden"); + href_label.classList.add("hidden"); + }else{ + hreflimitmsg_html.classList.add("hidden"); + href_form.classList.remove("hidden"); + href_label.classList.remove("hidden"); } + return false; + } + + this.show = function() { + scene_index = scene_stack.length - 1; + html_scene.classList.remove("hidden"); + close_btn.onclick = onclose; }; this.hide = function() { html_scene.classList.add("hidden"); close_btn.classList.remove("hidden"); + upload_btn.classList.remove("hidden"); + uploadfile_form.classList.remove("hidden"); + uploadfile_lbl.classList.remove("hidden"); + href_form.classList.remove("hidden"); + href_label.classList.remove("hidden"); + hreflimitmsg_html.classList.add("hidden"); + pending_html.classList.add("hidden"); close_btn.onclick = null; + upload_btn.onclick = null; + href_form.value = ""; + uploadfile_form.value = ""; + if(nodes == null){ + return; + } nodes.forEach(function(node) { node.parentNode.removeChild(node); }); @@ -902,14 +1048,25 @@ function DeleteCollectionScene(user, password, collection) { let html_scene = document.getElementById("deletecollectionscene"); let title_form = html_scene.querySelector("[data-name=title]"); let error_form = html_scene.querySelector("[data-name=error]"); + let confirmation_txt = html_scene.querySelector("[data-name=confirmationtxt]"); + let delete_confirmation_lbl = html_scene.querySelector("[data-name=deleteconfirmationtext]"); let delete_btn = html_scene.querySelector("[data-name=delete]"); let cancel_btn = html_scene.querySelector("[data-name=cancel]"); + delete_confirmation_lbl.innerHTML = DELETE_CONFIRMATION_TEXT; + confirmation_txt.value = ""; + confirmation_txt.addEventListener("keydown", onkeydown); + /** @type {?number} */ let scene_index = null; /** @type {?XMLHttpRequest} */ let delete_req = null; let error = ""; function ondelete() { + let confirmation_text_value = confirmation_txt.value; + if(confirmation_text_value != DELETE_CONFIRMATION_TEXT){ + alert("Please type the confirmation text to delete this collection."); + return; + } try { let loading_scene = new LoadingScene(); push_scene(loading_scene); @@ -940,14 +1097,27 @@ function DeleteCollectionScene(user, password, collection) { return false; } + function onkeydown(event){ + if (event.keyCode !== 13) { + return; + } + ondelete(); + } + this.show = function() { this.release(); scene_index = scene_stack.length - 1; html_scene.classList.remove("hidden"); title_form.textContent = collection.displayname || collection.href; - error_form.textContent = error ? "Error: " + error : ""; delete_btn.onclick = ondelete; cancel_btn.onclick = oncancel; + if(error){ + error_form.textContent = "Error: " + error; + error_form.classList.remove("hidden"); + }else{ + error_form.classList.add("hidden"); + } + }; this.hide = function() { html_scene.classList.add("hidden"); @@ -988,13 +1158,22 @@ function CreateEditCollectionScene(user, password, collection) { let html_scene = document.getElementById(edit ? "editcollectionscene" : "createcollectionscene"); let title_form = edit ? html_scene.querySelector("[data-name=title]") : null; let error_form = html_scene.querySelector("[data-name=error]"); + let href_form = html_scene.querySelector("[data-name=href]"); + let href_label = html_scene.querySelector("label[for=href]"); let displayname_form = html_scene.querySelector("[data-name=displayname]"); + let displayname_label = html_scene.querySelector("label[for=displayname]"); let description_form = html_scene.querySelector("[data-name=description]"); + let description_label = html_scene.querySelector("label[for=description]"); + let source_form = html_scene.querySelector("[data-name=source]"); + let source_label = html_scene.querySelector("label[for=source]"); let type_form = html_scene.querySelector("[data-name=type]"); + let type_label = html_scene.querySelector("label[for=type]"); let color_form = html_scene.querySelector("[data-name=color]"); + let color_label = html_scene.querySelector("label[for=color]"); let submit_btn = html_scene.querySelector("[data-name=submit]"); let cancel_btn = html_scene.querySelector("[data-name=cancel]"); + /** @type {?number} */ let scene_index = null; /** @type {?XMLHttpRequest} */ let create_edit_req = null; let error = ""; @@ -1003,40 +1182,69 @@ function CreateEditCollectionScene(user, password, collection) { let href = edit ? collection.href : collection.href + random_uuid() + "/"; let displayname = edit ? collection.displayname : ""; let description = edit ? collection.description : ""; + let source = edit ? collection.source : ""; let type = edit ? collection.type : CollectionType.CALENDAR_JOURNAL_TASKS; let color = edit && collection.color ? collection.color : "#" + random_hex(6); + if(!edit){ + href_form.addEventListener("keydown", cleanHREFinput); + } + function remove_invalid_types() { if (!edit) { return; } /** @type {HTMLOptionsCollection} */ let options = type_form.options; // remove all options that are not supersets + let valid_type_options = CollectionType.valid_options_for_type(type); for (let i = options.length - 1; i >= 0; i--) { - if (!CollectionType.is_subset(type, options[i].value)) { + if (valid_type_options.indexOf(options[i].value) < 0) { options.remove(i); } } } function read_form() { + if(!edit){ + cleanHREFinput(href_form); + let newhreftxtvalue = href_form.value.trim().toLowerCase(); + if(!isValidHREF(newhreftxtvalue)){ + alert("You must enter a valid HREF"); + return false; + } + href = collection.href + "/" + newhreftxtvalue + "/"; + } displayname = displayname_form.value; description = description_form.value; + source = source_form.value; type = type_form.value; color = color_form.value; + return true; } function fill_form() { + if(!edit){ + href_form.value = random_uuid(); + } displayname_form.value = displayname; description_form.value = description; + source_form.value = source; type_form.value = type; color_form.value = color; - error_form.textContent = error ? "Error: " + error : ""; + if(error){ + error_form.textContent = "Error: " + error; + error_form.classList.remove("hidden"); + } + error_form.classList.add("hidden"); + onTypeChange(); + type_form.addEventListener("change", onTypeChange); } function onsubmit() { try { - read_form(); + if(!read_form()){ + return false; + } let sane_color = color.trim(); if (sane_color) { let color_match = COLOR_RE.exec(sane_color); @@ -1049,7 +1257,7 @@ function CreateEditCollectionScene(user, password, collection) { } let loading_scene = new LoadingScene(); push_scene(loading_scene); - let collection = new Collection(href, type, displayname, description, sane_color); + let collection = new Collection(href, type, displayname, description, sane_color, 0, 0, source); let callback = function(error1) { if (scene_index === null) { return; @@ -1082,6 +1290,17 @@ function CreateEditCollectionScene(user, password, collection) { return false; } + + function onTypeChange(e){ + if(type_form.value == CollectionType.WEBCAL){ + source_label.classList.remove("hidden"); + source_form.classList.remove("hidden"); + }else{ + source_label.classList.add("hidden"); + source_form.classList.add("hidden"); + } + } + this.show = function() { this.release(); scene_index = scene_stack.length - 1; @@ -1117,6 +1336,57 @@ function CreateEditCollectionScene(user, password, collection) { }; } +/** + * Removed invalid HREF characters for a collection HREF. + * + * @param a A valid Input element or an onchange Event of an Input element. + */ +function cleanHREFinput(a) { + let href_form = a; + if (a.target) { + href_form = a.target; + } + let currentTxtVal = href_form.value.trim().toLowerCase(); + //Clean the HREF to remove non lowercase letters and dashes + currentTxtVal = currentTxtVal.replace(/(?![0-9a-z\-\_])./g, ''); + href_form.value = currentTxtVal; +} + +/** + * Checks if a proposed HREF for a collection has a valid format and syntax. + * + * @param href String of the porposed HREF. + * + * @return Boolean results if the HREF is valid. + */ +function isValidHREF(href) { + if (href.length < 1) { + return false; + } + if (href.indexOf("/") != -1) { + return false; + } + + return true; +} + +/** + * Format bytes to human-readable text. + * + * @param bytes Number of bytes. + * + * @return Formatted string. + */ +function bytesToHumanReadable(bytes, dp=1) { + let isNumber = !isNaN(parseFloat(bytes)) && !isNaN(bytes - 0); + if(!isNumber){ + return ""; + } + var i = bytes == 0 ? 0 : Math.floor(Math.log(bytes) / Math.log(1024)); + return (bytes / Math.pow(1024, i)).toFixed(dp) * 1 + ' ' + ['b', 'kb', 'mb', 'gb', 'tb'][i]; +} + + function main() { // Hide startup loading message document.getElementById("loadingscene").classList.add("hidden"); diff --git a/radicale/web/internal_data/index.html b/radicale/web/internal_data/index.html index ea2942669..7806765f1 100644 --- a/radicale/web/internal_data/index.html +++ b/radicale/web/internal_data/index.html @@ -1,70 +1,97 @@ + + + + + Radicale Web Interface + + + + + - - - - - -Radicale Web Interface - - - - + + - - +
+
+ Loading... +

Loading

+

Please wait...

+ +
-
-
-

Loading

-

Please wait...

- -
+ - + - - - + + + + + + + + + + +
+ +
+ + + + - + - + - -
- + +
+ diff --git a/radicale/xmlutils.py b/radicale/xmlutils.py index 09508d9c4..4b9c51bfc 100644 --- a/radicale/xmlutils.py +++ b/radicale/xmlutils.py @@ -33,7 +33,8 @@ MIMETYPES: Mapping[str, str] = { "VADDRESSBOOK": "text/vcard", - "VCALENDAR": "text/calendar"} + "VCALENDAR": "text/calendar", + "VSUBSCRIBED": "text/calendar"} OBJECT_MIMETYPES: Mapping[str, str] = { "VCARD": "text/vcard", @@ -177,6 +178,9 @@ def props_from_request(xml_request: Optional[ET.Element] if resource_type.tag == make_clark("C:calendar"): value = "VCALENDAR" break + if resource_type.tag == make_clark("CS:subscribed"): + value = "VSUBSCRIBED" + break if resource_type.tag == make_clark("CR:addressbook"): value = "VADDRESSBOOK" break diff --git a/setup.py b/setup.py index e6c2c4dfe..8a1157615 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,7 @@ install_requires = ["defusedxml", "passlib", "vobject>=0.9.6", "python-dateutil>=2.7.3", + "pika>=1.1.0", "setuptools; python_version<'3.9'"] bcrypt_requires = ["bcrypt"] # typeguard requires pytest<7