Merge branch 'v3.2-devel'
28
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"
|
||||
|
||||
|
|
|
@ -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
|
@ -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
|
|
@ -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]:
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
@ -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
|
@ -0,0 +1,6 @@
|
|||
from radicale import hook
|
||||
|
||||
|
||||
class Hook(hook.BaseHook):
|
||||
def notify(self, notification_item):
|
||||
"""Notify nothing. Empty hook."""
|
50
radicale/hook/rabbitmq/__init__.py
Normal 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)
|
|
@ -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
radicale/web/internal_data/css/icons/delete.svg
Normal 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 |
1
radicale/web/internal_data/css/icons/download.svg
Normal 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 |
1
radicale/web/internal_data/css/icons/edit.svg
Normal 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 |
1
radicale/web/internal_data/css/icons/new.svg
Normal 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 |
1
radicale/web/internal_data/css/icons/upload.svg
Normal 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 |
72
radicale/web/internal_data/css/loading.svg
Normal 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 |
10
radicale/web/internal_data/css/logo.svg
Normal 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 |
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
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
|
||||
|
|