1
0
Fork 0
mirror of https://github.com/Kozea/Radicale.git synced 2025-06-26 16:45:52 +00:00

Merge branch 'v3.2-devel'

This commit is contained in:
Peter Bieringer 2024-04-05 06:59:17 +02:00
commit 2741d73d68
25 changed files with 1334 additions and 223 deletions

View file

@ -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"

View file

@ -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
config
View file

@ -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

View file

@ -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]:

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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
radicale/hook/__init__.py Normal file
View file

@ -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
radicale/hook/none.py Normal file
View file

@ -0,0 +1,6 @@
from radicale import hook
class Hook(hook.BaseHook):
def notify(self, notification_item):
"""Notify nothing. Empty hook."""

View file

@ -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)

View file

@ -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

View file

@ -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>

After

Width:  |  Height:  |  Size: 418 B

View file

@ -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>

After

Width:  |  Height:  |  Size: 322 B

View file

@ -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>

After

Width:  |  Height:  |  Size: 499 B

View file

@ -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>

After

Width:  |  Height:  |  Size: 305 B

View file

@ -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>

After

Width:  |  Height:  |  Size: 320 B

View file

@ -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>

After

Width:  |  Height:  |  Size: 7.9 KiB

View file

@ -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>

After

Width:  |  Height:  |  Size: 564 B

View file

@ -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;
}
}

View file

@ -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,43 +856,89 @@ 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");
}
} 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;
}
upload_req = null;
errors.push(error);
updateFileStatus(errors.length - 1);
upload_next();
});
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[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;
results.push(result);
updateFileStatus(results.length - 1);
upload_next();
});
}
}catch(err){
console.error(err);
}
}
function onclose() {
try {
pop_scene(scene_index - 1);
@ -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");
}
}
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;
}
href = newhreftxtvalue;
if(uploadfile_form.files.length == 0){
alert("You must select at least one file to upload");
return false;
}
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");
if (errors.length < files.length) {
close_btn.classList.add("hidden");
}
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);
}
if (scene_index === null) {
scene_index = scene_stack.length - 1;
upload_next();
}
};
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");

View file

@ -1,138 +1,192 @@
<!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>
<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>
<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="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="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="container hidden">
<h1>Upload Collection</h1>
<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>
<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>
</article>
</section>
<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="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">
<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">Save</button>
<button type="button" 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="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="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>

View file

@ -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

View file

@ -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