├── .gitattributes ├── .gitignore ├── LICENSE ├── README.md ├── amd64.dockerfile ├── build ├── app.py ├── conversion_base.py ├── convert_to_dhtmlx.py └── static │ ├── favicon.ico │ └── js │ └── configure.js.bkp ├── rootfs ├── ics │ └── bin │ │ └── static │ │ └── etc │ │ ├── default.json │ │ └── demo.json └── usr │ └── local │ └── bin │ └── entrypoint.sh └── screenshots └── default.json.png /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | maintain/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 11notes 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Alpine :: ics-view 2 | Run open-web-calendar (web view for ics) based on Alpine Linux. Small, lightweight, secure and fast 🏔️ 3 | 4 | ![Calendar View](screenshots/default.json.png?raw=true "Calendar View (default.json)") 5 | 6 | ## Volumes 7 | * **/ics/etc** - Directory of json configuration files for different views 8 | 9 | ## Run 10 | ```shell 11 | docker run --name ics-view \ 12 | -v .../etc:/ics/etc \ 13 | -d 11notes/ics-view:[tag] 14 | ``` 15 | 16 | ## Defaults 17 | | Parameter | Value | Description | 18 | | --- | --- | --- | 19 | | `user` | docker | user docker | 20 | | `uid` | 1000 | user id 1000 | 21 | | `gid` | 1000 | group id 1000 | 22 | 23 | ## Environment 24 | | Parameter | Value | Default | 25 | | --- | --- | --- | 26 | | `ICS_IP` | localhost or 127.0.0.1 or a dedicated IP | 0.0.0.0 | 27 | | `ICS_PORT` | any port > 1024 | 5000 | 28 | | `ICS_MAX_PER_VIEW` | How many calendars (*.ics feeds) are allowed to be loaded at once | 5 | 29 | | `ICS_WORKERS` | How many workers should be started to handle requests | 4 | 30 | | `ICS_CACHE_LIFETIME` | How long *.ics feed are cached between requests in seconds | 60 | 31 | | `ICS_VIEW_DHTMLX_ENABLE_PLUGINS` | Enabled DHTMLX plugins | "agenda_view multisource quick_info tooltip readonly" | 32 | | `ICS_VIEW_DHTMLX_DISABLE_PLUGINS` | Disabled DHTMLX plugins | "" | 33 | | `ICS_DEBUG` | Enable debug mode | false | 34 | 35 | ## Configuration 36 | You can place different configuration json files in /ics/etc and use the directly via URL (you do not need to add .json, just the file name) 37 | ```shell 38 | http://localhost:5000/?calendar=demo // will use demo.json in /ics/etc 39 | http://localhost:5000/?calendar=https://domain.com/foo/demo.json 40 | ``` 41 | 42 | ## DHTMLX Plugins 43 | You can find a list of all available plugins [here](https://docs.dhtmlx.com/scheduler/extensions_list.html). 44 | To use additional plugins simply add them to the desired environment variable. The example will add the plugin all_timed and year_view and remove the plugin recurring from being used. 45 | 46 | ```shell 47 | docker run --name ics-view \ 48 | -v .../etc:/ics/etc \ 49 | -e ICS_VIEW_DHTMLX_ENABLE_PLUGINS="agenda_view multisource quick_info tooltip readonly all_timed year_view" \ 50 | -e ICS_VIEW_DHTMLX_DISABLE_PLUGINS="recurring" \ 51 | -d 11notes/ics-view:[tag] 52 | ``` 53 | 54 | # CSS tricks 55 | If you add ?calendarID=NAME at the end of the URL of your *.ics calendar you can use this NAME in a css selector to colour each *.ics calendar differently 56 | ```css 57 | [event_id^="christian"], 58 | [event_id^="christian"] div {background-color:#FF0000 !important; color:#FFFFFF !important;} 59 | ``` 60 | 61 | ## Parent 62 | * [python:3.11-alpine](https://github.com/docker-library/python/blob/b744d9708a2fb8e2295198ef146341c415e9bc28/3.11/alpine3.18/Dockerfile) 63 | 64 | ## Built with 65 | * [open-web-calendar](https://github.com/niccokunzmann/open-web-calendar) 66 | * [DHTMLX Scheduler](https://dhtmlx.com/docs/products/dhtmlxScheduler) 67 | * [Alpine Linux](https://alpinelinux.org) 68 | 69 | ## Tips 70 | * Don't bind to ports < 1024 (requires root), use NAT/reverse proxy 71 | * [Permanent Stroage](https://github.com/11notes/alpine-docker-netshare) - Module to store permanent container data via NFS/CIFS and more -------------------------------------------------------------------------------- /amd64.dockerfile: -------------------------------------------------------------------------------- 1 | # :: Build 2 | FROM python:3.11-alpine as build 3 | ENV APP_VERSION=v1.13 4 | 5 | RUN set -ex; \ 6 | apk add --update --no-cache \ 7 | curl \ 8 | wget \ 9 | unzip \ 10 | build-base \ 11 | linux-headers \ 12 | make \ 13 | cmake \ 14 | g++ \ 15 | git; \ 16 | git clone https://github.com/niccokunzmann/open-web-calendar.git; \ 17 | cd /open-web-calendar; \ 18 | git checkout ${APP_VERSION}; 19 | 20 | # :: Header 21 | FROM python:3.11-alpine 22 | ENV APP_ROOT=/ics 23 | COPY --from=build /open-web-calendar/ ${APP_ROOT}/bin 24 | 25 | # :: Run 26 | USER root 27 | 28 | # :: update image 29 | RUN set -ex; \ 30 | apk --update --no-cache add \ 31 | curl \ 32 | tzdata \ 33 | shadow; \ 34 | apk update; \ 35 | apk upgrade; 36 | 37 | # :: create user 38 | RUN set -ex; \ 39 | addgroup --gid 1000 -S docker; \ 40 | adduser --uid 1000 -D -S -h / -s /sbin/nologin -G docker docker; 41 | 42 | # :: prepare image 43 | RUN set -ex; \ 44 | mkdir -p ${APP_ROOT}/bin/static/etc; \ 45 | ln -s ${APP_ROOT}/bin/static/etc ${APP_ROOT}/etc; 46 | 47 | # :: install application 48 | RUN set -ex; \ 49 | cd ${APP_ROOT}/bin; \ 50 | # fix security 51 | # https://nvd.nist.gov/vuln/detail/CVE-2023-25577⁠ 52 | # https://nvd.nist.gov/vuln/detail/CVE-2023-23934⁠ 53 | # https://nvd.nist.gov/vuln/detail/CVE-2023-30861⁠ 54 | # https://nvd.nist.gov/vuln/detail/CVE-2023-32681⁠ 55 | rm requirements.txt; \ 56 | pip install --upgrade pip-tools -r requirements.in; \ 57 | pip-compile -o requirements.txt requirements.in; \ 58 | pip install --upgrade --no-cache-dir -r requirements.txt; 59 | 60 | # :: copy root filesystem changes and add execution rights to init scripts 61 | COPY ./rootfs / 62 | RUN set -ex; \ 63 | chmod +x -R /usr/local/bin 64 | 65 | # :: modify application 66 | COPY ./build ${APP_ROOT}/bin 67 | RUN set -ex; \ 68 | cd ${APP_ROOT}/bin; \ 69 | sed -i 's###' ./templates/calendars/dhtmlx.html; \ 70 | sed -i 's#DEBUG = os.environ.get("APP_DEBUG", "true").lower() == "true"#DEBUG = os.environ.get("ICS_DEBUG", "false").lower() == "false"#' ./app.py; \ 71 | sed -i 's#PORT = int(os.environ.get("PORT", "5000"))#PORT = int(os.environ.get("ICS_PORT", "5000"))#' ./app.py; \ 72 | sed -i 's#CACHE_REQUESTED_URLS_FOR_SECONDS = int(os.environ.get("CACHE_REQUESTED_URLS_FOR_SECONDS", 600))#CACHE_REQUESTED_URLS_FOR_SECONDS = int(os.environ.get("ICS_CACHE_LIFETIME", 60))#' ./app.py; \ 73 | sed -i 's#DEFAULT_SPECIFICATION_PATH = os.path.join(HERE, "default_specification.yml")#DEFAULT_SPECIFICATION_PATH = os.path.join(HERE, "static", "etc", "default.json")#' ./app.py; \ 74 | sed -i 's#PARAM_SPECIFICATION_URL = "specification_url"#PARAM_SPECIFICATION_URL = "calendar"#' ./app.py; 75 | 76 | # :: change home path for existing user and set correct permission 77 | RUN set -ex; \ 78 | usermod -d ${APP_ROOT} docker; \ 79 | chown -R 1000:1000 \ 80 | ${APP_ROOT}; 81 | 82 | # :: Volumes 83 | VOLUME ["${APP_ROOT}/etc"] 84 | 85 | # :: Start 86 | USER docker 87 | ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] -------------------------------------------------------------------------------- /build/app.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from flask import Flask, render_template, make_response, request, jsonify, \ 3 | redirect, send_from_directory 4 | from flask_caching import Cache 5 | import json 6 | import os 7 | import tempfile 8 | import requests 9 | import icalendar 10 | import datetime 11 | from dateutil.rrule import rrulestr 12 | from pprint import pprint 13 | import yaml 14 | import traceback 15 | import io 16 | import sys 17 | from convert_to_dhtmlx import ConvertToDhtmlx 18 | from convert_to_ics import ConvertToICS 19 | import pytz 20 | import translate 21 | 22 | # configuration 23 | DEBUG = os.environ.get("APP_DEBUG", "true").lower() == "true" 24 | PORT = int(os.environ.get("PORT", "5000")) 25 | CACHE_REQUESTED_URLS_FOR_SECONDS = int(os.environ.get("CACHE_REQUESTED_URLS_FOR_SECONDS", 600)) 26 | 27 | # constants 28 | HERE = os.path.dirname(__file__) or "." 29 | # START CHANGE by https://github.com/11notes 30 | DEFAULT_SPECIFICATION_PATH = os.path.join(HERE, "static", "etc", "default.json") 31 | # END CHANGE by https://github.com/11notes 32 | TEMPLATE_FOLDER_NAME = "templates" 33 | TEMPLATE_FOLDER = os.path.join(HERE, TEMPLATE_FOLDER_NAME) 34 | CALENDARS_TEMPLATE_FOLDER_NAME = "calendars" 35 | CALENDAR_TEMPLATE_FOLDER = os.path.join(TEMPLATE_FOLDER, CALENDARS_TEMPLATE_FOLDER_NAME) 36 | STATIC_FOLDER_NAME = "static" 37 | STATIC_FOLDER_PATH = os.path.join(HERE, STATIC_FOLDER_NAME) 38 | DHTMLX_LANGUAGES_FILE = os.path.join(STATIC_FOLDER_PATH, "js", "dhtmlx", "locale", "languages.json") 39 | 40 | # specification 41 | # START CHANGE by https://github.com/11notes 42 | PARAM_SPECIFICATION_URL = "calendar" 43 | # END CHANGE by https://github.com/11notes 44 | 45 | # globals 46 | app = Flask(__name__, template_folder="templates") 47 | # Check Configuring Flask-Cache section for more details 48 | CACHE_CONFIG = { 49 | 'CACHE_TYPE': 'FileSystemCache', 50 | 'CACHE_DIR': tempfile.mktemp(prefix="cache-")} 51 | cache = Cache(app, config=CACHE_CONFIG) 52 | 53 | # caching 54 | 55 | __URL_CACHE = {} 56 | def cache_url(url, text): 57 | """Cache the value of a url.""" 58 | __URL_CACHE[url] = text 59 | try: 60 | get_text_from_url(url) 61 | finally: 62 | del __URL_CACHE[url] 63 | 64 | 65 | @app.after_request 66 | def add_header(r): 67 | """ 68 | Add headers to both force latest IE rendering engine or Chrome Frame, 69 | and also to cache the rendered page for 10 minutes. 70 | """ 71 | r.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" 72 | r.headers["Pragma"] = "no-cache" 73 | r.headers["Expires"] = "0" 74 | return r 75 | 76 | # configuration 77 | 78 | def get_configuration(): 79 | """Return the configuration for the browser""" 80 | config = { 81 | "default_specification": get_default_specification(), 82 | "timezones": pytz.all_timezones, # see https://stackoverflow.com/a/13867319 83 | "dhtmlx": { 84 | "languages": translate.dhtmlx_languages() 85 | }, 86 | "index": { 87 | "languages": translate.languages_for_the_index_file() 88 | } 89 | } 90 | return config 91 | 92 | def set_JS_headers(response): 93 | response = make_response(response) 94 | response.headers['Access-Control-Allow-Origin'] = '*' 95 | # see https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS/Errors/CORSMissingAllowHeaderFromPreflight 96 | response.headers['Access-Control-Allow-Headers'] = request.headers.get("Access-Control-Request-Headers") 97 | response.headers['Content-Type'] = 'text/calendar' 98 | return response 99 | 100 | def set_js_headers(func): 101 | """Set the response headers for a valid CORS request.""" 102 | def with_js_response(*args, **kw): 103 | return set_JS_headers(func(*args, **kw)) 104 | return with_js_response 105 | 106 | @cache.memoize( 107 | CACHE_REQUESTED_URLS_FOR_SECONDS, 108 | forced_update=lambda: bool(__URL_CACHE)) 109 | def get_text_from_url(url): 110 | """Return the text from a url. 111 | 112 | The result is cached CACHE_REQUESTED_URLS_FOR_SECONDS. 113 | """ 114 | 115 | if __URL_CACHE: 116 | return __URL_CACHE[url] 117 | return requests.get(url).content 118 | 119 | def get_default_specification(): 120 | """Return the default specification.""" 121 | with open(DEFAULT_SPECIFICATION_PATH, encoding="UTF-8") as file: 122 | return yaml.safe_load(file) 123 | 124 | def get_specification(query=None): 125 | """Build the calendar specification.""" 126 | if query is None: 127 | query = request.args 128 | specification = get_default_specification() 129 | # get a request parameter, see https://stackoverflow.com/a/11774434 130 | url = query.get(PARAM_SPECIFICATION_URL, None) 131 | if url: 132 | # START CHANGE by https://github.com/11notes 133 | if not "http" in url: 134 | url = "http://localhost:{}/etc/{}.json".format(PORT, url) 135 | # END CHANGE by https://github.com/11notes 136 | url_specification_response = get_text_from_url(url) 137 | try: 138 | url_specification_values = json.loads(url_specification_response) 139 | except json.JSONDecodeError: 140 | url_specification_values = yaml.safe_load(url_specification_response) 141 | specification.update(url_specification_values) 142 | for parameter in query: 143 | # get a list of arguments 144 | # see http://werkzeug.pocoo.org/docs/0.14/datastructures/#werkzeug.datastructures.MultiDict 145 | value = query.getlist(parameter, None) 146 | if len(value) == 1: 147 | value = value[0] 148 | specification[parameter] = value 149 | return specification 150 | 151 | 152 | def get_query_string(): 153 | return "?" + request.query_string.decode() 154 | 155 | def render_app_template(template, specification): 156 | translation_file = os.path.splitext(template)[0] 157 | return render_template(template, 158 | specification=specification, 159 | configuration = get_configuration(), 160 | json=json, 161 | get_query_string=get_query_string, 162 | html=lambda id: translate.html(specification["language"], translation_file, id) 163 | ) 164 | 165 | @app.route("/calendar.", methods=['GET', 'OPTIONS']) 166 | # use query string in cache, see https://stackoverflow.com/a/47181782/1320237 167 | #@cache.cached(timeout=CACHE_TIMEOUT, query_string=True) 168 | def get_calendar(type): 169 | """Return a calendar.""" 170 | specification = get_specification() 171 | if type == "spec": 172 | return jsonify(specification) 173 | if type == "events.json": 174 | strategy = ConvertToDhtmlx(specification, get_text_from_url) 175 | strategy.retrieve_calendars() 176 | return strategy.merge() 177 | if type == "ics": 178 | strategy = ConvertToICS(specification, get_text_from_url) 179 | strategy.retrieve_calendars() 180 | return strategy.merge() 181 | if type == "html": 182 | template_name = specification["template"] 183 | all_template_names = os.listdir(CALENDAR_TEMPLATE_FOLDER) 184 | assert template_name in all_template_names, "Template names must be file names like \"{}\", not \"{}\".".format("\", \"".join(all_template_names), template_name) 185 | template = CALENDARS_TEMPLATE_FOLDER_NAME + "/" + template_name 186 | return render_app_template(template, specification) 187 | raise ValueError("Cannot use extension {}. Please see the documentation or report an error.".format(type)) 188 | 189 | for folder_name in os.listdir(STATIC_FOLDER_PATH): 190 | folder_path = os.path.join(STATIC_FOLDER_PATH, folder_name) 191 | if not os.path.isdir(folder_path): 192 | continue 193 | @app.route('/' + folder_name + '/', endpoint="static/" + folder_name) 194 | def send_static(path, folder_name=folder_name): 195 | return send_from_directory('static/' + folder_name, path) 196 | 197 | # START CHANGE by https://github.com/11notes 198 | @app.route("/") 199 | def serve_index(): 200 | specification = get_specification() 201 | template_name = specification["template"] 202 | all_template_names = os.listdir(CALENDAR_TEMPLATE_FOLDER) 203 | assert template_name in all_template_names, "Template names must be file names like \"{}\", not \"{}\".".format("\", \"".join(all_template_names), template_name) 204 | template = CALENDARS_TEMPLATE_FOLDER_NAME + "/" + template_name 205 | return render_app_template(template, specification) 206 | 207 | @app.route("/favicon.ico") 208 | def serve_favicon(): 209 | return send_from_directory("static", "favicon.ico") 210 | # END CHANGE by https://github.com/11notes 211 | 212 | @app.route("/configuration.js") 213 | def serve_configuration(): 214 | return "/* generated */\nconst configuration = {};".format(json.dumps(get_configuration())) 215 | 216 | @app.route("/locale_.js") 217 | def serve_locale(lang): 218 | return render_template("locale.js", locale=json.dumps(translate.dhtmlx(lang), indent=" ")) 219 | 220 | @app.errorhandler(500) 221 | def unhandledException(error): 222 | """Called when an error occurs. 223 | 224 | See https://stackoverflow.com/q/14993318 225 | """ 226 | file = io.StringIO() 227 | traceback.print_exception(type(error), error, error.__traceback__, file=file) 228 | return """ 229 | 230 | 231 | 232 | 500 Internal Server Error 233 | 234 | 235 |

Internal Server Error

236 |

The server encountered an internal error and was unable to complete your request. Either the server is overloaded or there is an error in the application.

237 |
\r\n{traceback}
238 |             
239 | 240 | 241 | """.format(traceback=file.getvalue()), 500 # return error code from https://stackoverflow.com/a/7824605 242 | 243 | # make serializable for multiprocessing 244 | #app.__reduce__ = lambda: __name__ + ".app" 245 | 246 | if __name__ == "__main__": 247 | app.run(debug=DEBUG, host="0.0.0.0", port=PORT) 248 | -------------------------------------------------------------------------------- /build/conversion_base.py: -------------------------------------------------------------------------------- 1 | from concurrent.futures import ThreadPoolExecutor 2 | from threading import RLock 3 | import requests 4 | import sys 5 | import traceback 6 | import io 7 | # START CHANGE by https://github.com/11notes 8 | import os 9 | import re 10 | # END CHANGE by https://github.com/11notes 11 | from icalendar import Calendar 12 | 13 | def get_text_from_url(url): 14 | """Return the text from a url.""" 15 | return requests.get(url).text 16 | 17 | 18 | class ConversionStrategy: 19 | """Base class for conversions.""" 20 | 21 | # START CHANGE by https://github.com/11notes 22 | MAXIMUM_THREADS = int(os.getenv("ICS_MAX_PER_VIEW", 5)) 23 | # END CHANGE by https://github.com/11notes 24 | 25 | def __init__(self, specification, get_text_from_url=get_text_from_url): 26 | self.specification = specification 27 | self.lock = RLock() 28 | self.components = [] 29 | self.get_text_from_url = get_text_from_url 30 | self.created() 31 | 32 | def created(self): 33 | """Template method for subclasses.""" 34 | 35 | def error(self, ty, err, tb, url): 36 | tb_s = io.StringIO() 37 | traceback.print_exception(ty, err, tb, file=tb_s) 38 | return self.convert_error(err, url, tb_s.getvalue()) 39 | 40 | def retrieve_calendars(self): 41 | """Retrieve the calendars from different sources.""" 42 | urls = self.specification["url"] 43 | if isinstance(urls, str): 44 | urls = [urls] 45 | assert len(urls) <= self.MAXIMUM_THREADS, "You can only merge {} urls. If you like more, open an issue.".format(MAXIMUM_THREADS) 46 | with ThreadPoolExecutor(max_workers=self.MAXIMUM_THREADS) as e: 47 | for e in e.map(self.retrieve_calendar, urls): 48 | pass # no error should pass silently; import this 49 | 50 | def retrieve_calendar(self, url): 51 | """Retrieve a calendar from a url""" 52 | try: 53 | # START CHANGE by https://github.com/11notes 54 | calendar_name = "none" 55 | regex = re.compile(r'(?i)calendarID\=(\S+)') 56 | rematch = regex.search(url) 57 | if rematch: 58 | calendar_name = rematch.groups()[0] 59 | # END CHANGE by https://github.com/11notes 60 | 61 | calendar_text = self.get_text_from_url(url) 62 | calendars = Calendar.from_ical(calendar_text, multiple=True) 63 | 64 | # START CHANGE by https://github.com/11notes 65 | self.collect_components_from(calendars, calendar_name) 66 | # END CHANGE by https://github.com/11notes 67 | except: 68 | ty, err, tb = sys.exc_info() 69 | with self.lock: 70 | self.components.append(self.error(ty, err, tb, url)) 71 | 72 | def collect_components_from(self, calendars): 73 | """Collect all the compenents from the calendar.""" 74 | raise NotImplementedError("to be implemented in subclasses") 75 | 76 | def collect_calendar_information(self, calendars): 77 | """Collect additional information from the calendars.""" 78 | 79 | def merge(self): 80 | """Return the flask Response for the merged calendars.""" 81 | raise NotImplementedError("to be implemented in subclasses") 82 | 83 | 84 | -------------------------------------------------------------------------------- /build/convert_to_dhtmlx.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from flask import jsonify 3 | from conversion_base import ConversionStrategy 4 | import recurring_ical_events 5 | import icalendar 6 | from dateutil.parser import parse as parse_date 7 | import pytz 8 | 9 | 10 | def is_date(date): 11 | """Whether the date is a datetime.date and not a datetime.datetime""" 12 | return isinstance(date, datetime.date) and not isinstance(date, datetime.datetime) 13 | 14 | 15 | class ConvertToDhtmlx(ConversionStrategy): 16 | """Convert events to dhtmlx. This conforms to a stratey pattern. 17 | 18 | - timeshift_minutes is the timeshift specified by the calendar 19 | for dates. 20 | """ 21 | 22 | def created(self): 23 | self.timezone = pytz.timezone(self.specification["timezone"]) 24 | 25 | def date_to_string(self, date): 26 | """Convert a date to a string.""" 27 | # use ISO format 28 | # see https://docs.dhtmlx.com/scheduler/howtostart_nodejs.html#step4implementingcrud 29 | # see https://docs.python.org/3/library/datetime.html#datetime.datetime.isoformat 30 | # see https://docs.python.org/3/library/datetime.html#strftime-and-strptime-behavior 31 | if is_date(date): 32 | date = datetime.datetime(date.year, date.month, date.day, tzinfo=self.timezone) 33 | elif date.tzinfo is None: 34 | date = self.timezone.localize(date) 35 | # convert to other timezone, see https://stackoverflow.com/a/54376154 36 | viewed_date = date.astimezone(self.timezone) 37 | return viewed_date.strftime("%Y-%m-%d %H:%M") 38 | 39 | # START CHANGE by https://github.com/11notes 40 | def convert_ical_event(self, calendar_event, calendar_name): 41 | # END CHANGE by https://github.com/11notes 42 | start = calendar_event["DTSTART"].dt 43 | end = calendar_event.get("DTEND", calendar_event["DTSTART"]).dt 44 | if is_date(start) and is_date(end) and end == start: 45 | end = datetime.timedelta(days=1) + start 46 | geo = calendar_event.get("GEO", None) 47 | if geo: 48 | geo = {"lon": geo.longitude, "lat": geo.latitude} 49 | name = calendar_event.get("SUMMARY", "") 50 | sequence = str(calendar_event.get("SEQUENCE", 0)) 51 | uid = calendar_event.get("UID", "") # issue 69: UID is helpful for debugging but not required 52 | start_date = self.date_to_string(start) 53 | categories = calendar_event.get("CATEGORIES", None) 54 | if categories and isinstance(categories, icalendar.prop.vCategory): 55 | categories = categories.to_ical().decode("UTF-8").replace(",", " | ") 56 | return { 57 | "start_date": start_date, 58 | "end_date": self.date_to_string(end), 59 | "start_date_iso": start.isoformat(), 60 | "end_date_iso": end.isoformat(), 61 | "start_date_iso_0": start.isoformat(), 62 | "end_date_iso_0": end.isoformat(), 63 | "text": name, 64 | "description": calendar_event.get("DESCRIPTION", ""), 65 | "location": calendar_event.get("LOCATION", None), 66 | "geo": geo, 67 | "uid": uid, 68 | "ical": calendar_event.to_ical().decode("UTF-8"), 69 | "sequence": sequence, 70 | "recurrence": None, 71 | "url": calendar_event.get("URL"), 72 | # START CHANGE by https://github.com/11notes 73 | "id": (calendar_name, uid, start_date), 74 | # END CHANGE by https://github.com/11notes 75 | "type": "event", 76 | "color": calendar_event.get("COLOR", calendar_event.get("X-APPLE-CALENDAR-COLOR", "")), 77 | "categories": categories, 78 | } 79 | 80 | def convert_error(self, error, url, tb_s): 81 | """Create an error which can be used by the dhtmlx scheduler.""" 82 | now = datetime.datetime.now(); 83 | now_iso = now.isoformat() 84 | now_s = self.date_to_string(now) 85 | return { 86 | "start_date": now_s, 87 | "end_date": now_s, 88 | "start_date_iso": now_iso, 89 | "end_date_iso": now_iso, 90 | "start_date_iso_0": now_iso, 91 | "end_date_iso_0": now_iso, 92 | "text": type(error).__name__, 93 | "description": str(error), 94 | "traceback": tb_s, 95 | "location": None, 96 | "geo": None, 97 | "uid": "error", 98 | "ical": "", 99 | "sequence": 0, 100 | "recurrence": None, 101 | "url": url, 102 | "id": id(error), 103 | "type": "error" 104 | } 105 | 106 | def merge(self): 107 | return jsonify(self.components) 108 | 109 | # START CHANGE by https://github.com/11notes 110 | def collect_components_from(self, calendars, calendar_name): 111 | # END CHANGE by https://github.com/11notes 112 | # see https://stackoverflow.com/a/16115575/1320237 113 | today = ( 114 | parse_date(self.specification["date"]) 115 | if self.specification.get("date") 116 | else datetime.datetime.utcnow() 117 | ) 118 | to_date = ( 119 | parse_date(self.specification["to"]) 120 | if self.specification.get("to") 121 | else today.replace(year=today.year + 1) 122 | ) 123 | from_date = ( 124 | parse_date(self.specification["from"]) 125 | if self.specification.get("from") 126 | else today.replace(year=today.year - 1) 127 | ) 128 | for calendar in calendars: 129 | events = recurring_ical_events.of(calendar).between(from_date, to_date) 130 | with self.lock: 131 | for event in events: 132 | # START CHANGE by https://github.com/11notes 133 | json_event = self.convert_ical_event(event, calendar_name) 134 | # END CHANGE by https://github.com/11notes 135 | self.components.append(json_event) 136 | 137 | 138 | 139 | -------------------------------------------------------------------------------- /build/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/11notes/docker-ics-view/e5e1b5b284409d3d81fadaca8fc426f5e6094f81/build/static/favicon.ico -------------------------------------------------------------------------------- /build/static/js/configure.js.bkp: -------------------------------------------------------------------------------- 1 | /* This is used by the dhtmlx scheduler. 2 | * 3 | */ 4 | 5 | function escapeHtml(unsafe) { 6 | // from https://stackoverflow.com/a/6234804 7 | return unsafe 8 | .replace(/&/g, "&") 9 | .replace(//g, ">") 11 | .replace(/"/g, """) 12 | .replace(/'/g, "'"); 13 | } 14 | 15 | function getQueries() { 16 | // from http://stackoverflow.com/a/1099670/1320237 17 | var qs = document.location.search; 18 | var tokens, re = /[?&]?([^=]+)=([^&]*)/g; 19 | qs = qs.split("+").join(" "); 20 | 21 | var queries = {}; 22 | while (tokens = re.exec(qs)) { 23 | var id = decodeURIComponent(tokens[1]); 24 | var content = decodeURIComponent(tokens[2]); 25 | if (Array.isArray(queries[id])) { 26 | queries[id].push(content); 27 | } if (queries[id]) { 28 | queries[id] = [queries[id], content]; 29 | } else { 30 | queries[id] = content; 31 | } 32 | } 33 | return queries; 34 | } 35 | 36 | // TODO: allow choice through specification 37 | var GOOGLE_URL = "https://maps.google.com/maps?q="; 38 | var OSM_URL = "https://www.openstreetmap.org/search?query="; 39 | 40 | /* Create a link around the HTML text. 41 | * Use this instead of creating links manually because it also sets the 42 | * target according to the specification. 43 | */ 44 | function makeLink(url, html) { 45 | return "
" + html + ""; 46 | } 47 | 48 | var template = { 49 | "summary": function(event) { 50 | return "
" + 51 | (event.url ? makeLink(event.url, event.text) : event.text) + 52 | "
"; 53 | }, 54 | "details": function(event) { 55 | return "
" + event.description + "
"; 56 | }, 57 | "location": function(event) { 58 | if (!event.location && !event.geo) { 59 | return ""; 60 | } 61 | var text = event.location || "🗺"; 62 | var geoUrl; 63 | if (event.geo) { 64 | geoUrl = "https://www.openstreetmap.org/?mlon=" + event.geo.lon + "&mlat=" + event.geo.lat + "&#map=15/" + event.geo.lat + "/" + event.geo.lon; 65 | } else { 66 | geoUrl = OSM_URL + encodeURIComponent(event.location); 67 | } 68 | return makeLink(geoUrl, text); 69 | }, 70 | "debug": function(event) { 71 | return "" 74 | } 75 | } 76 | 77 | /* The files use a Scheduler variable. 78 | * scheduler.locale is used to load the locale. 79 | * This creates the required interface. 80 | */ 81 | var setLocale = function(){}; 82 | var Scheduler = {plugin:function(setLocale_){ 83 | // this is called by the locale_??.js files. 84 | setLocale = setLocale_; 85 | }}; 86 | 87 | function showError(element) { 88 | var icon = document.getElementById("errorStatusIcon"); 89 | icon.classList.add("onError"); 90 | var errors = document.getElementById("errorWindow"); 91 | element.classList.add("item"); 92 | errors.appendChild(element); 93 | } 94 | 95 | function toggleErrorWindow() { 96 | var scheduler_tag = document.getElementById("scheduler_here"); 97 | var errors = document.getElementById("errorWindow"); 98 | scheduler_tag.classList.toggle("hidden"); 99 | errors.classList.toggle("hidden"); 100 | } 101 | 102 | function showXHRError(xhr) { 103 | var iframe = document.createElement("iframe"); 104 | iframe.srcdoc = xhr.responseText; 105 | iframe.className = "errorFrame"; 106 | showError(iframe); 107 | } 108 | 109 | function showEventError(error) { 110 | // show an error created by app.py -> error_to_dhtmlx 111 | var div = document.createElement("div"); 112 | div.innerHTML = "

" + error.text + "

" + 113 | "" + error.url + "" + 114 | "

" + error.description + "

" + 115 | "
" + error.traceback + "
"; 116 | showError(div); 117 | } 118 | 119 | function disableLoader() { 120 | var loader = document.getElementById("loader"); 121 | loader.classList.add("hidden"); 122 | } 123 | 124 | function setLoader() { 125 | if (specification.loader) { 126 | var loader = document.getElementById("loader"); 127 | var url = specification.loader.replace(/'/g, "%27"); 128 | loader.style.cssText += "background:url('" + url + "') center center no-repeat;" 129 | } else { 130 | disableLoader(); 131 | } 132 | } 133 | 134 | function loadCalendar() { 135 | var format = scheduler.date.date_to_str("%H:%i"); 136 | setLocale(scheduler); 137 | // load plugins, see https://docs.dhtmlx.com/scheduler/migration_from_older_version.html#5360 138 | // START CHANGE by https://github.com/11notes 139 | scheduler.plugins({$ICS_VIEW_DHTMLX_PLUGINS}); 140 | // END CHANGE by https://github.com/11notes 141 | // set format of dates in the data source 142 | scheduler.config.xml_date="%Y-%m-%d %H:%i"; 143 | // use UTC, see https://docs.dhtmlx.com/scheduler/api__scheduler_server_utc_config.html 144 | // scheduler.config.server_utc = true; // we use timezones now 145 | 146 | scheduler.config.readonly = true; 147 | // set the start of the week. See https://docs.dhtmlx.com/scheduler/api__scheduler_start_on_monday_config.html 148 | scheduler.config.start_on_monday = specification["start_of_week"] == "mo"; 149 | let hour_division = parseInt(specification["hour_division"]); 150 | scheduler.config.hour_size_px = 44 * hour_division; 151 | scheduler.templates.hour_scale = function(date){ 152 | var step = 60 / hour_division; 153 | var html = ""; 154 | for (var i=0; i"; // TODO: This should be in CSS. 156 | date = scheduler.date.add(date, step, "minute"); 157 | } 158 | return html; 159 | } 160 | scheduler.config.first_hour = parseInt(specification["starting_hour"]); 161 | scheduler.config.last_hour = parseInt(specification["ending_hour"]); 162 | var date = specification["date"] ? new Date(specification["date"]) : new Date(); 163 | scheduler.init('scheduler_here', date, specification["tab"]); 164 | 165 | // event in the calendar 166 | scheduler.templates.event_bar_text = function(start, end, event){ 167 | return event.text; 168 | } 169 | // tool tip 170 | // see https://docs.dhtmlx.com/scheduler/tooltips.html 171 | scheduler.templates.tooltip_text = function(start, end, event) { 172 | return template.summary(event) + template.details(event) + template.location(event); 173 | }; 174 | scheduler.tooltip.config.delta_x = 1; 175 | scheduler.tooltip.config.delta_y = 1; 176 | // quick info 177 | scheduler.templates.quick_info_title = function(start, end, event){ 178 | return template.summary(event); 179 | } 180 | scheduler.templates.quick_info_content = function(start, end, event){ 181 | return template.details(event) + 182 | template.location(event) + 183 | template.debug(event); 184 | } 185 | 186 | scheduler.templates.event_header = function(start, end, event){ 187 | if (event.categories){ 188 | return (scheduler.templates.event_date(start)+" - "+ 189 | scheduler.templates.event_date(end)+' | '+ 190 | event.categories)+' |' 191 | } else { 192 | return(scheduler.templates.event_date(start)+" - "+ 193 | scheduler.templates.event_date(end)) 194 | } 195 | }; 196 | 197 | // general style 198 | scheduler.templates.event_class=function(start,end,event){ 199 | if (event.type == "error") { 200 | showEventError(event); 201 | } 202 | return event.type; 203 | }; 204 | 205 | // set agenda date 206 | scheduler.templates.agenda_date = scheduler.templates.month_date; 207 | 208 | // START CHANGE by https://github.com/11notes 209 | schedulerUrl = `/calendar.events.json${document.location.search}`; 210 | if(!(/\?/i.test(schedulerUrl))){ 211 | schedulerUrl += '?'; 212 | } 213 | // add the time zone if not specified 214 | if (specification.timezone == "") { 215 | schedulerUrl += "&timezone=" + getTimezone(); 216 | } 217 | // END CHANGE by https://github.com/11notes 218 | 219 | scheduler.attachEvent("onLoadError", function(xhr) { 220 | disableLoader(); 221 | console.log("could not load events"); 222 | console.log(xhr); 223 | showXHRError(xhr); 224 | }); 225 | 226 | scheduler.attachEvent("onXLE", disableLoader); 227 | 228 | 229 | //requestJSON(schedulerUrl, loadEventsOnSuccess, loadEventsOnError); 230 | scheduler.setLoadMode("day"); 231 | scheduler.load(schedulerUrl, "json"); 232 | 233 | 234 | //var dp = new dataProcessor(schedulerUrl); 235 | // use RESTful API on the backend 236 | //dp.setTransactionMode("REST"); 237 | //dp.init(scheduler); 238 | 239 | setLoader(); 240 | } 241 | 242 | /* Agenda view 243 | * 244 | * see https://docs.dhtmlx.com/scheduler/agenda_view.html 245 | */ 246 | 247 | scheduler.date.agenda_start = function(date){ 248 | return scheduler.date.month_start(new Date(date)); 249 | }; 250 | 251 | scheduler.date.add_agenda = function(date, inc){ 252 | return scheduler.date.add(date, inc, "month"); 253 | }; 254 | 255 | window.addEventListener("load", loadCalendar); 256 | 257 | -------------------------------------------------------------------------------- /rootfs/ics/bin/static/etc/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "default.json calendar", 3 | "description": "", 4 | "date":"", 5 | "starting_hour": 6, 6 | "ending_hour": 20, 7 | "hour_division": 1, 8 | "timeshift": 0, 9 | "start_of_week": "mo", 10 | "timezone": "", 11 | "template": "dhtmlx.html", 12 | 13 | "skin": "dhtmlxscheduler_material.css", 14 | "language": "en", 15 | "target": "_top", 16 | "loader": "/img/loaders/circular-loader.gif", 17 | "controls": [ 18 | "next", 19 | "previous", 20 | "today", 21 | "date" 22 | ], 23 | "tabs": [ 24 | "month", 25 | "week", 26 | "day", 27 | "agenda" 28 | ], 29 | "tab":"week", 30 | "url": [ 31 | "https://www.calendarlabs.com/ical-calendar/ics/41/christian-holidays.ics?calendarID=christian", 32 | "https://www.calendarlabs.com/ical-calendar/ics/52/islam-holidays.ics?calendarID=islam", 33 | "https://www.calendarlabs.com/ical-calendar/ics/55/jewish-holidays.ics?calendarID=jewish", 34 | "https://www.calendarlabs.com/ical-calendar/ics/48/hindu-holidays.ics?calendarID=hindu" 35 | ], 36 | "css": "[event_id^=\"christian\"],[event_id^=\"christian\"] div {background-color: #e28743 !important;color: #FFFFFF !important;}\n\t\t\t[event_id^=\"islam\"],[event_id^=\"islam\"] div {background-color: #2596be !important;color: #FFFFFF !important;}\n\t\t\t[event_id^=\"jewish\"],[event_id^=\"jewish\"] div {background-color: #49be25 !important;color: #FFFFFF !important;}\n\t\t\t[event_id^=\"hindu\"],[event_id^=\"hindu\"] div {background-color: #bea925 !important;color: #FFFFFF !important;}" 37 | } -------------------------------------------------------------------------------- /rootfs/ics/bin/static/etc/demo.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "demo.json calendar", 3 | "description": "", 4 | "date":"", 5 | "starting_hour": 12, 6 | "ending_hour": 20, 7 | "hour_division": 1, 8 | "timeshift": 0, 9 | "start_of_week": "su", 10 | "timezone": "", 11 | "template": "dhtmlx.html", 12 | "skin": "dhtmlxscheduler_material.css", 13 | "language": "en", 14 | "target": "_top", 15 | "loader": "/img/loaders/circular-loader.gif", 16 | "controls": [ 17 | "next", 18 | "previous", 19 | "today", 20 | "date" 21 | ], 22 | "tabs": [ 23 | "month", 24 | "week", 25 | "day", 26 | "agenda" 27 | ], 28 | "tab":"month", 29 | "url": [ 30 | "https://www.calendarlabs.com/ical-calendar/ics/577/NYSE_Market_Holidays.ics" 31 | ] 32 | } -------------------------------------------------------------------------------- /rootfs/usr/local/bin/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/ash 2 | if [ -z "${ICS_IP}" ]; then ICS_IP=0.0.0.0; fi 3 | if [ -z "${ICS_PORT}" ]; then ICS_PORT=5000; fi 4 | if [ -z "${ICS_WORKERS}" ]; then ICS_WORKERS=4; fi 5 | 6 | # DHTMLX plugins 7 | if [ -z "${ICS_VIEW_DHTMLX_ENABLE_PLUGINS}" ]; then ICS_VIEW_DHTMLX_ENABLE_PLUGINS="agenda_view multisource quick_info tooltip readonly all_timed"; fi 8 | if [ -z "${ICS_VIEW_DHTMLX_DISABLE_PLUGINS}" ]; then ICS_VIEW_DHTMLX_DISABLE_PLUGINS=""; fi 9 | 10 | ICS_VIEW_DHTMLX_PLUGINS="" 11 | 12 | for PLUGIN in ${ICS_VIEW_DHTMLX_ENABLE_PLUGINS}; do 13 | ICS_VIEW_DHTMLX_PLUGINS="${ICS_VIEW_DHTMLX_PLUGINS}${PLUGIN}:true," 14 | done 15 | 16 | for PLUGIN in ${ICS_VIEW_DHTMLX_DISABLE_PLUGINS}; do 17 | ICS_VIEW_DHTMLX_PLUGINS="${ICS_VIEW_DHTMLX_PLUGINS}${PLUGIN}:false," 18 | done 19 | 20 | cp /ics/bin/static/js/configure.js.bkp /ics/bin/static/js/configure.js 21 | sed -i "s/\$ICS_VIEW_DHTMLX_PLUGINS/${ICS_VIEW_DHTMLX_PLUGINS}/" /ics/bin/static/js/configure.js 22 | 23 | if [ -z "${1}" ]; then 24 | cd /ics/bin 25 | set -- "gunicorn" \ 26 | -w ${ICS_WORKERS} \ 27 | -b ${ICS_IP}:${ICS_PORT} \ 28 | app:app 29 | fi 30 | 31 | exec "$@" -------------------------------------------------------------------------------- /screenshots/default.json.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/11notes/docker-ics-view/e5e1b5b284409d3d81fadaca8fc426f5e6094f81/screenshots/default.json.png --------------------------------------------------------------------------------