Procházet zdrojové kódy

Merge branch 'v3.2-devel'

Peter Bieringer před 1 rokem
rodič
revize
2741d73d68

+ 27 - 1
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"
 

+ 35 - 1
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:
 

+ 9 - 0
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

+ 4 - 2
radicale/app/base.py

@@ -21,8 +21,8 @@ import sys
 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 @@ class ApplicationBase:
         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]:

+ 19 - 0
radicale/app/delete.py

@@ -23,6 +23,7 @@ from typing import Optional
 
 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 @@ class ApplicationPartDelete(ApplicationBase):
             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)

+ 18 - 1
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)

+ 13 - 0
radicale/app/proppatch.py

@@ -22,9 +22,12 @@ import xml.etree.ElementTree as ET
 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 @@ class ApplicationPartProppatch(ApplicationBase):
             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)

+ 14 - 0
radicale/app/put.py

@@ -30,6 +30,7 @@ import vobject
 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 @@ class ApplicationPartPut(ApplicationBase):
                 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 @@ class ApplicationPartPut(ApplicationBase):
                 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)

+ 19 - 1
radicale/config.py

@@ -35,7 +35,7 @@ from configparser import RawConfigParser
 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 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([
             "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",

+ 60 - 0
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
+        )

+ 6 - 0
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."""

+ 50 - 0
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)

+ 2 - 2
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
 

+ 1 - 0
radicale/web/internal_data/css/icons/delete.svg

@@ -0,0 +1 @@
+<?xml version="1.0" encoding="UTF-8"?><svg width="24px" height="24px" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg" color="#000000"><path d="M20 9l-1.995 11.346A2 2 0 0116.035 22h-8.07a2 2 0 01-1.97-1.654L4 9M21 6h-5.625M3 6h5.625m0 0V4a2 2 0 012-2h2.75a2 2 0 012 2v2m-6.75 0h6.75" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></svg>

+ 1 - 0
radicale/web/internal_data/css/icons/download.svg

@@ -0,0 +1 @@
+<?xml version="1.0" encoding="UTF-8"?><svg width="24px" height="24px" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" color="#000000"><path d="M6 20h12M12 4v12m0 0l3.5-3.5M12 16l-3.5-3.5" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></svg>

+ 1 - 0
radicale/web/internal_data/css/icons/edit.svg

@@ -0,0 +1 @@
+<?xml version="1.0" encoding="UTF-8"?><svg width="24px" height="24px" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg" color="#000000"><path d="M14.363 5.652l1.48-1.48a2 2 0 012.829 0l1.414 1.414a2 2 0 010 2.828l-1.48 1.48m-4.243-4.242l-9.616 9.615a2 2 0 00-.578 1.238l-.242 2.74a1 1 0 001.084 1.085l2.74-.242a2 2 0 001.24-.578l9.615-9.616m-4.243-4.242l4.243 4.242" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></svg>

+ 1 - 0
radicale/web/internal_data/css/icons/new.svg

@@ -0,0 +1 @@
+<?xml version="1.0" encoding="UTF-8"?><svg width="24px" height="24px" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" color="#000000"><path d="M6 12h6m6 0h-6m0 0V6m0 6v6" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></svg>

+ 1 - 0
radicale/web/internal_data/css/icons/upload.svg

@@ -0,0 +1 @@
+<?xml version="1.0" encoding="UTF-8"?><svg width="24px" height="24px" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" color="#000000"><path d="M6 20h12M12 16V4m0 0l3.5 3.5M12 4L8.5 7.5" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></svg>

+ 72 - 0
radicale/web/internal_data/css/loading.svg

@@ -0,0 +1,72 @@
+<svg xmlns="http://www.w3.org/2000/svg"
+    xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="1080" height="1080" viewBox="0 0 1080 1080" xml:space="preserve">
+    <g transform="matrix(10.8 0 0 10.8 540 540)">
+        <g style="">
+            <g transform="matrix(2.64 0 0 2.64 0 -42.24)">
+                <rect style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(78,154,6); fill-rule: nonzero; opacity: 1;" vector-effect="non-scaling-stroke" x="-4.5" y="-2" rx="0" ry="0" width="9" height="4">
+                    <animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="0.8695652173913042s" begin="-0.8026755852842808s" repeatCount="indefinite"></animate>
+                </rect>
+            </g>
+            <g transform="matrix(2.34 1.23 -1.23 2.34 19.63 -37.4)">
+                <rect style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(113,204,26); fill-rule: nonzero; opacity: 1;" vector-effect="non-scaling-stroke" x="-4.5" y="-2" rx="0" ry="0" width="9" height="4">
+                    <animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="0.8695652173913042s" begin="-0.7357859531772575s" repeatCount="indefinite"></animate>
+                </rect>
+            </g>
+            <g transform="matrix(1.5 2.17 -2.17 1.5 34.76 -24)">
+                <rect style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(140,225,57); fill-rule: nonzero; opacity: 1;" vector-effect="non-scaling-stroke" x="-4.5" y="-2" rx="0" ry="0" width="9" height="4">
+                    <animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="0.8695652173913042s" begin="-0.6688963210702341s" repeatCount="indefinite"></animate>
+                </rect>
+            </g>
+            <g transform="matrix(0.32 2.62 -2.62 0.32 41.93 -5.09)">
+                <rect style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(205,255,156); fill-rule: nonzero; opacity: 1;" vector-effect="non-scaling-stroke" x="-4.5" y="-2" rx="0" ry="0" width="9" height="4">
+                    <animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="0.8695652173913042s" begin="-0.6020066889632106s" repeatCount="indefinite"></animate>
+                </rect>
+            </g>
+            <g transform="matrix(-0.94 2.47 -2.47 -0.94 39.5 14.98)">
+                <rect style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(205,247,166); fill-rule: nonzero; opacity: 1;" vector-effect="non-scaling-stroke" x="-4.5" y="-2" rx="0" ry="0" width="9" height="4">
+                    <animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="0.8695652173913042s" begin="-0.5351170568561873s" repeatCount="indefinite"></animate>
+                </rect>
+            </g>
+            <g transform="matrix(-1.98 1.75 -1.75 -1.98 28.01 31.62)">
+                <rect style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(252,252,252); fill-rule: nonzero; opacity: 1;" vector-effect="non-scaling-stroke" x="-4.5" y="-2" rx="0" ry="0" width="9" height="4">
+                    <animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="0.8695652173913042s" begin="-0.46822742474916385s" repeatCount="indefinite"></animate>
+                </rect>
+            </g>
+            <g transform="matrix(-2.56 0.63 -0.63 -2.56 10.11 41.01)">
+                <rect style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(254,254,254); fill-rule: nonzero; opacity: 1;" vector-effect="non-scaling-stroke" x="-4.5" y="-2" rx="0" ry="0" width="9" height="4">
+                    <animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="0.8695652173913042s" begin="-0.4013377926421404s" repeatCount="indefinite"></animate>
+                </rect>
+            </g>
+            <g transform="matrix(-2.56 -0.63 0.63 -2.56 -10.11 41.01)">
+                <rect style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(244,244,244); fill-rule: nonzero; opacity: 1;" vector-effect="non-scaling-stroke" x="-4.5" y="-2" rx="0" ry="0" width="9" height="4">
+                    <animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="0.8695652173913042s" begin="-0.33444816053511706s" repeatCount="indefinite"></animate>
+                </rect>
+            </g>
+            <g transform="matrix(-1.98 -1.75 1.75 -1.98 -28.01 31.62)">
+                <rect style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(255,214,214); fill-rule: nonzero; opacity: 1;" vector-effect="non-scaling-stroke" x="-4.5" y="-2" rx="0" ry="0" width="9" height="4">
+                    <animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="0.8695652173913042s" begin="-0.26755852842809363s" repeatCount="indefinite"></animate>
+                </rect>
+            </g>
+            <g transform="matrix(-0.94 -2.47 2.47 -0.94 -39.5 14.98)">
+                <rect style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(248,111,111); fill-rule: nonzero; opacity: 1;" vector-effect="non-scaling-stroke" x="-4.5" y="-2" rx="0" ry="0" width="9" height="4">
+                    <animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="0.8695652173913042s" begin="-0.2006688963210702s" repeatCount="indefinite"></animate>
+                </rect>
+            </g>
+            <g transform="matrix(0.32 -2.62 2.62 0.32 -41.93 -5.09)">
+                <rect style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(231,60,60); fill-rule: nonzero; opacity: 1;" vector-effect="non-scaling-stroke" x="-4.5" y="-2" rx="0" ry="0" width="9" height="4">
+                    <animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="0.8695652173913042s" begin="-0.13377926421404682s" repeatCount="indefinite"></animate>
+                </rect>
+            </g>
+            <g transform="matrix(1.5 -2.17 2.17 1.5 -34.76 -24)">
+                <rect style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(218,33,33); fill-rule: nonzero; opacity: 1;" vector-effect="non-scaling-stroke" x="-4.5" y="-2" rx="0" ry="0" width="9" height="4">
+                    <animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="0.8695652173913042s" begin="-0.06688963210702341s" repeatCount="indefinite"></animate>
+                </rect>
+            </g>
+            <g transform="matrix(2.34 -1.23 1.23 2.34 -19.63 -37.4)">
+                <rect style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(164,0,0); fill-rule: nonzero; opacity: 1;" vector-effect="non-scaling-stroke" x="-4.5" y="-2" rx="0" ry="0" width="9" height="4">
+                    <animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="0.8695652173913042s" begin="0s" repeatCount="indefinite"></animate>
+                </rect>
+            </g>
+        </g>
+    </g>
+</svg>

+ 10 - 0
radicale/web/internal_data/css/logo.svg

@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg width="200" height="300" xmlns="http://www.w3.org/2000/svg">
+  <path fill="#a40000" d="M 186,188 C 184,98 34,105 47,192 C 59,279 130,296 130,296 C 130,296 189,277 186,188 z" />
+  <path fill="#ffffff" d="M 73,238 C 119,242 140,241 177,222 C 172,270 131,288 131,288 C 131,288 88,276 74,238 z" />
+  <g fill="none" stroke="#4e9a06" stroke-width="15">
+    <path d="M 103,137 C 77,69 13,62 13,62" />
+    <path d="M 105,136 C 105,86 37,20 37,20" />
+    <path d="M 105,135 C 112,73 83,17 83,17" />
+  </g>
+</svg>

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
radicale/web/internal_data/css/main.css


+ 355 - 85
radicale/web/internal_data/fn.js

@@ -1,6 +1,6 @@
 /**
  * This file is part of Radicale Server - Calendar Server
- * Copyright © 2017-2018 Unrud <unrud@outlook.com>
+ * Copyright © 2017-2024 Unrud <unrud@outlook.com>
  *
  * 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('<?xml version="1.0" encoding="utf-8" ?>' +
-                 '<propfind xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" ' +
+                 '<propfind ' +
+                         '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/" ' +
-                         'xmlns:RADICALE="http://radicale.org/ns/">' +
+                         'xmlns:RADICALE="http://radicale.org/ns/"' +
+                         '>' +
                      '<prop>' +
                          '<resourcetype />' +
                          '<RADICALE:displayname />' +
@@ -248,6 +299,9 @@ function get_collections(user, password, collection, callback) {
                          '<C:calendar-description />' +
                          '<C:supported-calendar-component-set />' +
                          '<CR:addressbook-description />' +
+                         '<CS:source />' +
+                         '<RADICALE:getcontentcount />' +
+                         '<getcontentlength />' +
                      '</prop>' +
                  '</propfind>');
     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 = '<CR:addressbook />';
+    } else if (collection.type === CollectionType.WEBCAL) {
+        calendar_color = escape_xml(collection.color + (collection.color ? "ff" : ""));
+        calendar_description = escape_xml(collection.description);
+        resourcetype = '<CS:subscribed />';
+        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 version="1.0" encoding="UTF-8" ?>' +
-                 '<' + 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/">' +
                      '<set>' +
                          '<prop>' +
                              (create ? '<resourcetype><collection />' + resourcetype + '</resourcetype>' : '') +
@@ -361,6 +421,7 @@ function create_edit_collection(user, password, collection, create, callback) {
                              (addressbook_color ? '<INF:addressbook-color>' + addressbook_color + '</INF:addressbook-color>' : '') +
                              (addressbook_description ? '<CR:addressbook-description>' + addressbook_description + '</CR:addressbook-description>' : '') +
                              (calendar_description ? '<C:calendar-description>' + calendar_description + '</C:calendar-description>' : '') +
+                             (calendar_source ? '<CS:source>' + calendar_source + '</CS:source>' : '') +
                          '</prop>' +
                      '</set>' +
                      (!create ? ('<remove>' +
@@ -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<Collection>} */ let collections = null;
     /** @type {Array<Node>} */ 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<File>} 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<string>} */ let errors = [];
+    /** @type {Array<string>} */ let results = [];
     /** @type {?Array<Node>} */ 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");

+ 172 - 118
radicale/web/internal_data/index.html

@@ -1,70 +1,97 @@
 <!DOCTYPE html>
 <html lang="en">
+  <head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <meta http-equiv="X-UA-Compatible" content="IE=edge">
+    <title>Radicale Web Interface</title>
+    <link href="css/main.css" type="text/css" media="screen" rel="stylesheet">
+    <link href="css/icon.png" type="image/png" rel="icon">
+    <style>.hidden {display: none !important;}</style>
+    <script src="fn.js"></script>
+  </head>
 
-<head>
-<meta charset="utf-8">
-<meta name="viewport" content="width=device-width, initial-scale=1">
-<meta http-equiv="X-UA-Compatible" content="IE=edge">
-<script src="fn.js"></script>
-<title>Radicale Web Interface</title>
-<link href="css/main.css" media="screen" rel="stylesheet">
-<link href="css/icon.png" type="image/png" rel="icon">
-<style>
-    .hidden {display:none;}
-</style>
-</head>
+  <body>
+    <nav id="logoutview" class="hidden">
+      <span data-name="user" style="word-wrap:break-word;"></span>
+      <a href="#" class="green" data-name="refresh" title="Refresh">Refresh</a>
+      <a href="#" class="red" data-name="logout" title="Logout">Logout</a>
+    </nav>
 
-<body>
-<nav>
-    <ul>
-        <li id="logoutview" class="hidden"><a href="" data-name="link">Logout [<span data-name="user" style="word-wrap:break-word;"></span>]</a></li>
-    </ul>
-</nav>
+    <main>
+      <section id="loadingscene">
+        <img src="css/loading.svg" alt="Loading..." class="loading">
+        <h2>Loading</h2>
+        <p>Please wait...</p>
+        <noscript>JavaScript is required</noscript>
+      </section>
 
-<main>
-<section id="loadingscene">
-    <h1>Loading</h1>
-    <p>Please wait...</p>
-    <noscript>JavaScript is required</noscript>
-</section>
+      <section id="loginscene" class="container hidden">
+        <div class="logocontainer">
+          <img src="css/logo.svg" alt="Radicale">
+        </div>
+        <h1>Sign in</h1>
+        <br>
+        <form data-name="form">
+          <input data-name="user" type="text" placeholder="Username">
+          <input data-name="password" type="password" placeholder="Password">
+          <button class="green" type="submit">Next</button>
+          <span class="error" data-name="error"></span>
+        </form>
+      </section>
 
-<section id="loginscene" class="hidden">
-    <h1>Login</h1>
-    <form data-name="form">
-        <input data-name="user" type="text" placeholder="Username"><br>
-        <input data-name="password" type="password" placeholder="Password"><br>
-        <span style="color: #A40000;" data-name="error"></span><br>
-        <button type="submit">Next</button>
-    </form>
-</section>
+      <section id="collectionsscene" class="hidden">
+        <div class="fabcontainer">
+          <a href="" class="green" data-name="new" title="Create a new addressbook or calendar">
+            <img src="css/icons/new.svg" class="icon" alt="➕">
+          </a>
+          <a href="" class="blue" data-name="upload" title="Upload an addressbook or calendar">
+            <img src="css/icons/upload.svg" class="icon" alt="⬆️">
+          </a>
+        </div>
+        <article data-name="collectiontemplate" class="hidden">
+          <div class="colorbar" data-name="color"></div>
+          <h3 class="title" data-name="title">Title</h3>
+          <small>
+            <span data-name="ADDRESSBOOK">Address book</span>
+            <span data-name="CALENDAR_JOURNAL_TASKS">Calendar, journal and tasks</span>
+            <span data-name="CALENDAR_JOURNAL">Calendar and journal</span>
+            <span data-name="CALENDAR_TASKS">Calendar and tasks</span>
+            <span data-name="JOURNAL_TASKS">Journal and tasks</span>
+            <span data-name="CALENDAR">Calendar</span>
+            <span data-name="JOURNAL">Journal</span>
+            <span data-name="TASKS">Tasks</span>
+            <span data-name="WEBCAL">Webcal</span>
+          </small>
+          <small data-name="contentcount"></small>
+          <input type="text" data-name="url" value="" readonly="" onfocus="this.setSelectionRange(0, 99999);">
+          <p data-name="description" style="word-wrap:break-word;">Description</p>
+          <ul>
+            <li>
+              <a href="" title="Download" class="green" data-name="download">
+                <img src="css/icons/download.svg" class="icon" alt="🔗">
+              </a>
+            </li>
+            <li>
+              <a href="" title="Edit" class="blue" data-name="edit">
+                <img src="css/icons/edit.svg" class="icon" alt="✏️">
+              </a>
+            </li>
+            <li>
+              <a href="" title="Delete" class="red" data-name="delete">
+                <img src="css/icons/delete.svg" class="icon" alt="❌">
+              </a>
+            </li>
+          </ul>
+        </article>
+      </section>
 
-<section id="collectionsscene" class="hidden">
-    <h1>Collections</h1>
-    <ul>
-        <li><a href="" data-name="new">Create new addressbook or calendar</a></li>
-        <li><a href="" data-name="upload">Upload addressbook or calendar</a></li>
-    </ul>
-    <article data-name="collectiontemplate" class="hidden">
-        <h2><span data-name="color">█ </span><span data-name="title" style="word-wrap:break-word;">Title</span> <small>[<span data-name="ADDRESSBOOK">addressbook</span><span data-name="CALENDAR_JOURNAL_TASKS">calendar, journal and tasks</span><span data-name="CALENDAR_JOURNAL">calendar and journal</span><span data-name="CALENDAR_TASKS">calendar and tasks</span><span data-name="JOURNAL_TASKS">journal and tasks</span><span data-name="CALENDAR">calendar</span><span data-name="JOURNAL">journal</span><span data-name="TASKS">tasks</span>]</small></h2>
-        <span data-name="description" style="word-wrap:break-word;">Description</span>
-        <ul>
-            <li>URL: <a data-name="url" style="word-wrap:break-word;">url</a></li>
-            <li><a href="" data-name="edit">Edit</a></li>
-            <li><a href="" data-name="delete">Delete</a></li>
-        </ul>
-    </article>
-</section>
-
-<section id="editcollectionscene" class="hidden">
-    <h1>Edit collection</h1>
-    <h2>Edit <span data-name="title" style="word-wrap:break-word;font-weight:bold;">title</span>:</h2>
-    <form>
-        Title:<br>
-        <input data-name="displayname" type="text"><br>
-        Description:<br>
-        <input data-name="description" type="text"><br>
-        Type:<br>
-        <select data-name="type">
+      <section id="editcollectionscene" class="container hidden">
+        <h1>Edit Collection</h1>
+        <p>Editing collection <span class="title" data-name="title">title</span>
+        </p>
+        <form> Type: <br>
+          <select data-name="type">
             <option value="ADDRESSBOOK">addressbook</option>
             <option value="CALENDAR_JOURNAL_TASKS">calendar, journal and tasks</option>
             <option value="CALENDAR_JOURNAL">calendar and journal</option>
@@ -73,66 +100,93 @@
             <option value="CALENDAR">calendar</option>
             <option value="JOURNAL">journal</option>
             <option value="TASKS">tasks</option>
-        </select><br>
-        Color:<br>
-        <input data-name="color" type="color"><br>
-        <span style="color: #A40000;" data-name="error"></span><br>
-        <button type="submit" data-name="submit">Save</button>
-        <button type="button" data-name="cancel">Cancel</button>
-    </form>
-</section>
+            <option value="WEBCAL">webcal</option>
+          </select>
+          <label for="displayname">Title:</label>
+          <input data-name="displayname" type="text">
+          <label for="description">Description:</label>
+          <input data-name="description" type="text">
+          <label for="source">Source:</label>
+          <input data-name="source" type="url">
+          <label for="color">Color:</label>
+          <input data-name="color" type="color">
+          <br>
+          <span class="error hidden" data-name="error"></span>
+          <br>
+          <button type="submit" class="green" data-name="submit">Save</button>
+          <button type="button" class="red" data-name="cancel">Cancel</button>
+        </form>
+      </section>
 
-<section id="createcollectionscene" class="hidden">
-    <h1>Create new collection</h1>
-    <form>
-        Title:<br>
-        <input data-name="displayname" type="text"><br>
-        Description:<br>
-        <input data-name="description" type="text"><br>
-        Type:<br>
-        <select data-name="type">
-            <option value="ADDRESSBOOK">addressbook</option>
-            <option value="CALENDAR_JOURNAL_TASKS">calendar, journal and tasks</option>
-            <option value="CALENDAR_JOURNAL">calendar and journal</option>
-            <option value="CALENDAR_TASKS">calendar and tasks</option>
-            <option value="JOURNAL_TASKS">journal and tasks</option>
-            <option value="CALENDAR">calendar</option>
-            <option value="JOURNAL">journal</option>
-            <option value="TASKS">tasks</option>
-        </select><br>
-        Color:<br>
-        <input data-name="color" type="color"><br>
-        <span style="color: #A40000;" data-name="error"></span><br>
-        <button type="submit" data-name="submit">Create</button>
-        <button type="button" data-name="cancel">Cancel</button>
-    </form>
-</section>
+      <section id="createcollectionscene" class="container hidden">
+        <h1>Create a new Collection</h1>
+        <p>Enter the details of your new collection.</p>
+        <form> Type: <br>
+          <select data-name="type">
+            <option value="ADDRESSBOOK">Address book</option>
+            <option value="CALENDAR_JOURNAL_TASKS">Calendar, journal and tasks</option>
+            <option value="CALENDAR_JOURNAL">Calendar and journal</option>
+            <option value="CALENDAR_TASKS">Calendar and tasks</option>
+            <option value="JOURNAL_TASKS">Journal and tasks</option>
+            <option value="CALENDAR">Calendar</option>
+            <option value="JOURNAL">Journal</option>
+            <option value="TASKS">Tasks</option>
+            <option value="WEBCAL">Webcal</option>
+          </select>
+          <label for="href">HREF:</label>
+          <input data-name="href" type="text">
+          <label for="displayname">Title:</label>
+          <input data-name="displayname" type="text">
+          <label for="description">Description:</label>
+          <input data-name="description" type="text">
+          <label for="source">Source:</label>
+          <input data-name="source" type="url">
+          <label for="color">Color:</label>
+          <input data-name="color" type="color">
+          <br>
+          <span class="error" data-name="error"></span>
+          <br>
+          <button type="submit" class="green" data-name="submit">Create</button>
+          <button type="button" class="red" data-name="cancel">Cancel</button>
+        </form>
+      </section>
 
-<section id="uploadcollectionscene" class="hidden">
-    <h1>Upload collection</h1>
-    <ul>
-        <li data-name="filetemplate" class="hidden">
-                Upload <span data-name="name" style="word-wrap:break-word;font-weight:bold;">name</span>:<br>
-                <span data-name="pending">Please wait...</span>
-                <span style="color: #00A400;" data-name="success">Finished</span>
-                <span style="color: #A40000;" data-name="error"></span>
-        </li>
-    </ul>
-    <form>
-        <button type="button" data-name="close">Close</button>
-    </form>
-</section>
+      <section id="uploadcollectionscene" class="container hidden">
+        <h1>Upload Collection</h1>
+        <ul>
+          <li data-name="filetemplate" class="hidden"> Uploading <span data-name="name">name</span>
+            <br>
+            <span class="successmessage" data-name="success">Uploaded Successfully!</span>
+            <span class="error" data-name="error"></span>
+          </li>
+        </ul>
+        <div data-name="pending" class="hidden">
+          <img src="css/loading.svg" class="loading" alt="Please wait..."/>
+        </div>
+        <form>
+          <label for="uploadfile">File:</label>
+          <input data-name="uploadfile" type="file" accept=".ics, .vcf" multiple>
+          <label for="href">HREF:</label>
+          <input data-name="href" type="text">
+          <small data-name="hreflimitmsg" class="hidden">You can only specify the HREF if you upload 1 file.</small>
+          <button type="submit" class="green" data-name="submit">Upload</button>
+          <button type="button" class="red" data-name="close">Close</button>
+        </form>
+      </section>
 
-<section id="deletecollectionscene" class="hidden">
-    <h1>Delete collection</h1>
-    <h2>Delete <span data-name="title" style="word-wrap:break-word;font-weight:bold;">title</span>?</h2>
-    <span style="color: #A40000;" data-name="error"></span><br>
-    <form>
-        <button type="button" data-name="delete">Yes</button>
-        <button type="button" data-name="cancel">No</button>
-    </form>
-</section>
-</main>
-</body>
+      <section id="deletecollectionscene" class="container hidden">
+        <h1>Delete Collection</h1>
+        <p>To delete the collection <span class="title" data-name="title">title</span> please enter the phrase <strong data-name="deleteconfirmationtext"></strong> in the box below:</p>
+        <input type="text" class="deleteconfirmationtxt" data-name="confirmationtxt" />
+        <p class="red">WARNING: This action cannot be reversed.</p>
+        <form>
+          <button type="button" class="red" data-name="delete">Delete</button>
+          <button type="button" class="blue" data-name="cancel">Cancel</button>
+        </form>
+        <span class="error hidden" data-name="error"></span>
+        <br>
+      </section>
 
+    </main>
+  </body>
 </html>

+ 5 - 1
radicale/xmlutils.py

@@ -33,7 +33,8 @@ from radicale import item, pathutils
 
 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

+ 1 - 0
setup.py

@@ -30,6 +30,7 @@ web_files = ["web/internal_data/css/icon.png",
 
 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

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů