From 94c48f41600aa40ba794f92d1cfd36cccb4bb821 Mon Sep 17 00:00:00 2001 From: Casvt <88994465+Casvt@users.noreply.github.com> Date: Mon, 31 Jul 2023 22:03:21 +0200 Subject: [PATCH] V1.3.3 (#58) * First version of URL builder (#3) * Updated API docs * Added option for default notification service (#56) * Added support for args in URL Builder (#3) * Added regex checks in URL builder (#3) * Added test button when adding notification service * Updated API docs * Added recurring reminders on week days (#55) * Updated API docs --- backend/db.py | 13 +- backend/notification_service.py | 173 ++++++++- backend/reminders.py | 147 +++++--- docs/api.md | 38 ++ frontend/api.py | 43 ++- frontend/static/css/general.css | 2 +- frontend/static/css/info.css | 84 ++++- frontend/static/css/notification.css | 176 ++++++++- frontend/static/js/general.js | 66 +++- frontend/static/js/library.js | 30 +- frontend/static/js/notification.js | 509 +++++++++++++++++++++------ frontend/static/js/settings.js | 21 +- frontend/static/js/show.js | 17 +- frontend/static/js/window.js | 58 ++- frontend/templates/reminders.html | 65 ++-- requirements.txt | 2 +- 16 files changed, 1217 insertions(+), 227 deletions(-) diff --git a/backend/db.py b/backend/db.py index 43bf7a6..aea8981 100644 --- a/backend/db.py +++ b/backend/db.py @@ -12,7 +12,7 @@ from waitress.task import ThreadedTaskDispatcher as OldThreadedTaskDispatcher from backend.custom_exceptions import AccessUnauthorized, UserNotFound -__DATABASE_VERSION__ = 6 +__DATABASE_VERSION__ = 7 class Singleton(type): _instances = {} @@ -206,6 +206,16 @@ def migrate_db(current_db_version: int) -> None: User('User1', 'Password1').delete() except (UserNotFound, AccessUnauthorized): pass + + current_db_version = 6 + + if current_db_version == 6: + # V6 -> V7 + cursor.executescript(""" + ALTER TABLE reminders + ADD weekdays VARCHAR(13); + """) + current_db_version = 7 return @@ -240,6 +250,7 @@ def setup_db() -> None: repeat_quantity VARCHAR(15), repeat_interval INTEGER, original_time INTEGER, + weekdays VARCHAR(13), color VARCHAR(7), diff --git a/backend/notification_service.py b/backend/notification_service.py index 12f04d3..c4d7ca1 100644 --- a/backend/notification_service.py +++ b/backend/notification_service.py @@ -1,13 +1,167 @@ #-*- coding: utf-8 -*- import logging -from typing import List +from typing import Dict, List, Union + +from apprise import Apprise from backend.custom_exceptions import (NotificationServiceInUse, NotificationServiceNotFound) from backend.db import get_db +def _sort_tokens(t: dict) -> int: + result = [ + int(not t['required']) + ] + + if t['type'] == 'choice': + result.append(0) + elif t['type'] != 'list': + result.append(1) + else: + result.append(2) + + return result + +def get_apprise_services() -> List[Dict[str, Union[str, Dict[str, list]]]]: + apprise_services = [] + raw = Apprise().details() + for entry in raw['schemas']: + entry: Dict[str, Union[str, dict]] + result: Dict[str, Union[str, Dict[str, list]]] = { + 'name': str(entry['service_name']), + 'doc_url': entry['setup_url'], + 'details': { + 'templates': entry['details']['templates'], + 'tokens': [], + 'args': [] + } + } + + schema = entry['details']['tokens']['schema'] + result['details']['tokens'].append({ + 'name': schema['name'], + 'map_to': 'schema', + 'required': schema['required'], + 'type': 'choice', + 'options': schema['values'], + 'default': schema.get('default') + }) + + handled_tokens = {'schema'} + result['details']['tokens'] += [ + { + 'name': v['name'], + 'map_to': k, + 'required': v['required'], + 'type': 'list', + 'delim': v['delim'][0], + 'content': [ + { + 'name': content['name'], + 'required': content['required'], + 'type': content['type'], + 'prefix': content.get('prefix'), + 'regex': content.get('regex') + } + for content, _ in ((entry['details']['tokens'][e], handled_tokens.add(e)) for e in v['group']) + ] + } + for k, v in + filter( + lambda t: t[1]['type'].startswith('list:'), + entry['details']['tokens'].items() + ) + ] + handled_tokens.update( + set(map(lambda e: e[0], + filter(lambda e: e[1]['type'].startswith('list:'), + entry['details']['tokens'].items()) + )) + ) + + result['details']['tokens'] += [ + { + 'name': v['name'], + 'map_to': k, + 'required': v['required'], + 'type': v['type'].split(':')[0], + **({ + 'options': v.get('values'), + 'default': v.get('default') + } if v['type'].startswith('choice') else { + 'prefix': v.get('prefix'), + 'min': v.get('min'), + 'max': v.get('max'), + 'regex': v.get('regex') + }) + } + for k, v in + filter( + lambda t: not t[0] in handled_tokens, + entry['details']['tokens'].items() + ) + ] + + result['details']['tokens'].sort(key=_sort_tokens) + + result['details']['args'] += [ + { + 'name': v.get('name', k), + 'map_to': k, + 'required': v.get('required', False), + 'type': v['type'].split(':')[0], + **({ + 'delim': v['delim'][0], + 'content': [] + } if v['type'].startswith('list') else { + 'options': v['values'], + 'default': v.get('default') + } if v['type'].startswith('choice') else { + 'default': v['default'] + } if v['type'] == 'bool' else { + 'min': v.get('min'), + 'max': v.get('max'), + 'regex': v.get('regex') + }) + } + for k, v in + filter( + lambda a: ( + a[1].get('alias_of') is None + and not a[0] in ('cto', 'format', 'overflow', 'rto', 'verify') + ), + entry['details']['args'].items() + ) + ] + result['details']['args'].sort(key=_sort_tokens) + + apprise_services.append(result) + + apprise_services.sort(key=lambda s: s['name'].lower()) + + apprise_services.insert(0, { + 'name': 'Custom URL', + 'doc_url': 'https://github.com/caronc/apprise#supported-notifications', + 'details': { + 'templates': ['{url}'], + 'tokens': [{ + 'name': 'Apprise URL', + 'map_to': 'url', + 'required': True, + 'type': 'string', + 'prefix': None, + 'min': None, + 'max': None, + 'regex': None + }], + 'args': [] + } + }) + + return apprise_services + class NotificationService: def __init__(self, user_id: int, notification_service_id: int) -> None: self.id = notification_service_id @@ -179,4 +333,19 @@ class NotificationServices: ).lastrowid return self.fetchone(new_id) - \ No newline at end of file + + def test_service( + self, + url: str + ) -> None: + """Send a test notification using the supplied Apprise URL + + Args: + url (str): The Apprise URL to use to send the test notification + """ + logging.info(f'Testing service with {url=}') + a = Apprise() + a.add(url) + a.notify(title='MIND: Test title', body='MIND: Test body') + return + \ No newline at end of file diff --git a/backend/reminders.py b/backend/reminders.py index 4c0fb69..84f6030 100644 --- a/backend/reminders.py +++ b/backend/reminders.py @@ -1,13 +1,13 @@ #-*- coding: utf-8 -*- -from datetime import datetime import logging +from datetime import datetime from sqlite3 import IntegrityError from threading import Timer from typing import List, Literal from apprise import Apprise -from dateutil.relativedelta import relativedelta +from dateutil.relativedelta import relativedelta, weekday from flask import Flask from backend.custom_exceptions import (InvalidKeyValue, InvalidTime, @@ -23,13 +23,34 @@ filter_function = lambda query, p: ( def _find_next_time( original_time: int, repeat_quantity: Literal["years", "months", "weeks", "days", "hours", "minutes"], - repeat_interval: int + repeat_interval: int, + weekdays: List[int] ) -> int: - td = relativedelta(**{repeat_quantity: repeat_interval}) + if weekdays is not None: + weekdays.sort() + new_time = datetime.fromtimestamp(original_time) current_time = datetime.fromtimestamp(datetime.utcnow().timestamp()) - while new_time <= current_time: - new_time += td + + if repeat_quantity is not None: + td = relativedelta(**{repeat_quantity: repeat_interval}) + while new_time <= current_time: + new_time += td + + else: + next_day = ([d for d in weekdays if new_time.weekday() < d] or weekdays)[0] + proposed_time = new_time + relativedelta(weekday=weekday(next_day)) + if proposed_time == new_time: + proposed_time += relativedelta(weekday=weekday(next_day, 2)) + new_time = proposed_time + + while new_time <= current_time: + next_day = ([d for d in weekdays if new_time.weekday() < d] or weekdays)[0] + proposed_time = new_time + relativedelta(weekday=weekday(next_day)) + if proposed_time == new_time: + proposed_time += relativedelta(weekday=weekday(next_day, 2)) + new_time = proposed_time + result = int(new_time.timestamp()) logging.debug( f'{original_time=}, {current_time=} and interval of {repeat_interval} {repeat_quantity} leads to {result}' @@ -60,7 +81,9 @@ class ReminderHandler: SELECT r.id, r.title, r.text, - r.repeat_quantity, r.repeat_interval, r.original_time + r.repeat_quantity, r.repeat_interval, + r.weekdays, + r.original_time FROM reminders r WHERE time = ?; """, (time,)) @@ -81,7 +104,7 @@ class ReminderHandler: a.add(url['url']) a.notify(title=reminder["title"], body=reminder["text"]) - if reminder['repeat_quantity'] is None: + if reminder['repeat_quantity'] is None and reminder['weekdays'] is None: # Delete the reminder from the database cursor.execute( "DELETE FROM reminders WHERE id = ?;", @@ -93,7 +116,8 @@ class ReminderHandler: new_time = _find_next_time( reminder['original_time'], reminder['repeat_quantity'], - reminder['repeat_interval'] + reminder['repeat_interval'], + [int(d) for d in reminder['weekdays'].split(',')] if reminder['weekdays'] is not None else None ) cursor.execute( "UPDATE reminders SET time = ? WHERE id = ?;", @@ -177,6 +201,7 @@ class Reminder: time, repeat_quantity, repeat_interval, + weekdays, color FROM reminders WHERE id = ? @@ -202,6 +227,7 @@ class Reminder: text: str = None, repeat_quantity: Literal["years", "months", "weeks", "days", "hours", "minutes"] = None, repeat_interval: int = None, + weekdays: List[int] = None, color: str = None ) -> dict: """Edit the reminder @@ -213,17 +239,23 @@ class Reminder: text (str, optional): The new body of the reminder. Defaults to None. repeat_quantity (Literal["years", "months", "weeks", "days", "hours", "minutes"], optional): The new quantity of the repeat specified for the reminder. Defaults to None. repeat_interval (int, optional): The new amount of repeat_quantity, like "5" (hours). Defaults to None. + weekdays (List[int], optional): The new indexes of the days of the week that the reminder should run. Defaults to None. color (str, optional): The new hex code of the color of the reminder, which is shown in the web-ui. Defaults to None. + Note about args: + Either repeat_quantity and repeat_interval are given, weekdays is given or neither, but not both. + Raises: NotificationServiceNotFound: One of the notification services was not found + InvalidKeyValue: The value of one of the keys is not valid or the "Note about args" is violated Returns: dict: The new reminder info """ logging.info( f'Updating notification service {self.id}: ' - + f'{title=}, {time=}, {notification_services=}, {text=}, {repeat_quantity=}, {repeat_interval=}, {color=}' + + f'{title=}, {time=}, {notification_services=}, {text=}, ' + + f'{repeat_quantity=}, {repeat_interval=}, {weekdays=}, {color=}' ) cursor = get_db() @@ -232,7 +264,12 @@ class Reminder: raise InvalidKeyValue('repeat_quantity', repeat_quantity) elif repeat_quantity is not None and repeat_interval is None: raise InvalidKeyValue('repeat_interval', repeat_interval) - repeated_reminder = repeat_quantity is not None and repeat_interval is not None + elif weekdays is not None and repeat_quantity is not None and repeat_interval is not None: + raise InvalidKeyValue('weekdays', weekdays) + repeated_reminder = ( + (repeat_quantity is not None and repeat_interval is not None) + or weekdays is not None + ) if time is not None: if not repeated_reminder: @@ -248,43 +285,28 @@ class Reminder: 'text': text, 'repeat_quantity': repeat_quantity, 'repeat_interval': repeat_interval, + 'weekdays': ",".join(map(str, sorted(weekdays))) if weekdays is not None else None, 'color': color } for k, v in new_values.items(): - if k in ('repeat_quantity', 'repeat_interval', 'color') or v is not None: + if k in ('repeat_quantity', 'repeat_interval', 'weekdays', 'color') or v is not None: data[k] = v # Update database - if not repeated_reminder: - next_time = data["time"] - cursor.execute(""" - UPDATE reminders - SET - title=?, text=?, - time=?, - repeat_quantity=?, repeat_interval=?, - color=? - WHERE id = ?; - """, ( - data["title"], - data["text"], - data["time"], - data["repeat_quantity"], - data["repeat_interval"], - data["color"], - self.id - )) - else: + if repeated_reminder: next_time = _find_next_time( data["time"], - data["repeat_quantity"], data["repeat_interval"] + data["repeat_quantity"], data["repeat_interval"], + weekdays ) cursor.execute(""" UPDATE reminders SET title=?, text=?, time=?, - repeat_quantity=?, repeat_interval=?, original_time=?, + repeat_quantity=?, repeat_interval=?, + weekdays=?, + original_time=?, color=? WHERE id = ?; """, ( @@ -293,11 +315,34 @@ class Reminder: next_time, data["repeat_quantity"], data["repeat_interval"], + data["weekdays"], data["time"], data["color"], self.id )) + else: + next_time = data["time"] + cursor.execute(""" + UPDATE reminders + SET + title=?, text=?, + time=?, + repeat_quantity=?, repeat_interval=?, + weekdays=?, + color=? + WHERE id = ?; + """, ( + data["title"], + data["text"], + data["time"], + data["repeat_quantity"], + data["repeat_interval"], + data["weekdays"], + data["color"], + self.id + )) + if notification_services: cursor.connection.isolation_level = None cursor.execute("BEGIN TRANSACTION;") @@ -360,6 +405,7 @@ class Reminders: time, repeat_quantity, repeat_interval, + weekdays, color FROM reminders WHERE user_id = ?; @@ -408,6 +454,7 @@ class Reminders: text: str = '', repeat_quantity: Literal["years", "months", "weeks", "days", "hours", "minutes"] = None, repeat_interval: int = None, + weekdays: List[int] = None, color: str = None ) -> Reminder: """Add a reminder @@ -419,17 +466,22 @@ class Reminders: text (str, optional): The body of the reminder. Defaults to ''. repeat_quantity (Literal["years", "months", "weeks", "days", "hours", "minutes"], optional): The quantity of the repeat specified for the reminder. Defaults to None. repeat_interval (int, optional): The amount of repeat_quantity, like "5" (hours). Defaults to None. + weekdays (List[int], optional): The indexes of the days of the week that the reminder should run. Defaults to None. color (str, optional): The hex code of the color of the reminder, which is shown in the web-ui. Defaults to None. + Note about args: + Either repeat_quantity and repeat_interval are given, weekdays is given or neither, but not both. + Raises: NotificationServiceNotFound: One of the notification services was not found + InvalidKeyValue: The value of one of the keys is not valid or the "Note about args" is violated Returns: dict: The info about the reminder """ logging.info( f'Adding reminder with {title=}, {time=}, {notification_services=}, ' - + f'{text=}, {repeat_quantity=}, {repeat_interval=}, {color=}' + + f'{text=}, {repeat_quantity=}, {repeat_interval=}, {weekdays=}, {color=}' ) if time < datetime.utcnow().timestamp(): @@ -440,6 +492,8 @@ class Reminders: raise InvalidKeyValue('repeat_quantity', repeat_quantity) elif repeat_quantity is not None and repeat_interval is None: raise InvalidKeyValue('repeat_interval', repeat_interval) + elif weekdays is not None and repeat_quantity is not None and repeat_interval is not None: + raise InvalidKeyValue('weekdays', weekdays) cursor = get_db() for service in notification_services: @@ -449,18 +503,27 @@ class Reminders: ).fetchone(): raise NotificationServiceNotFound - if repeat_quantity is None and repeat_interval is None: - id = cursor.execute(""" - INSERT INTO reminders(user_id, title, text, time, color) - VALUES (?, ?, ?, ?, ?); - """, (self.user_id, title, text, time, color) - ).lastrowid - else: + if repeat_quantity is not None and repeat_interval is not None: id = cursor.execute(""" INSERT INTO reminders(user_id, title, text, time, repeat_quantity, repeat_interval, original_time, color) VALUES (?, ?, ?, ?, ?, ?, ?, ?); """, (self.user_id, title, text, time, repeat_quantity, repeat_interval, time, color) ).lastrowid + + elif weekdays is not None: + weekdays = ",".join(map(str, sorted(weekdays))) + id = cursor.execute(""" + INSERT INTO reminders(user_id, title, text, time, weekdays, original_time, color) + VALUES (?, ?, ?, ?, ?, ?, ?); + """, (self.user_id, title, text, time, weekdays, time, color) + ).lastrowid + + else: + id = cursor.execute(""" + INSERT INTO reminders(user_id, title, text, time, color) + VALUES (?, ?, ?, ?, ?); + """, (self.user_id, title, text, time, color) + ).lastrowid try: cursor.executemany( diff --git a/docs/api.md b/docs/api.md index d8658d3..50b14a4 100644 --- a/docs/api.md +++ b/docs/api.md @@ -181,6 +181,42 @@ The following is automatically generated. Please report any issues on [GitHub](h | 400 | InvalidKeyValue | The value of a key is invalid | | 400 | KeyNotFound | A key was not found in the input that is required to be given | +### `/notificationservices/available` + +| Requires being logged in | Description | +| ------------------------ | ----------- | +| Yes | Get all available notification services and their url layout | + +??? GET + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 200| N/A | Success | + +### `/notificationservices/test` + +| Requires being logged in | Description | +| ------------------------ | ----------- | +| Yes | Send a test notification using the supplied Apprise URL | + +??? POST + + **Parameters (body)** + + | Name | Required | Description | Allowed values | + | ---- | -------- | ----------- | -------------- | + | url | Yes | The Apprise URL of the notification service | N/A | + + **Returns** + + | Code | Error | Description | + | ---- | ----- | ----------- | + | 201| N/A | Success | + | 400 | InvalidKeyValue | The value of a key is invalid | + | 400 | KeyNotFound | A key was not found in the input that is required to be given | + ### `/notificationservices/` | Requires being logged in | Description | @@ -265,6 +301,7 @@ Replace `` with the ID of the entry. For example: `/notificationservic | text | No | The body of the entry | N/A | | repeat_quantity | No | The quantity of the repeat_interval | `years`, `months`, `weeks`, `days`, `hours`, `minutes` | | repeat_interval | No | The number of the interval | N/A | + | weekdays | No | On which days of the weeks to run the reminder | N/A | | color | No | The hex code of the color of the entry, which is shown in the web-ui | N/A | **Returns** @@ -355,6 +392,7 @@ Replace `` with the ID of the entry. For example: `/reminders/2`. | text | No | The body of the entry | N/A | | repeat_quantity | No | The quantity of the repeat_interval | `years`, `months`, `weeks`, `days`, `hours`, `minutes` | | repeat_interval | No | The number of the interval | N/A | + | weekdays | No | On which days of the weeks to run the reminder | N/A | | color | No | The hex code of the color of the entry, which is shown in the web-ui | N/A | **Returns** diff --git a/frontend/api.py b/frontend/api.py index 7f1ac25..5f525f1 100644 --- a/frontend/api.py +++ b/frontend/api.py @@ -19,7 +19,8 @@ from backend.custom_exceptions import (AccessUnauthorized, APIKeyExpired, UsernameInvalid, UsernameTaken, UserNotFound) from backend.notification_service import (NotificationService, - NotificationServices) + NotificationServices, + get_apprise_services) from backend.reminders import Reminders, reminder_handler from backend.static_reminders import StaticReminders from backend.templates import Template, Templates @@ -231,6 +232,21 @@ class RepeatIntervalVariable(DefaultInputVariable): def validate(self) -> bool: return self.value is None or (isinstance(self.value, int) and self.value > 0) +class WeekDaysVariable(DefaultInputVariable): + name = 'weekdays' + description = 'On which days of the weeks to run the reminder' + required = False + default = None + related_exceptions = [InvalidKeyValue] + _options = {0, 1, 2, 3, 4, 5, 6} + + def validate(self) -> bool: + return self.value is None or ( + isinstance(self.value, list) + and len(self.value) > 0 + and all(v in self._options for v in self.value) + ) + class ColorVariable(DefaultInputVariable): name = 'color' description = 'The hex code of the color of the entry, which is shown in the web-ui' @@ -480,6 +496,27 @@ def api_notification_services_list(inputs: Dict[str, str]): url=inputs['url']).get() return return_api(result, code=201) +@api.route( + '/notificationservices/available', + 'Get all available notification services and their url layout', + methods=['GET'] +) +@endpoint_wrapper +def api_notification_service_available(): + result = get_apprise_services() + return return_api(result) + +@api.route( + '/notificationservices/test', + 'Send a test notification using the supplied Apprise URL', + {'POST': [[URLVariable]]}, + methods=['POST'] +) +@endpoint_wrapper +def api_test_service(inputs: Dict[str, Any]): + g.user_data.notification_services.test_service(inputs['url']) + return return_api({}, code=201) + @api.route( '/notificationservices/', 'Manage a specific notification service', @@ -518,6 +555,7 @@ def api_notification_service(inputs: Dict[str, str], n_id: int): 'POST': [[TitleVariable, TimeVariable, NotificationServicesVariable, TextVariable, RepeatQuantityVariable, RepeatIntervalVariable, + WeekDaysVariable, ColorVariable], 'Add a reminder'] }, @@ -538,6 +576,7 @@ def api_reminders_list(inputs: Dict[str, Any]): text=inputs['text'], repeat_quantity=inputs['repeat_quantity'], repeat_interval=inputs['repeat_interval'], + weekdays=inputs['weekdays'], color=inputs['color']) return return_api(result.get(), code=201) @@ -570,6 +609,7 @@ def api_test_reminder(inputs: Dict[str, Any]): {'PUT': [[EditTitleVariable, EditTimeVariable, EditNotificationServicesVariable, TextVariable, RepeatQuantityVariable, RepeatIntervalVariable, + WeekDaysVariable, ColorVariable], 'Edit the reminder'], 'DELETE': [[], @@ -590,6 +630,7 @@ def api_get_reminder(inputs: Dict[str, Any], r_id: int): text=inputs['text'], repeat_quantity=inputs['repeat_quantity'], repeat_interval=inputs['repeat_interval'], + weekdays=inputs['weekdays'], color=inputs['color']) return return_api(result) diff --git a/frontend/static/css/general.css b/frontend/static/css/general.css index a93a477..5a7d580 100644 --- a/frontend/static/css/general.css +++ b/frontend/static/css/general.css @@ -38,7 +38,7 @@ button:hover { cursor: pointer; } -input, +input:not([type="checkbox"]), select, textarea { width: 100%; diff --git a/frontend/static/css/info.css b/frontend/static/css/info.css index aaa4517..85b1e42 100644 --- a/frontend/static/css/info.css +++ b/frontend/static/css/info.css @@ -105,13 +105,12 @@ border-top: 1px solid var(--color-gray); } -.notification-service-list > div > input { - width: fit-content; - box-shadow: none; +.form-container > form .repeat-options > button { + width: calc((100% / 3) - (var(--gap) / 1.5)); + min-width: min-content; } -.repeat-bar, -.repeat-edit-bar { +.repeat-bar { display: flex; justify-content: space-between; align-items: center; @@ -122,32 +121,52 @@ box-shadow: var(--default-shadow); } -.repeat-bar > *, -.repeat-edit-bar > * { +.repeat-bar > input, +.repeat-bar > select { border: 0; box-shadow: none; } -.repeat-bar > p, -.repeat-edit-bar > p { +.repeat-bar > p { min-width: 7rem; padding: 1rem; padding-right: .25rem; } -.repeat-bar > input[type="number"], -.repeat-edit-bar > input[type="number"] { +.repeat-bar > input[type="number"] { min-width: 5rem; padding-left: .25rem; } -.repeat-bar > select, -.repeat-edit-bar > select { +.repeat-bar > select { min-width: 8rem; } +.weekday-bar { + display: grid; + grid-template-columns: auto auto auto auto auto auto auto; + row-gap: .6rem; + + border: 2px solid var(--color-gray); + border-radius: 4px; + padding: 1rem; + + box-shadow: var(--default-shadow); +} + +.weekday-bar > p { + text-align: center; + grid-row: 1 / 2; +} + +.weekday-bar > input { + margin: auto; + width: 1.3rem; + aspect-ratio: 1/1; +} + div.options > button { width: 6rem; @@ -168,14 +187,16 @@ div.options > button { color: var(--color-error); } -#test-reminder { +#test-reminder, +#test-service { display: flex; gap: 1rem; overflow-x: hidden; } -#test-reminder > div { +#test-reminder > div, +#test-service > div { width: 100%; flex: 0 0 auto; @@ -184,16 +205,35 @@ div.options > button { transition: transform .1s linear; } -#test-reminder.show-sent > div { +#test-reminder.show-sent > div, +#test-service.show-sent > div { transform: translateX(calc(-100% - 1rem)); } @media (max-width: 460px) { .sub-inputs > input, .sub-inputs > select, - .sub-inputs > button { + .sub-inputs > button, + .form-container > form .repeat-options > button { width: 100%; } + + .weekday-bar { + grid-template-columns: auto auto; + grid-auto-flow: dense; + column-gap: 1rem; + row-gap: .8rem; + } + + .weekday-bar > p { + grid-row: unset; + text-align: right; + } + + .weekday-bar > input { + margin: unset; + width: min-content; + } } /* */ @@ -206,7 +246,9 @@ div.options > button { #info.show-add-static-reminder #time-input, #info.show-add-static-reminder #normal-button, #info.show-add-static-reminder #repeat-button, +#info.show-add-static-reminder #weekday-button, #info.show-add-static-reminder .repeat-bar, +#info.show-add-static-reminder .weekday-bar, #info.show-add-static-reminder #delete-info { display: none; } @@ -225,7 +267,9 @@ div.options > button { #info.show-add-template #time-input, #info.show-add-template #normal-button, #info.show-add-template #repeat-button, +#info.show-add-template #weekday-button, #info.show-add-template .repeat-bar, +#info.show-add-template .weekday-bar, #info.show-add-template #test-reminder, #info.show-add-template #delete-info { display: none; @@ -256,7 +300,9 @@ div.options > button { #info.show-edit-static-reminder #time-input, #info.show-edit-static-reminder #normal-button, #info.show-edit-static-reminder #repeat-button, -#info.show-edit-static-reminder .repeat-bar { +#info.show-edit-static-reminder #weekday-button, +#info.show-edit-static-reminder .repeat-bar, +#info.show-edit-static-reminder .weekday-bar { display: none; } @@ -273,7 +319,9 @@ div.options > button { #info.show-edit-template #time-input, #info.show-edit-template #normal-button, #info.show-edit-template #repeat-button, +#info.show-edit-template #weekday-button, #info.show-edit-template .repeat-bar, +#info.show-edit-template .weekday-bar, #info.show-edit-template #test-reminder { display: none; } diff --git a/frontend/static/css/notification.css b/frontend/static/css/notification.css index dbea9fb..3fc8a69 100644 --- a/frontend/static/css/notification.css +++ b/frontend/static/css/notification.css @@ -90,4 +90,178 @@ #services-list > tr.edit > td.action-column > button[data-type="edit"], #services-list > tr:not(.edit):not(#add-row) > td.action-column > button[data-type="save"] { display: none; -} \ No newline at end of file +} + +/* */ +/* Add service */ +/* */ +#add-service-container { + display: none; +} + +.overflow-container.show-add #add-service-container { + display: block; +} + +.overflow-container.show-add > table { + display: none; +} + +#service-list { + display: flex; + gap: 1rem; + flex-wrap: wrap; + justify-content: center; +} + +#service-list button { + width: max(30%, 10rem); + height: 6rem; + + display: flex; + justify-content: center; + align-items: center; + + padding: .75rem; + border-radius: 4px; + border: 2px solid var(--color-gray); + + text-align: center; + font-size: 1.1rem; +} + +#add-service-container.show-add-window #add-service-window { + display: flex; +} + +#add-service-container.show-add-window #service-list { + display: none; +} + +/* */ +/* Add service form */ +/* */ +#add-service-window { + max-width: 30rem; + margin: auto; + + display: none; + flex-direction: column; + justify-content: center; + gap: 1rem; + + text-align: center; +} + +#add-service-window > h3 { + font-size: 1.75rem; +} + +#add-service-window > p { + margin-bottom: calc((1rem + 2px) * -1); + + border: 2px solid var(--color-gray); + border-top-left-radius: 4px; + border-top-right-radius: 4px; + padding: .75rem 1rem; + color: var(--color-gray); + + text-align: left; + + box-shadow: var(--default-shadow); +} + +#add-service-window > button { + border-radius: 4px; + border: 2px solid var(--color-gray); + padding: .75rem; +} + +#add-service-window > a, +#add-service-window > p > a { + color: var(--color-light); +} + +#add-service-window > div[data-map], +#add-service-window > div[data-map] > .entries-list { + display: flex; + flex-direction: column; + gap: inherit; +} + +#add-service-window > div[data-map] { + padding: .5rem; + border: 2px solid var(--color-gray); + border-radius: 4px; + box-shadow: var(--default-shadow); +} + +#add-service-window > div[data-map] > p { + color: var(--color-gray); + font-size: 1.1rem; +} + +.entries-list { + min-height: 5rem; + max-height: 15rem; + overflow-y: auto; + + align-items: center; + + background-color: var(--color-dark); + color: var(--color-light); + border: 2px solid var(--color-gray); + border-radius: 4px; + padding: .75rem; + box-shadow: var(--default-shadow); + + font-size: 1rem; +} + +.entries-list > p:first-child { + color: var(--color-gray); + font-size: 1.1rem; +} + +.input-entries:not(:has(div)) { + display: none; +} + +.add-row { + height: 2rem; + width: 80%; + + display: flex; + gap: 1rem; +} + +.add-row input { + flex-grow: 1; + font-size: .8rem; +} + +.add-row button { + padding: .35rem .75rem; + background-color: var(--color-gray); + border-radius: 4px; +} + +.entries-list > button { + height: 1.5rem; + width: min(100%, 21rem); + + display: flex; + justify-content: center; + align-items: center; + + background-color: var(--color-gray); +} + +.entries-list > button svg { + height: 60%; +} + +.entries-list > button path { + height: inherit; + fill: var(--color-dark); +} diff --git a/frontend/static/js/general.js b/frontend/static/js/general.js index bc74a16..cc249ee 100644 --- a/frontend/static/js/general.js +++ b/frontend/static/js/general.js @@ -7,7 +7,8 @@ const types = { const icons = { 'save': '', 'edit': '', - 'delete': '' + 'delete': '', + 'add': '' }; const info_classes = [ @@ -47,20 +48,71 @@ function logout() { 'method': 'POST' }) .then(response => { - const new_stor = JSON.parse(localStorage.getItem('MIND')) - new_stor.api_key = null - localStorage.setItem('MIND', JSON.stringify(new_stor)); + setLocalStorage({'api_key': null}); window.location.href = `${url_prefix}/`; }); }; +// +// LocalStorage +// +const default_values = { + 'api_key': null, + 'locale': 'en-GB', + 'default_service': null +}; + +function setupLocalStorage() { + if (!localStorage.getItem('MIND')) + localStorage.setItem('MIND', JSON.stringify(default_values)); + + const missing_keys = [ + ...Object.keys(default_values) + ].filter(e => + ![...Object.keys(JSON.parse(localStorage.getItem('MIND')))].includes(e) + ) + + if (missing_keys.length) { + const storage = JSON.parse(localStorage.getItem('MIND')); + + missing_keys.forEach(missing_key => { + storage[missing_key] = default_values[missing_key] + }) + + localStorage.setItem('MIND', JSON.stringify(storage)); + }; + return; +}; + +function getLocalStorage(keys) { + const storage = JSON.parse(localStorage.getItem('MIND')); + const result = {}; + if (typeof keys === 'string') + result[keys] = storage[keys]; + + else if (typeof keys === 'object') + for (const key in keys) + result[key] = storage[key]; + + return result; +}; + +function setLocalStorage(keys_values) { + const storage = JSON.parse(localStorage.getItem('MIND')); + + for (const [key, value] of Object.entries(keys_values)) + storage[key] = value; + + localStorage.setItem('MIND', JSON.stringify(storage)); + return; +}; + // code run on load -if (localStorage.getItem('MIND') === null) - localStorage.setItem('MIND', JSON.stringify({'api_key': null, 'locale': 'en-GB'})) +setupLocalStorage(); const url_prefix = document.getElementById('url_prefix').dataset.value; -const api_key = JSON.parse(localStorage.getItem('MIND')).api_key; +const api_key = getLocalStorage('api_key')['api_key']; if (api_key === null) { window.location.href = `${url_prefix}/`; }; diff --git a/frontend/static/js/library.js b/frontend/static/js/library.js index 215d006..ac74f85 100644 --- a/frontend/static/js/library.js +++ b/frontend/static/js/library.js @@ -39,6 +39,19 @@ function showTab(button) { // // Filling library // +function getWeekDays(locale) +{ + let baseDate = new Date(Date.UTC(2017, 0, 2)); // just a Monday + let weekDays = []; + for(i = 0; i < 7; i++) + { + weekDays.push(baseDate.toLocaleDateString(locale, { weekday: 'short' })); + baseDate.setDate(baseDate.getDate() + 1); + } + return weekDays; +}; +const week_days = getWeekDays(getLocalStorage('locale')['locale']); + function fillTable(table, results) { table.querySelectorAll('button.entry:not(.add-entry)').forEach(e => e.remove()); @@ -56,19 +69,22 @@ function fillTable(table, results) { if (table === types.reminder) { const time = document.createElement('p'); - var offset = new Date(r.time * 1000).getTimezoneOffset() * -60; - var d = new Date((r.time + offset) * 1000); - var formatted_date = d.toLocaleString(JSON.parse(localStorage.getItem('MIND')).locale); + let offset = new Date(r.time * 1000).getTimezoneOffset() * -60; + let d = new Date((r.time + offset) * 1000); + let formatted_date = d.toLocaleString(getLocalStorage('locale')['locale']); + if (r.repeat_interval !== null) { if (r.repeat_interval === 1) { - var quantity = r.repeat_quantity.endsWith('s') ? r.repeat_quantity.slice(0, -1) : r.repeat_quantity; + let quantity = r.repeat_quantity.slice(0, -1) var interval_text = ` (each ${quantity})`; } else { - var quantity = r.repeat_quantity.endsWith('s') ? r.repeat_quantity : r.repeat_quantity + 's'; - var interval_text = ` (every ${r.repeat_interval} ${quantity})`; + var interval_text = ` (every ${r.repeat_interval} ${r.repeat_quantity})`; }; formatted_date += interval_text; - }; + + } else if (r.weekdays !== null) + formatted_date += ` (each ${r.weekdays.split(',').map(d => week_days[parseInt(d)]).join(', ')})`; + time.innerText = formatted_date; entry.appendChild(time); }; diff --git a/frontend/static/js/notification.js b/frontend/static/js/notification.js index 272937b..318a8bf 100644 --- a/frontend/static/js/notification.js +++ b/frontend/static/js/notification.js @@ -8,6 +8,24 @@ function fillNotificationSelection() { if (json.result.length) { document.getElementById('add-reminder').classList.remove('error', 'error-icon'); + const default_select = document.querySelector('#default-service-input'); + default_select.innerHTML = ''; + let default_service = getLocalStorage('default_service')['default_service']; + json.result.forEach(service => { + const entry = document.createElement('option'); + entry.value = service.id; + entry.innerText = service.title; + if (default_service === service.id) + entry.setAttribute('selected', ''); + default_select.appendChild(entry); + }); + if (!document.querySelector(`#default-service-input > option[value="${default_service}"]`)) + setLocalStorage({'default_service': + parseInt(document.querySelector('#default-service-input > option')?.value) + || null + }); + default_service = getLocalStorage('default_service')['default_service']; + inputs.notification_service.innerHTML = ''; json.result.forEach(service => { const entry = document.createElement('div'); @@ -26,8 +44,7 @@ function fillNotificationSelection() { inputs.notification_service.querySelector(':first-child input').checked = true; const table = document.getElementById('services-list'); - table.querySelectorAll('tr:not(#add-row)').forEach(e => e.remove()); - // table.innerHTML = ''; + table.innerHTML = ''; json.result.forEach(service => { const entry = document.createElement('tr'); entry.dataset.id = service.id; @@ -47,6 +64,10 @@ function fillNotificationSelection() { url.setAttribute('readonly', ''); url.setAttribute('type', 'text'); url.value = service.url; + url.addEventListener('keydown', e => { + if (e.key === 'Enter') + saveService(service.id); + }); url_container.appendChild(url); entry.appendChild(url_container); @@ -82,6 +103,18 @@ function fillNotificationSelection() { }); } else { document.getElementById('add-reminder').classList.add('error', 'error-icon'); + + inputs.notification_service.innerHTML = ''; + + const default_select = document.querySelector('#default-service-input'); + default_select.innerHTML = ''; + + const default_service = getLocalStorage('default_service')['default_service']; + if (!document.querySelector(`#default-service-input > option[value="${default_service}"]`)) + setLocalStorage({'default_service': + parseInt(document.querySelector('#default-service-input > option')?.value) + || null + }); }; }) .catch(e => { @@ -136,8 +169,9 @@ function deleteService(id) { if (json.error !== null) return Promise.reject(json); row.remove(); - if (document.querySelectorAll('#services-list > tr:not(#add-row)').length === 0) - document.getElementById('add-entry').classList.add('error', 'error-icon'); + fillNotificationSelection(); + if (document.querySelectorAll('#services-list > tr').length === 0) + document.getElementById('add-reminder').classList.add('error', 'error-icon'); }) .catch(e => { if (e.error === 'ApiKeyExpired' || e.error === 'ApiKeyInvalid') @@ -151,10 +185,58 @@ function deleteService(id) { }); }; +function testService() { + const test_button = document.querySelector('#test-service'); + + // Check regexes for input's + [...document.querySelectorAll('#add-service-window > input:not([data-regex=""])[data-regex]')] + .forEach(el => el.classList.remove('error-input')); + + const faulty_inputs = + [...document.querySelectorAll('#add-service-window > input:not([data-regex=""])[data-regex]')] + .filter(el => !new RegExp + ( + el.dataset.regex.split('').reverse().join('').split(',').slice(1).join(',').split('').reverse().join(''), + el.dataset.regex.split('').reverse().join('').split(',')[0] + ).test(el.value) + ); + if (faulty_inputs.length > 0) { + faulty_inputs.forEach(el => el.classList.add('error-input')); + return; + }; + + const data = { + 'url': buildAppriseURL() + }; + if (!data.url) { + test_button.classList.add('error-input'); + test_button.title = 'Required field missing'; + return; + }; + fetch(`${url_prefix}/api/notificationservices/test?api_key=${api_key}`, { + 'method': 'POST', + 'headers': {'Content-Type': 'application/json'}, + 'body': JSON.stringify(data) + }) + .then(response => { + if (!response.ok) return Promise.reject(response.status); + + test_button.classList.remove('error-input'); + test_button.title = ''; + test_button.classList.add('show-sent'); + }) + .catch(e => { + if (e === 401) + window.location.href = `${url_prefix}/`; + else if (e === 400) { + test_button.classList.add('error-input'); + test_button.title = 'Invalid Apprise URL'; + } else + console.log(e); + }); +}; + function toggleAddService() { - document.getElementById('add-row').classList.toggle('hidden'); - return; - const cont = document.querySelector('.overflow-container'); if (cont.classList.contains('show-add')) { // Hide add @@ -180,110 +262,239 @@ function toggleAddService() { }; }; +function createTitle() { + const service_title = document.createElement('input'); + service_title.id = 'service-title'; + service_title.type = 'text'; + service_title.placeholder = 'Service Title'; + service_title.required = true; + return service_title; +}; + +function createChoice(token) { + const choice = document.createElement('select'); + choice.dataset.map = token.map_to || ''; + choice.dataset.prefix = ''; + choice.placeholder = token.name; + choice.required = token.required; + token.options.forEach(option => { + const entry = document.createElement('option'); + entry.value = option; + entry.innerText = option; + choice.appendChild(entry); + }); + if (token.default) + choice.querySelector(`option[value="${token.default}"]`).setAttribute('selected', ''); + + return choice; +}; + +function createString(token) { + const str_input = document.createElement('input'); + str_input.dataset.map = token.map_to || ''; + str_input.dataset.prefix = token.prefix || ''; + str_input.dataset.regex = token.regex || ''; + str_input.type = 'text'; + str_input.placeholder = `${token.name}${!token.required ? ' (Optional)' : ''}`; + str_input.required = token.required; + return str_input; +}; + +function createInt(token) { + const int_input = document.createElement('input'); + int_input.dataset.map = token.map_to || ''; + int_input.dataset.prefix = token.prefix || ''; + int_input.type = 'number'; + int_input.placeholder = `${token.name}${!token.required ? ' (Optional)' : ''}`; + int_input.required = token.required; + if (token.min !== null) + int_input.min = token.min; + if (token.max !== null) + int_input.max = token.max; + return int_input; +}; + +function createBool(token) { + const bool_input = document.createElement('select'); + bool_input.dataset.map = token.map_to || ''; + bool_input.dataset.prefix = ''; + bool_input.placeholder = token.name; + bool_input.required = token.required; + [['Yes', 'true'], ['No', 'false']].forEach(option => { + const entry = document.createElement('option'); + entry.value = option[1]; + entry.innerText = option[0]; + bool_input.appendChild(entry); + }); + bool_input.querySelector(`option[value="${token.default}"]`).setAttribute('selected', ''); + + return bool_input; +}; + +function createEntriesList(token) { + const entries_list = document.createElement('div'); + entries_list.classList.add('entries-list'); + entries_list.dataset.map = token.map_to || ''; + entries_list.dataset.delim = token.delim || ''; + entries_list.dataset.prefix = token.prefix || ''; + + const entries_desc = document.createElement('p'); + entries_desc.innerText = token.name; + entries_list.appendChild(entries_desc); + + const entries = document.createElement('div'); + entries.classList.add('input-entries'); + entries_list.appendChild(entries); + + const add_row = document.createElement('div'); + add_row.classList.add('add-row', 'hidden'); + const add_input = document.createElement('input'); + add_input.type = 'text'; + add_input.addEventListener('keydown', e => { + if (e.key === "Enter") { + e.preventDefault(); + e.stopImmediatePropagation(); + addEntry(entries_list); + }; + }); + add_row.appendChild(add_input); + const add_entry_button = document.createElement('button'); + add_entry_button.type = 'button'; + add_entry_button.innerText = 'Add'; + add_entry_button.addEventListener('click', e => addEntry(entries_list)); + add_row.appendChild(add_entry_button); + entries_list.appendChild(add_row); + + const add_button = document.createElement('button'); + add_button.type = 'button'; + add_button.innerHTML = icons.add; + add_button.addEventListener('click', e => toggleAddRow(add_row)); + entries_list.appendChild(add_button); + + return entries_list; +}; + +function toggleAddRow(row) { + if (row.classList.contains('hidden')) { + // Show row + row.querySelector('input').value = ''; + row.classList.remove('hidden'); + } else { + // Hide row + row.classList.add('hidden'); + }; +}; + +function addEntry(entries_list) { + const value = entries_list.querySelector('.add-row > input').value; + const entry = document.createElement('div'); + entry.innerText = value; + entries_list.querySelector('.input-entries').appendChild(entry); + toggleAddRow(entries_list.querySelector('.add-row')); +}; + function showAddServiceWindow(index) { const window = document.getElementById('add-service-window'); window.innerHTML = ''; + window.dataset.index = index; - if (index === -1) { - // Custom url - const title = document.createElement('h3'); - title.innerText = 'Custom URL'; - window.appendChild(title); - - const desc = document.createElement('p'); - desc.innerHTML = 'Enter a custom Apprise URL. See the Apprise URL documentation.'; - window.appendChild(desc); + const data = notification_services[index]; + console.log(data); + + const title = document.createElement('h3'); + title.innerText = data.name; + window.appendChild(title); - const service_title = document.createElement('input'); - service_title.id = 'service-title'; - service_title.type = 'text'; - service_title.placeholder = 'Service Title'; - service_title.required = true; - window.appendChild(service_title); - - const url_input = document.createElement('input'); - url_input.type = 'text'; - url_input.placeholder = 'Apprise URL'; - window.appendChild(url_input); - } else { - const data = notification_services[index]; - console.log(data); - - const title = document.createElement('h3'); - title.innerText = data.name; - window.appendChild(title); + const docs = document.createElement('a'); + docs.href = data.doc_url; + docs.target = '_blank'; + docs.innerText = 'Documentation'; + window.appendChild(docs); - const docs = document.createElement('a'); - docs.href = data.doc_url; - docs.target = '_blank'; - docs.innerText = 'Documentation'; - window.appendChild(docs); + window.appendChild(createTitle()); + + [[data.details.tokens, 'tokens'], [data.details.args, 'args']].forEach(vars => { + if (vars[1] === 'args' && vars[0].length > 0) { + // The args are hidden behind a "Show Advanced Settings" button + const show_args = document.createElement('button'); + show_args.type = 'button'; + show_args.innerText = 'Show Advanced Settings'; + show_args.addEventListener('click', e => { + window.querySelectorAll('[data-is_arg="true"]').forEach(el => el.classList.toggle('hidden')); + show_args.innerText = show_args.innerText === 'Show Advanced Settings' ? 'Hide Advanced Settings' : 'Show Advanced Settings'; + }); + window.appendChild(show_args); + }; - const service_title = document.createElement('input'); - service_title.id = 'service-title'; - service_title.type = 'text'; - service_title.placeholder = 'Service Title'; - service_title.required = true; - window.appendChild(service_title); - - data.details.tokens.forEach(token => { + vars[0].forEach(token => { + let result = null; if (token.type === 'choice') { - const choice = document.createElement('select'); - choice.dataset.map = token.map_to; - choice.dataset.prefix = ''; - choice.placeholder = token.name; - choice.required = token.required; - token.options.forEach(option => { - const entry = document.createElement('option'); - entry.value = option; - entry.innerText = option; - choice.appendChild(entry); - }); - window.appendChild(choice); - + const desc = document.createElement('p'); + desc.innerText = `${token.name}${!token.required ? ' (Optional)' : ''}`; + desc.dataset.is_arg = vars[1] === 'args'; + window.appendChild(desc); + result = createChoice(token); + } else if (token.type === 'list') { - if (token.content.length === 0) { - - } else { - token.content.forEach(content => { - - }); - }; - - } else if (token.type === 'string') { - const str_input = document.createElement('input'); - str_input.dataset.map = token.map_to; - str_input.dataset.prefix = token.prefix; - str_input.dataset.regex = token.regex; - str_input.type = 'text'; - str_input.placeholder = `${token.name}${!token.required ? ' (Optional)' : ''}`; - str_input.required = token.required; - window.appendChild(str_input); - - } else if (token.type === 'int') { - const int_input = document.createElement('input'); - int_input.dataset.map = token.map_to; - int_input.dataset.prefix = token.prefix; - int_input.type = 'number'; - int_input.placeholder = `${token.name}${!token.required ? ' (Optional)' : ''}`; - int_input.required = token.required; - if (token.min !== null) - int_input.min = token.min; - if (token.max !== null) - int_input.max = token.max; - window.appendChild(int_input); + const joint_list = document.createElement('div'); + joint_list.dataset.map = token.map_to; + joint_list.dataset.delim = token.delim; + + const desc = document.createElement('p'); + desc.innerText = `${token.name}${!token.required ? ' (Optional)' : ''}`; + joint_list.appendChild(desc); + + if (token.content.length === 0) + joint_list.appendChild(createEntriesList(token)); + else + token.content.forEach(content => + joint_list.appendChild(createEntriesList(content)) + ); + + result = joint_list; + + } else if (token.type === 'string') + result = createString(token); + else if (token.type === 'int') + result = createInt(token); + else if (token.type === 'bool') { + const desc = document.createElement('p'); + desc.innerText = `${token.name}${!token.required ? ' (Optional)' : ''}`; + desc.dataset.is_arg = vars[1] === 'args'; + window.appendChild(desc); + result = createBool(token); }; + + result.dataset.is_arg = vars[1] === 'args'; + window.appendChild(result); }); - }; + + if (vars[1] === 'args' && vars[0].length > 0) + window.querySelectorAll('[data-is_arg="true"]').forEach(el => el.classList.toggle('hidden')); + }) // Bottom options const options = document.createElement('div'); options.classList.add('options'); + const cancel = document.createElement('button'); cancel.type = 'button'; cancel.innerText = 'Cancel'; cancel.addEventListener('click', e => toggleAddService()); options.appendChild(cancel); + + const test = document.createElement('button'); + test.id = 'test-service'; + test.type = 'button'; + test.addEventListener('click', e => testService()); + options.appendChild(test); + const test_text = document.createElement('div'); + test_text.innerText = 'Test'; + test.appendChild(test_text); + const test_sent_text = document.createElement('div'); + test_sent_text.innerText = 'Sent'; + test.appendChild(test_sent_text); + const add = document.createElement('button'); add.type = 'submit'; add.innerText = 'Add'; @@ -298,20 +509,109 @@ function hideAddServiceWindow() { }; function buildAppriseURL() { - return null; + const data = notification_services[document.querySelector('#add-service-window').dataset.index]; + const inputs = document.querySelectorAll('#add-service-window > [data-map][data-is_arg="false"]'); + const values = {}; + + // Gather all values and format + inputs.forEach(i => { + if (['INPUT', 'SELECT'].includes(i.nodeName)) { + // Standard input + let value = `${i.dataset.prefix || ''}${i.value}`; + if (value) + values[i.dataset.map] = value; + } else if (i.nodeName === 'DIV') { + let value = + [...i.querySelectorAll('.entries-list')] + .map(l => + [...l.querySelectorAll('.input-entries > div')] + .map(e => `${l.dataset.prefix || ''}${e.innerText}`) + ) + .flat() + .join(i.dataset.delim) + + if (value) + values[i.dataset.map] = value; + }; + }); + + // Find template(s) that match the given tokens + const input_keys = Object.keys(values).sort().join(); + const matching_templates = data.details.templates.filter(template => + input_keys === template.replaceAll('}', '{').split('{').filter((e, i) => i % 2).sort().join() + ); + + if (!matching_templates.length) + return null; + + // Build URL with template and values + let template = matching_templates[0]; + + for (const [key, value] of Object.entries(values)) + template = template.replace(`{${key}}`, value); + + // Add args + const args = [...document.querySelectorAll('#add-service-window > [data-map][data-is_arg="true"]')] + .map(el => { + if (['INPUT', 'SELECT'].includes(el.nodeName) && el.value) + return `${el.dataset.map}=${el.value}`; + else if (el.nodeName == 'DIV') { + let value = + [...el.querySelectorAll('.entries-list')] + .map(l => + [...l.querySelectorAll('.input-entries > div')] + .map(e => `${l.dataset.prefix || ''}${e.innerText}`) + ) + .flat() + .join(el.dataset.delim) + + if (value) + return `${el.dataset.map}=${value}`; + }; + + return null; + }) + .filter(el => el !== null) + .join('&') + if (args) + template += (template.includes('?') ? '&' : '?') + args; + template = template.replaceAll(' ', '%20'); + + console.debug(matching_templates); + console.debug(template); + + return template; }; function addService() { - const add_button = document.querySelector('#add-row > .action-column > button'); - const data = { - 'title': document.querySelector('#add-row > .title-column > input').value, - 'url': document.querySelector('#add-row > .url-column > input').value + const add_button = document.querySelector('#add-service-window > .options > button[type="submit"]'); + + // Check regexes for input's + [...document.querySelectorAll('#add-service-window > input:not([data-regex=""])[data-regex]')] + .forEach(el => el.classList.remove('error-input')); + + const faulty_inputs = + [...document.querySelectorAll('#add-service-window > input:not([data-regex=""])[data-regex]')] + .filter(el => !new RegExp + ( + el.dataset.regex.split('').reverse().join('').split(',').slice(1).join(',').split('').reverse().join(''), + el.dataset.regex.split('').reverse().join('').split(',')[0] + ).test(el.value) + ); + if (faulty_inputs.length > 0) { + faulty_inputs.forEach(el => el.classList.add('error-input')); + return; + }; + + const data = { + 'title': document.querySelector('#service-title').value, + 'url': buildAppriseURL() + }; + if (!data.url) { + add_button.classList.add('error-input'); + add_button.title = 'Required field missing'; + return; }; - // const add_button = document.querySelector('#add-service-window > .options > button[type="submit"]'); - // const data = { - // 'title': document.querySelector('#service-title').value, - // 'url': buildAppriseURL() - // }; fetch(`${url_prefix}/api/notificationservices?api_key=${api_key}`, { 'method': 'POST', 'headers': {'Content-Type': 'application/json'}, @@ -330,8 +630,7 @@ function addService() { if (e === 401) window.location.href = `${url_prefix}/`; else if (e === 400) { - // add_button.classList.add('error-input'); - add_button.classList.add('error-icon'); + add_button.classList.add('error-input'); add_button.title = 'Invalid Apprise URL'; } else console.log(e); @@ -345,6 +644,4 @@ fillNotificationSelection(); let notification_services = null; document.getElementById('add-service-button').addEventListener('click', e => toggleAddService()); -// document.querySelector('#service-list button').addEventListener('click', e => showAddServiceWindow(-1)); -// document.getElementById('add-service-window').setAttribute('action', 'javascript:addService();'); -document.querySelector('#add-row > .action-column > button').addEventListener('click', e => addService()); +document.getElementById('add-service-window').setAttribute('action', 'javascript:addService();'); diff --git a/frontend/static/js/settings.js b/frontend/static/js/settings.js index b422e51..e73559d 100644 --- a/frontend/static/js/settings.js +++ b/frontend/static/js/settings.js @@ -1,5 +1,14 @@ function loadSettings() { - document.getElementById('locale-input').value = JSON.parse(localStorage.getItem('MIND')).locale; + document.getElementById('locale-input').value = getLocalStorage('locale')['locale']; +}; + +function updateLocale(e) { + setLocalStorage({'locale': e.target.value}); + window.location.reload(); +}; + +function updateDefaultService(e) { + setLocalStorage({'default_service': parseInt(e.target.value)}); }; function changePassword() { @@ -23,13 +32,6 @@ function changePassword() { }); }; -function updateLocale(e) { - const new_stor = JSON.parse(localStorage.getItem('MIND')); - new_stor.locale = e.target.value; - localStorage.setItem('MIND', JSON.stringify(new_stor)); - window.location.reload(); -}; - function deleteAccount() { fetch(`${url_prefix}/api/user?api_key=${api_key}`, { 'method': 'DELETE' @@ -43,6 +45,7 @@ function deleteAccount() { loadSettings(); -document.getElementById('change-password-form').setAttribute('action', 'javascript:changePassword()'); document.getElementById('locale-input').addEventListener('change', updateLocale); +document.querySelector('#default-service-input').addEventListener('change', updateDefaultService); +document.getElementById('change-password-form').setAttribute('action', 'javascript:changePassword()'); document.getElementById('delete-account-button').addEventListener('click', e => deleteAccount()); diff --git a/frontend/static/js/show.js b/frontend/static/js/show.js index 672f000..5c33b2b 100644 --- a/frontend/static/js/show.js +++ b/frontend/static/js/show.js @@ -1,10 +1,12 @@ function showAdd(type) { + const default_service = getLocalStorage('default_service')['default_service']; inputs.template.value = '0'; inputs.title.value = ''; inputs.text.value = ''; inputs.time.value = ''; inputs.notification_service.querySelectorAll('input[type="checkbox"]').forEach(c => c.checked = false); - inputs.notification_service.querySelector('input[type="checkbox"]:first-child').checked = true; + inputs.notification_service.querySelector(`input[type="checkbox"][data-id="${default_service}"]`).checked = true; + document.querySelectorAll('.weekday-bar > input[type="checkbox"]').forEach(el => el.checked = false); toggleNormal(); toggleColor(true); document.getElementById('test-reminder').classList.remove('show-sent'); @@ -77,13 +79,18 @@ function showEdit(id, type) { ); if (type == types.reminder) { - if (json.result.repeat_interval === null) { - toggleNormal(); - } else { + if (json.result.repeat_interval !== null) { toggleRepeated(); type_buttons.repeat_interval.value = json.result.repeat_interval; type_buttons.repeat_quantity.value = json.result.repeat_quantity; - }; + } + else if (json.result.weekdays !== null) { + toggleWeekDay(); + [...document.querySelectorAll('.weekday-bar > input[type="checkbox"]')] + .map((el, index) => [el, index]) + .forEach(el => el[0].checked = json.result.weekdays.includes(el[1])) + } else + toggleNormal(); }; inputs.text.value = json.result.text; diff --git a/frontend/static/js/window.js b/frontend/static/js/window.js index 834b2d5..db2a160 100644 --- a/frontend/static/js/window.js +++ b/frontend/static/js/window.js @@ -12,10 +12,13 @@ const inputs = { const type_buttons = { 'normal_button': document.getElementById('normal-button'), 'repeat_button': document.getElementById('repeat-button'), + 'weekday_button': document.getElementById('weekday-button'), 'repeat_bar': document.querySelector('.repeat-bar'), 'repeat_interval': document.getElementById('repeat-interval'), - 'repeat_quantity': document.getElementById('repeat-quantity') + 'repeat_quantity': document.getElementById('repeat-quantity'), + + 'weekday_bar': document.querySelector('.weekday-bar') }; function loadColor() { @@ -54,18 +57,38 @@ function toggleNotificationService(hide=false) { function toggleNormal() { type_buttons.normal_button.dataset.selected = 'true'; type_buttons.repeat_button.dataset.selected = 'false'; + type_buttons.weekday_button.dataset.selected = 'false'; type_buttons.repeat_bar.classList.add('hidden'); type_buttons.repeat_interval.removeAttribute('required'); type_buttons.repeat_interval.value = ''; + + type_buttons.weekday_bar.classList.add('hidden'); + type_buttons.weekday_bar.querySelectorAll('input[type="checkbox"]').forEach(el => el.checked = false); }; function toggleRepeated() { type_buttons.normal_button.dataset.selected = 'false'; type_buttons.repeat_button.dataset.selected = 'true'; + type_buttons.weekday_button.dataset.selected = 'false'; type_buttons.repeat_bar.classList.remove('hidden'); type_buttons.repeat_interval.setAttribute('required', ''); + + type_buttons.weekday_bar.classList.add('hidden'); + type_buttons.weekday_bar.querySelectorAll('input[type="checkbox"]').forEach(el => el.checked = false); +}; + +function toggleWeekDay() { + type_buttons.normal_button.dataset.selected = 'false'; + type_buttons.repeat_button.dataset.selected = 'false'; + type_buttons.weekday_button.dataset.selected = 'true'; + + type_buttons.repeat_bar.classList.add('hidden'); + type_buttons.repeat_interval.removeAttribute('required'); + type_buttons.repeat_interval.value = ''; + + type_buttons.weekday_bar.classList.remove('hidden'); }; function testReminder() { @@ -169,6 +192,8 @@ function submitInfo() { inputs.time.removeAttribute('title'); inputs.notification_service.classList.remove('error-input'); inputs.notification_service.removeAttribute('title'); + type_buttons.weekday_bar.classList.remove('error-input'); + type_buttons.weekday_bar.removeAttribute('title'); let fetch_data = { url: null, method: null, @@ -189,7 +214,7 @@ function submitInfo() { if (data.notification_services.length === 0) { inputs.notification_service.classList.add('error-input'); inputs.notification_service.title = 'No notification service set'; - return + return; }; const e_id = document.getElementById('info').dataset.id; @@ -200,7 +225,21 @@ function submitInfo() { if (type_buttons.repeat_button.dataset.selected === 'true') { data['repeat_quantity'] = type_buttons.repeat_quantity.value; data['repeat_interval'] = parseInt(type_buttons.repeat_interval.value) + + } else if (type_buttons.weekday_button.dataset.selected === 'true') { + data['weekdays'] = + [...document.querySelectorAll('.weekday-bar > input[type="checkbox"]')] + .map((el, index) => [el, index]) + .filter(el => el[0].checked) + .map(el => el[1]); + + if (data['weekdays'].length === 0) { + type_buttons.weekday_bar.classList.add('error-input'); + type_buttons.weekday_bar.title = 'No day of the week is selected'; + return; + }; }; + fetch_data.url = `${url_prefix}/api/reminders?api_key=${api_key}`; fetch_data.method = 'POST'; fetch_data.call_back = fillReminders; @@ -226,7 +265,21 @@ function submitInfo() { if (type_buttons.repeat_button.dataset.selected === 'true') { data['repeat_quantity'] = type_buttons.repeat_quantity.value; data['repeat_interval'] = parseInt(type_buttons.repeat_interval.value) + + } else if (type_buttons.weekday_button.dataset.selected === 'true') { + data['weekdays'] = + [...document.querySelectorAll('.weekday-bar > input[type="checkbox"]')] + .map((el, index) => [el, index]) + .filter(el => el[0].checked) + .map(el => el[1]); + + if (data['weekdays'].length === 0) { + type_buttons.weekday_bar.classList.add('error-input'); + type_buttons.weekday_bar.title = 'No day of the week is selected'; + return; + }; }; + fetch_data.url = `${url_prefix}/api/reminders/${e_id}?api_key=${api_key}`; fetch_data.method = 'PUT'; fetch_data.call_back = fillReminders; @@ -279,6 +332,7 @@ document.getElementById('color-toggle').addEventListener('click', e => toggleCol document.getElementById('toggle-notification-service-list').addEventListener('click', e => toggleNotificationService()); document.getElementById('normal-button').addEventListener('click', e => toggleNormal()); document.getElementById('repeat-button').addEventListener('click', e => toggleRepeated()); +document.getElementById('weekday-button').addEventListener('click', e => toggleWeekDay()); document.getElementById('close-info').addEventListener('click', e => hideWindow()); document.getElementById('delete-info').addEventListener('click', e => deleteInfo()); document.getElementById('test-reminder').addEventListener('click', e => testReminder()); diff --git a/frontend/templates/reminders.html b/frontend/templates/reminders.html index 47e7198..be5fad8 100644 --- a/frontend/templates/reminders.html +++ b/frontend/templates/reminders.html @@ -163,7 +163,7 @@ -
+
+
+
@@ -210,7 +234,7 @@

Notification Services

-

Setup your notification providers here. See the Apprise URL documentation to learn how to make a valid Apprise URL.

+

Setup your notification providers here

@@ -264,11 +276,6 @@

Settings

-

Change Password

-
- - -

Locale

+ +

Default Notification Service

+ + +

Change Password

+
+ + +
+

Delete Account

diff --git a/requirements.txt b/requirements.txt index 262de54..be86fbe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -apprise>=0.9.9 +apprise>=1.4.5 python-dateutil>=2.8.2 Flask>=2.1.2 waitress>=2.1.2 \ No newline at end of file