24 Commits
Author SHA1 Message Date
yyc12345 0f674d0483 feat: use AI to migrate calendar and event views 2026-07-09 16:59:00 +08:00
yyc12345 e6fc06c0f9 feat: use AI to migrate DateTimePicker component 2026-07-09 16:09:14 +08:00
yyc12345 91317c9eb7 refactor: move individual css into global css for sharing use 2026-07-08 11:12:36 +08:00
yyc12345 b1b42ab2fb fix: fix type error in datetime module 2026-07-07 21:03:22 +08:00
yyc12345 18f73b3084 feat: finish datatime utility functions 2026-06-30 16:28:06 +08:00
yyc12345 fcc022d070 feat: migrate utils 2026-06-30 15:41:34 +08:00
yyc12345 fda2fe9ec9 feat: finish collection migration 2026-06-30 14:08:51 +08:00
yyc12345 ebe7ea5f56 feat: improve api 2026-06-08 10:45:35 +08:00
yyc12345 c4b68400b3 feat: move old scripts from my gist to here 2026-06-01 23:27:41 +08:00
yyc12345 d7df194a12 feat: add image for legacy frontend 2026-06-01 23:26:45 +08:00
yyc12345 35fee0f473 refactor: modify backend for better understand 2026-05-15 11:08:57 +08:00
yyc12345 e484ded5be feat: add logout for navbar 2026-05-15 09:39:15 +08:00
yyc12345 2a280dcba0 feat: support login
- migrate old API into typescript but not finished (only webLogin works now)
- seperate the logger of backend due to the shitty behavior of Flask (change logging level)
2026-05-14 21:13:13 +08:00
yyc12345 6337ae432d feat: add messagebox component 2026-05-14 10:36:03 +08:00
yyc12345 826cbf18b1 feat: add login page 2026-05-13 13:20:23 +08:00
yyc12345 167c83f7d4 feat: disable navigation to some pages if there is no user logged in 2026-05-13 12:46:11 +08:00
yyc12345 078e61e993 feat: add persist feature for frontend 2026-05-13 11:02:35 +08:00
yyc12345 bdee3b3efa feat: update database fields 2026-05-12 19:25:31 +08:00
yyc12345 37b08927a7 update frontend with bulma css and home&404 pages 2026-05-12 15:32:30 +08:00
yyc12345 46f2d69800 feat: modify vite config for backend frontend decouple dev 2026-05-11 22:34:16 +08:00
yyc12345 24790d8e69 feat: add nginx router config file for backend and frontend 2026-05-11 22:28:08 +08:00
yyc12345 07205396c8 refactor: change backend as the pure API backend 2026-05-11 22:20:06 +08:00
yyc12345 433c6cf2f8 doc: add roadmap for refactor 2026-05-06 12:19:56 +08:00
yyc12345 37435eeb66 refactor: introduce modern frontend 2026-04-28 15:47:32 +08:00
110 changed files with 9089 additions and 605 deletions
+10
View File
@@ -0,0 +1,10 @@
# Roadmap
1. 前后端分离,将前端静态文件和后端Python分装到两个文件夹中。同时辅助类文件夹改换位置。
1. 后端使用Astral UV重构,使得项目可以跑起来。
1. 定为1.1版本。1.0版本也要打tag然后提交。然后分叉v1-maintain分支。后续在master上开发v2。
1. 后端数据库字段重命名。
1. 前后端通信API命名格式修改。
1. 使用Vue重写前端
1. 使用Tailwind重写前端CSS
1. 使用Go重写后端。
+61
View File
@@ -0,0 +1,61 @@
# ============================
# 路由 1: /web -> 静态文件
# ============================
location /web {
# 使用 alias 精确映射
# 请求 /web/index.html -> /var/www/static/index.html
alias /var/www/static;
# 静态文件优化
expires 7d;
add_header Cache-Control "public, max-age=604800";
# 尝试返回文件,不存在则返回404(避免落入其他location
try_files $uri $uri/ =404;
# 可选:启用 gzip 压缩
gzip_static on;
}
# ============================
# 路由 2: /api -> Go 程序 (8848端口)
# ============================
location /api {
# 反向代理到本地 Go 服务
proxy_pass http://127.0.0.1:8848;
# 重要:保留原始请求头
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket 支持(如果 Go 程序需要)
# proxy_http_version 1.1;
# proxy_set_header Upgrade $http_upgrade;
# proxy_set_header Connection "upgrade";
# 超时设置(根据业务调整)
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# 缓冲设置(可选,大文件上传时注意调整)
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
}
# ============================
# 可选:根路径处理
# ============================
location = / {
# 重定向到 /web
return 302 /web/;
}
# 禁止访问隐藏文件
location ~ /\. {
deny all;
return 404;
}
+101
View File
@@ -0,0 +1,101 @@
import icalendar
def DumpComponentHeader(file, component):
fields = []
inclusiveUnitCounter = 0
fields += map(lambda x: x + ' - required', component.required)
fields += map(lambda x: x + ' - singletons', component.singletons)
for units in component.inclusive:
fields += map(lambda x: x + ' - inclusive{}'.format(inclusiveUnitCounter), units)
inclusiveUnitCounter += 1
fields += ('exclusive - name', 'exclusive - data')
fields += ('multiple', )
file.write(','.join(fields))
file.write('\n')
def DumpComponentData(file, component):
data = []
gotten_instance = None
for item in component.required:
gotten_instance = component.get(item)
if gotten_instance is not None:
data.append(AdvancedFormater(gotten_instance))
else:
data.append('')
for item in component.singletons:
gotten_instance = component.get(item)
if gotten_instance is not None:
data.append(AdvancedFormater(gotten_instance))
else:
data.append('')
for units in component.inclusive:
for item in units:
gotten_instance = component.get(item)
if gotten_instance is not None:
data.append(AdvancedFormater(gotten_instance))
else:
data.append('')
gotten_name = ""
gotten_data = ""
for item in component.exclusive:
gotten_instance = component.get(item)
if gotten_instance is not None:
gotten_name = item
gotten_data = AdvancedFormater(gotten_instance)
break
data.append(gotten_name)
data.append(gotten_data)
for item in component.multiple:
gotten_instance = component.get(item)
if gotten_instance is not None:
data.append('- {} -'.format(item))
data.append(AdvancedFormater(gotten_instance))
else:
data.append('')
file.write(','.join(data))
file.write('\n')
def AdvancedFormater(data):
if isinstance(data, icalendar.prop.vDDDTypes):
return str(data.dt)
else:
return str(data)
# read file
icsFile = open('test.ics', 'rb')
cal = icalendar.Calendar.from_ical(icsFile.read())
icsFile.close()
# analyse file
csvEvent = open('event.csv', 'w')
csvEventHeader = False
csvAlarm = open('alarm.csv', 'w')
csvAlarmHeader = False
eventCount = 0
alarmCount = 0
miscCount = 0
for component in cal.walk():
if component.name == 'VEVENT':
eventCount += 1
if not csvEventHeader:
DumpComponentHeader(csvEvent, component)
csvEventHeader = True
DumpComponentData(csvEvent, component)
elif component.name == 'VALARM':
alarmCount += 1
if not csvAlarmHeader:
DumpComponentHeader(csvAlarm, component)
csvAlarmHeader = True
DumpComponentData(csvAlarm, component)
else:
miscCount += 1
csvEvent.close()
csvAlarm.close()
print('Event count: {}\nAlarm count: {}\nMisc count: {}'.format(eventCount, alarmCount, miscCount))
+156
View File
@@ -0,0 +1,156 @@
import icalendar
import sys
import os
import database
import json
import datetime
import dt as localdt
def AdvancedDatetTimeGet(dt, isStartDateTime):
if isinstance(dt, datetime.datetime):
gottenDatetime = int(dt.timestamp() / 60)
elif isinstance(dt, datetime.date):
gottenDatetime = int(datetime.datetime(
dt.year,
dt.month,
dt.day,
0 if isStartDateTime else 23,
0 if isStartDateTime else 59,
0 if isStartDateTime else 59,
0, tzinfo=LOCAL_TZ
).timestamp() / 60)
else:
raise Exception('Unexpected data')
timezoneOffset = LOCAL_UTC_OFFSET
return (gottenDatetime, timezoneOffset)
def AdvancedDateTimeAnalyser(component):
startDatetimeRef = component.get('DTSTART').dt
(startDatetime, timezoneOffset) = AdvancedDatetTimeGet(startDatetimeRef, True)
if component.get('DTEND') is not None:
(endDatetime, _) = AdvancedDatetTimeGet(startDatetimeRef, False)
elif component.get('DURATION') is not None:
endDurationRef = component.get('DURATION').dt
if isinstance(endDurationRef, datetime.timedelta):
endDatetime = startDatetime + int(endDurationRef.total_seconds() / 60)
else:
raise Exception('Unexpected data')
else:
raise Exception('Unexpected data')
return (startDatetime, endDatetime, timezoneOffset)
def LoopRulesConverter(component):
jsonData = component.get('RRULE')
if jsonData is None:
return ""
loopRules = ""
loopStopRules = ""
freq = jsonData.get('FREQ')[0]
if freq == 'MONTHLY':
loopRules = 'MSA{}'.format(str(jsonData.get('INTERVAL')[0]))
elif freq == 'WEEKLY':
occupiedWeek = [False, ] * 7
for item in jsonData.get('BYDAY'):
occupiedWeek[WEEK_DICT[item]] = True
loopRules = 'W{}{}'.format(
''.join(map(lambda x: 'T' if x else 'F', occupiedWeek)),
str(jsonData.get('INTERVAL')[0])
)
elif freq == 'YEARLY':
loopRules = 'YS{}'.format(str(jsonData.get('INTERVAL')[0]))
else:
raise Exception('Unexpected data')
if jsonData.get('COUNT') is not None:
loopStopRules = 'T{}'.format(str(jsonData.get('COUNT')[0]))
else:
loopStopRules = 'F'
return loopRules + '-' + loopStopRules
# ============================ read args
icsFilePath = sys.argv[1]
if not os.path.isfile(icsFilePath):
print('Fail to load ics file')
sys.exit(1)
# read file
icsFile = open(icsFilePath, 'rb')
cal = icalendar.Calendar.from_ical(icsFile.read())
icsFile.close()
# ============================ init const
utfOffset = float(input('Input this ics file\'s utc offset (time unit: hour)>'))
LOCAL_UTC_OFFSET = int(utfOffset * 60)
LOCAL_TZ = localdt.UTCTimezone(LOCAL_UTC_OFFSET)
WEEK_DICT = {
"SU": 6, "MO": 0, "TU": 1, "WE": 2, "TH": 3, "FR": 4, "SA": 5,
}
# ============================ pick database
db = database.CalendarDatabase()
db.open()
username = input('Input username >')
password = input('Input password >')
(status, error, token) = db.common_webLogin(username, password, 'Python backend', '127.0.0.1')
if not status:
print('Fail to login.')
sys.exit(1)
(status, error, collectionList) = db.collection_getFullOwn(token)
if not status:
print('Database return an error')
sys.exit(1)
print('Pick a collection to insert imported events')
counter = 0
for i in collectionList:
print('{}\t{}'.format(counter, i[1]))
counter += 1
pickedIndex = int(input())
collectionUuid = collectionList[pickedIndex][0]
# ============================ analyse file
eventCount = 0
allCount = 0
for component in cal.walk():
allCount += 1
# only import event chunk
if component.name == 'VEVENT':
eventCount += 1
title = str(component.get('SUMMARY'))
descriptionPrototype = {
'color': '#1e90ff',
'description': None
}
descriptionList = []
if component.get('DESCRIPTION') is not None and str(component.get('DESCRIPTION')) != '':
descriptionList.append(component.get('DESCRIPTION'))
if component.get('LOCATION') is not None and str(component.get('LOCATION')) != '':
descriptionList.append(component.get('LOCATION'))
descriptionPrototype['description'] = '\n'.join(descriptionList)
description = json.dumps(descriptionPrototype)
(eventDateTimeStart, eventDateTimeEnd, timezoneOffset) = AdvancedDateTimeAnalyser(component)
loopRules = LoopRulesConverter(component)
(status, _, _) = db.calendar_add(
token,
collectionUuid,
title,
description,
eventDateTimeStart,
eventDateTimeEnd,
loopRules,
timezoneOffset
)
if not status:
print('Database return an error')
sys.exit(1)
db.common_logout(token)
db.close()
print('All chunk: {}\nEvent count: {}'.format(allCount, eventCount))
+3
View File
@@ -0,0 +1,3 @@
# Migration
This directory contains the migration scripts for the database.
+49
View File
@@ -0,0 +1,49 @@
-- Migration script for coconut-leaf database v1 to v2
-- This script updates field names by:
-- 1. Removing 'ccn_' prefix from all fields
-- 2. Converting camelCase to snake_case
-- Step 1: Rename user table columns
ALTER TABLE user RENAME COLUMN ccn_name TO name;
ALTER TABLE user RENAME COLUMN ccn_password TO password;
ALTER TABLE user RENAME COLUMN ccn_isAdmin TO is_admin;
ALTER TABLE user RENAME COLUMN ccn_salt TO salt;
-- Step 2: Rename token table columns
ALTER TABLE token RENAME COLUMN ccn_user TO user;
ALTER TABLE token RENAME COLUMN ccn_token TO token;
ALTER TABLE token RENAME COLUMN ccn_tokenExpireOn TO token_expire_on;
ALTER TABLE token RENAME COLUMN ccn_ua TO ua;
ALTER TABLE token RENAME COLUMN ccn_ip TO ip;
-- Step 3: Rename collection table columns
ALTER TABLE collection RENAME COLUMN ccn_uuid TO uuid;
ALTER TABLE collection RENAME COLUMN ccn_name TO name;
ALTER TABLE collection RENAME COLUMN ccn_user TO user;
ALTER TABLE collection RENAME COLUMN ccn_lastChange TO last_change;
-- Step 4: Rename share table columns
ALTER TABLE share RENAME COLUMN ccn_uuid TO uuid;
ALTER TABLE share RENAME COLUMN ccn_target TO target;
-- Step 5: Rename calendar table columns
ALTER TABLE calendar RENAME COLUMN ccn_uuid TO uuid;
ALTER TABLE calendar RENAME COLUMN ccn_belongTo TO belong_to;
ALTER TABLE calendar RENAME COLUMN ccn_title TO title;
ALTER TABLE calendar RENAME COLUMN ccn_description TO description;
ALTER TABLE calendar RENAME COLUMN ccn_lastChange TO last_change;
ALTER TABLE calendar RENAME COLUMN ccn_eventDateTimeStart TO event_date_time_start;
ALTER TABLE calendar RENAME COLUMN ccn_eventDateTimeEnd TO event_date_time_end;
ALTER TABLE calendar RENAME COLUMN ccn_timezoneOffset TO timezone_offset;
ALTER TABLE calendar RENAME COLUMN ccn_loopRules TO loop_rules;
ALTER TABLE calendar RENAME COLUMN ccn_loopDateTimeStart TO loop_date_time_start;
ALTER TABLE calendar RENAME COLUMN ccn_loopDateTimeEnd TO loop_date_time_end;
-- Step 6: Rename todo table columns
ALTER TABLE todo RENAME COLUMN ccn_uuid TO uuid;
ALTER TABLE todo RENAME COLUMN ccn_belongTo TO belong_to;
ALTER TABLE todo RENAME COLUMN ccn_data TO data;
ALTER TABLE todo RENAME COLUMN ccn_lastChange TO last_change;
-- Note: Foreign key constraints will be automatically updated by SQLite when renaming columns
-- No additional steps needed for foreign keys
+21 -13
View File
@@ -1,15 +1,17 @@
import sys
import logging
from argparse import ArgumentParser
from typing import cast
from pathlib import Path
import server
import config
import utils
import database
import logger
from logger import LOGGER, LoggerLevel
def GetUsernamePassword():
def GetUsernamePassword() -> tuple[str, str]:
print("What is the first username of this calendar system?")
cache = input()
while not utils.IsValidUsername(cache):
@@ -26,15 +28,15 @@ def GetUsernamePassword():
return (username, password)
if __name__ == "__main__":
print("Coconut-leaf")
print("A self-host, multi-account calendar system.")
print("Project: https://github.com/yyc12345/coconut-leaf")
print("===================")
# Set as INFO level in default first,
# and we will change it once we load the configuration file.
logger.set_level(LoggerLevel.INFO)
# Receive arguments
parser = ArgumentParser(description="Coconut-leaf")
parser = ArgumentParser(
description="The server of light, self-host and multi-account calendar system."
)
parser.add_argument(
"-c",
"--config",
@@ -54,16 +56,22 @@ if __name__ == "__main__":
)
args = parser.parse_args()
# Show splash
LOGGER.info("Coconut-leaf")
LOGGER.info("A light, self-host and multi-account calendar system")
LOGGER.info("Project: https://github.com/yyc12345/coconut-leaf")
LOGGER.info("===================")
# Load config file
try:
config.setup_config(cast(Path, args.config))
except Exception as e:
print(f"Error loading config file: {e}")
LOGGER.critical(f"Error loading config file: {e}")
sys.exit(1)
# Setup logging level
logging_level = logging.DEBUG if config.get_config().others.debug else logging.INFO
logging.basicConfig(format='[%(levelname)s] %(message)s', level=logging_level)
# Change logging level again according to whether enable debug mode
logging_level = LoggerLevel.DEBUG if config.get_config().others.debug else LoggerLevel.INFO
logger.set_level(logging_level)
# Initialize the calendar system if needed
if cast(bool, args.init):
@@ -72,5 +80,5 @@ if __name__ == "__main__":
calendar.init(*gotten_data)
calendar.close()
logging.info("Staring server...")
LOGGER.info("Staring server...")
server.run()
+1 -1
View File
@@ -15,7 +15,7 @@ path = "coconut-leaf.db"
# database = "coconut_leaf"
[web]
port = 8888
port = 8848
[others]
auto-token-clean-duration = 86400
-14
View File
@@ -1,14 +0,0 @@
{
"database-type": "sqlite",
"database-config": {
"user": "",
"password": "",
"db": "",
"url": "",
"port": 3306
},
"web": {
"port": 8888
},
"debug": true
}
+256 -158
View File
@@ -1,54 +1,81 @@
import config
import sqlite3
import utils
import threading
import logging
import dt
from typing import cast
from pathlib import Path
from dataclasses import dataclass
from typing import Callable, ParamSpec, TypeVar, Generic
def SafeDatabaseOperation(func):
def wrapper(self: 'CalendarDatabase', *args, **kwargs):
import dt
import utils
import config
from logger import LOGGER
T = TypeVar('T')
P = ParamSpec('P')
R = TypeVar('R')
@dataclass(frozen=True)
class ResponseBody(Generic[T]):
"""The generic response body for API return."""
success: bool
"""True if this operation is successful, otherwise false."""
error: str
"""The error message provided when operation failed."""
data: T | None
"""The payload provided when operation successed."""
class DbException(Exception):
"""Error occurs when manipulating with database."""
pass
def SafeDatabaseOperation(inner: Callable[P, R]) -> Callable[P, ResponseBody[R]]:
def wrapper(*args, **kwargs) -> ResponseBody[R]:
# extract self from args
self: 'CalendarDatabase' = args[0]
# get config
cfg = config.get_config()
with self.mutex:
# check database and acquire cursor
# try to fetching database and allocate database cursor
try:
self.check_database()
self.cursor = self.db.cursor()
db = self._get_db()
self._allocate_cursor()
except Exception as e:
self.cursor = None
self._free_cursor()
if cfg.others.debug:
logging.exception(e)
return (False, str(e), None)
LOGGER.exception(e)
return ResponseBody(False, str(e), None)
# do real data work
try:
currentTime = utils.GetCurrentTimestamp()
if currentTime - self.latestClean > cfg.others.auto_token_clean_duration:
self.latestClean = currentTime
logging.info('Cleaning outdated token...')
LOGGER.info('Cleaning outdated token...')
self.tokenOper_clean()
result = (True, '', func(self, *args, **kwargs))
self.cursor.close()
self.cursor = None
self.db.commit()
result = ResponseBody(True, '', inner(*args, **kwargs))
self._free_cursor()
db.commit()
return result
except Exception as e:
self.cursor.close()
self.cursor = None
self.db.rollback()
self._free_cursor()
db.rollback()
if cfg.others.debug:
logging.exception(e)
return (False, str(e), None)
LOGGER.exception(e)
return ResponseBody(False, str(e), None)
return wrapper
class CalendarDatabase:
db: sqlite3.Connection
cursor: sqlite3.Cursor
db: sqlite3.Connection | None
cursor: sqlite3.Cursor | None
mutex: threading.Lock
latestClean: int
@@ -59,8 +86,8 @@ class CalendarDatabase:
self.latestClean = 0
def open(self):
if (self.is_database_valid()):
raise Exception('Databade is opened')
if (self.db is not None):
raise DbException('Database is already opened')
cfg = config.get_config()
match cfg.database.driver:
@@ -69,13 +96,13 @@ class CalendarDatabase:
self.db.execute('PRAGMA encoding = "UTF-8";')
self.db.execute('PRAGMA foreign_keys = ON;')
case config.DatabaseDriver.MYSQL:
raise Exception('Not implemented database')
raise DbException('Not implemented database')
case _:
raise Exception('Unknow database type')
raise DbException('Unknow database type')
def init(self, username, password):
if (self.is_database_valid()):
raise Exception('Database is opened')
def init(self, username: str, password: str):
if (self.db is not None):
raise DbException('Database is already opened')
# establish tables
cfg = config.get_config()
@@ -85,44 +112,74 @@ class CalendarDatabase:
case config.DatabaseDriver.SQLITE:
sql_file = backend_sql_path / 'sqlite.sql'
case config.DatabaseDriver.MYSQL:
raise Exception('Not implemented database')
raise DbException('Not implemented database')
case _:
raise Exception('Unknow database type')
raise DbException('Unknow database type')
self.open()
cursor = self.db.cursor()
db = self._get_db()
self._allocate_cursor()
cursor = self._get_cursor()
# execute script for creating tables
with open(sql_file, 'r', encoding='utf-8') as fsql:
cursor.executescript(fsql.read())
# finish init
# add default user in user table
cursor.execute('INSERT INTO user VALUES (?, ?, ?, ?);', (
username,
utils.ComputePasswordHash(password),
1,
utils.GenerateSalt()
))
cursor.close()
self.db.commit()
self._free_cursor()
# commit to database
db.commit()
def close(self):
self.check_database()
if (self.db is None):
LOGGER.warning('Try to close null database.')
else:
self._free_cursor()
self.db.close()
self.db = None
def check_database(self):
if (not self.is_database_valid()):
raise Exception('Databade is None')
def _get_db(self) -> sqlite3.Connection:
if (self.db is None):
raise DbException('There is no opened database')
else:
return self.db
def is_database_valid(self):
return not (self.db == None)
def _allocate_cursor(self) -> None:
if (self.cursor is not None):
raise DbException('There is already opened database cursor')
else:
self.cursor = self._get_db().cursor()
def _get_cursor(self) -> sqlite3.Cursor:
if (self.cursor is None):
raise DbException('There is no opened database cursor')
else:
return self.cursor
def _free_cursor(self) -> None:
if (self.cursor is None):
LOGGER.warning('Try to free null databse cursor.')
else:
self.cursor.close()
self.cursor = None
# ======================= token related internal operation
def tokenOper_clean(self):
# remove outdated token
self.cursor.execute('DELETE FROM token WHERE [ccn_tokenExpireOn] <= ?',(utils.GetCurrentTimestamp(), ))
cursor = self._get_cursor()
cursor.execute('DELETE FROM token WHERE [token_expire_on] <= ?',(utils.GetCurrentTimestamp(), ))
def tokenOper_postpone_expireOn(self, token):
self.cursor.execute('UPDATE token SET [ccn_tokenExpireOn] = ? WHERE [ccn_token] = ?;', (
cursor = self._get_cursor()
cursor.execute('UPDATE token SET [token_expire_on] = ? WHERE [token] = ?;', (
utils.GetTokenExpireOn(),
token
))
@@ -131,16 +188,18 @@ class CalendarDatabase:
self.tokenOper_get_username(token)
def tokenOper_is_admin(self, username):
self.cursor.execute('SELECT [ccn_isAdmin] FROM user WHERE [ccn_name] = ?;',(username, ))
cache = self.cursor.fetchone()[0]
cursor = self._get_cursor()
cursor.execute('SELECT [is_admin] FROM user WHERE [name] = ?;',(username, ))
cache = cursor.fetchone()[0]
return cache == 1
def tokenOper_get_username(self, token):
self.cursor.execute('SELECT [ccn_user] FROM token WHERE [ccn_token] = ? AND [ccn_tokenExpireOn] > ?;',(
cursor = self._get_cursor()
cursor.execute('SELECT [user] FROM token WHERE [token] = ? AND [token_expire_on] > ?;',(
token,
utils.GetCurrentTimestamp()
))
result = self.cursor.fetchone()[0]
result = cursor.fetchone()[0]
# need postpone expire on time
self.tokenOper_postpone_expireOn(token)
return result
@@ -150,8 +209,9 @@ class CalendarDatabase:
@SafeDatabaseOperation
def common_salt(self, username):
cursor = self._get_cursor()
salt = utils.GenerateSalt()
self.cursor.execute('UPDATE user SET [ccn_salt] = ? WHERE [ccn_name] = ?;', (
cursor.execute('UPDATE user SET [salt] = ? WHERE [name] = ?;', (
salt,
username
))
@@ -159,16 +219,17 @@ class CalendarDatabase:
@SafeDatabaseOperation
def common_login(self, username, password, clientUa, clientIp):
self.cursor.execute('SELECT [ccn_password], [ccn_salt] FROM user WHERE [ccn_name] = ?;', (username, ))
(gotten_salt, gotten_password) = self.cursor.fetchone()
cursor = self._get_cursor()
cursor.execute('SELECT [password], [salt] FROM user WHERE [name] = ?;', (username, ))
(gotten_salt, gotten_password) = cursor.fetchone()
if password == utils.ComputePasswordHashWithSalt(gotten_password, gotten_salt):
token = utils.GenerateToken(username)
self.cursor.execute('UPDATE user SET [ccn_salt] = ? WHERE [ccn_name] = ?;', (
cursor.execute('UPDATE user SET [salt] = ? WHERE [name] = ?;', (
utils.GenerateSalt(), # regenerate a new slat to prevent re-login try
username
))
self.cursor.execute('INSERT INTO token VALUES (?, ?, ?, ?, ?);', (
cursor.execute('INSERT INTO token VALUES (?, ?, ?, ?, ?);', (
username,
token,
utils.GetTokenExpireOn(), # add 2 day from now
@@ -178,15 +239,21 @@ class CalendarDatabase:
return token
else:
# throw a exception to indicate fail to login
raise Exception('Login authentication failed')
raise DbException('Login authentication failed')
@SafeDatabaseOperation
def common_webLogin(self, username, password, clientUa, clientIp):
self.cursor.execute('SELECT [ccn_name] FROM user WHERE [ccn_name] = ? AND [ccn_password] = ?;', (username, utils.ComputePasswordHash(password)))
cursor = self._get_cursor()
LOGGER.debug(f'WebLogin Username: {username}')
LOGGER.debug(f'WebLogin Password: {password}')
passwordHash = utils.ComputePasswordHash(password)
LOGGER.debug(f'WebLogin Password Hash: {passwordHash}')
if len(self.cursor.fetchall()) != 0:
cursor.execute('SELECT [name] FROM user WHERE [name] = ? AND [password] = ?;', (username, passwordHash))
if len(cursor.fetchall()) != 0:
token = utils.GenerateToken(username)
self.cursor.execute('INSERT INTO token VALUES (?, ?, ?, ?, ?);', (
cursor.execute('INSERT INTO token VALUES (?, ?, ?, ?, ?);', (
username,
token,
utils.GetTokenExpireOn(), # add 2 day from now
@@ -196,12 +263,13 @@ class CalendarDatabase:
return token
else:
# throw a exception to indicate fail to login
raise Exception('Login authentication failed')
raise DbException('Login authentication failed')
@SafeDatabaseOperation
def common_logout(self, token):
cursor = self._get_cursor()
self.tokenOper_check_valid(token)
self.cursor.execute('DELETE FROM token WHERE [ccn_token] = ?;', (token, ))
cursor.execute('DELETE FROM token WHERE [token] = ?;', (token, ))
return True
@SafeDatabaseOperation
@@ -212,40 +280,44 @@ class CalendarDatabase:
# =============================== calendar
@SafeDatabaseOperation
def calendar_getFull(self, token, startDateTime, endDateTime):
cursor = self._get_cursor()
username = self.tokenOper_get_username(token)
self.cursor.execute('SELECT calendar.* FROM calendar INNER JOIN collection \
ON collection.ccn_uuid = calendar.ccn_belongTo \
WHERE (collection.ccn_user = ? AND calendar.ccn_loopDateTimeEnd >= ? AND calendar.ccn_loopDateTimeStart - (calendar.ccn_eventDateTimeEnd - calendar.ccn_eventDateTimeStart) <= ?);',
cursor.execute('SELECT calendar.* FROM calendar INNER JOIN collection \
ON collection.uuid = calendar.belong_to \
WHERE (collection.user = ? AND calendar.loop_date_time_end >= ? AND calendar.loop_date_time_start - (calendar.event_date_time_end - calendar.event_date_time_start) <= ?);',
(username, startDateTime, endDateTime))
return self.cursor.fetchall()
return cursor.fetchall()
@SafeDatabaseOperation
def calendar_getList(self, token, startDateTime, endDateTime):
cursor = self._get_cursor()
username = self.tokenOper_get_username(token)
self.cursor.execute('SELECT calendar.ccn_uuid FROM calendar INNER JOIN collection \
ON collection.ccn_uuid = calendar.ccn_belongTo \
WHERE (collection.ccn_user = ? AND calendar.ccn_loopDateTimeEnd >= ? AND calendar.ccn_loopDateTimeStart - (calendar.ccn_eventDateTimeEnd - calendar.ccn_eventDateTimeStart) <= ?);',
cursor.execute('SELECT calendar.uuid FROM calendar INNER JOIN collection \
ON collection.uuid = calendar.belong_to \
WHERE (collection.user = ? AND calendar.loop_date_time_end >= ? AND calendar.loop_date_time_start - (calendar.event_date_time_end - calendar.event_date_time_start) <= ?);',
(username, startDateTime, endDateTime))
return tuple(map(lambda x: x[0], self.cursor.fetchall()))
return tuple(map(lambda x: x[0], cursor.fetchall()))
@SafeDatabaseOperation
def calendar_getDetail(self, token, uuid):
cursor = self._get_cursor()
self.tokenOper_check_valid(token)
self.cursor.execute('SELECT * FROM calendar WHERE [ccn_uuid] = ?;', (uuid, ))
return self.cursor.fetchone()
cursor.execute('SELECT * FROM calendar WHERE [uuid] = ?;', (uuid, ))
return cursor.fetchone()
@SafeDatabaseOperation
def calendar_update(self, token, uuid, lastChange, **optArgs):
cursor = self._get_cursor()
self.tokenOper_check_valid(token)
# get prev data
self.cursor.execute('SELECT * FROM calendar WHERE [ccn_uuid] = ? AND [ccn_lastChange] = ?;', (uuid, lastChange))
analyseData = list(self.cursor.fetchone())
cursor.execute('SELECT * FROM calendar WHERE [uuid] = ? AND [last_change] = ?;', (uuid, lastChange))
analyseData = list(cursor.fetchone())
# construct update data
lastupdate = utils.GenerateUUID()
sqlList = [
'[ccn_lastChange] = ?',
'[last_change] = ?',
]
argumentsList = [
lastupdate,
@@ -256,44 +328,44 @@ class CalendarDatabase:
cache = optArgs.get('belongTo', None)
if cache is not None:
sqlList.append('[ccn_belongTo] = ?')
sqlList.append('[belong_to] = ?')
argumentsList.append(cache)
cache = optArgs.get('title', None)
if cache is not None:
sqlList.append('[ccn_title] = ?')
sqlList.append('[title] = ?')
argumentsList.append(cache)
cache = optArgs.get('description', None)
if cache is not None:
sqlList.append('[ccn_description] = ?')
sqlList.append('[description] = ?')
argumentsList.append(cache)
cache = optArgs.get('eventDateTimeStart', None)
if cache is not None:
sqlList.append('[ccn_eventDateTimeStart] = ?')
sqlList.append('[event_date_time_start] = ?')
argumentsList.append(cache)
reAnalyseLoop = True
analyseData[5] = cache
cache = optArgs.get('eventDateTimeEnd', None)
if cache is not None:
sqlList.append('[ccn_eventDateTimeEnd] = ?')
sqlList.append('[event_date_time_end] = ?')
argumentsList.append(cache)
cache = optArgs.get('loopRules', None)
if cache is not None:
sqlList.append('[ccn_loopRules] = ?')
sqlList.append('[loop_rules] = ?')
argumentsList.append(cache)
reAnalyseLoop = True
analyseData[8] = cache
cache = optArgs.get('timezoneOffset', None)
if cache is not None:
sqlList.append('[ccn_timezoneOffset] = ?')
sqlList.append('[timezone_offset] = ?')
argumentsList.append(cache)
reAnalyseLoop = True
analyseData[7] = cache
if reAnalyseLoop:
# re-compute loop data and upload it into list
sqlList.append('[ccn_loopDateTimeStart] = ?')
sqlList.append('[loop_date_time_start] = ?')
argumentsList.append(analyseData[5])
sqlList.append('[ccn_loopDateTimeEnd] = ?')
sqlList.append('[loop_date_time_end] = ?')
argumentsList.append(str(dt.ResolveLoopStr(
analyseData[8],
analyseData[5],
@@ -302,14 +374,15 @@ class CalendarDatabase:
# execute
argumentsList.append(uuid)
self.cursor.execute('UPDATE calendar SET {} WHERE [ccn_uuid] = ?;'.format(', '.join(sqlList)),
cursor.execute('UPDATE calendar SET {} WHERE [uuid] = ?;'.format(', '.join(sqlList)),
tuple(argumentsList))
if self.cursor.rowcount != 1:
raise Exception('Fail to update due to no matched rows or too much rows.')
if cursor.rowcount != 1:
raise DbException('Fail to update due to no matched rows or too much rows.')
return lastupdate
@SafeDatabaseOperation
def calendar_add(self, token, belongTo, title, description, eventDateTimeStart, eventDateTimeEnd, loopRules, timezoneOffset):
cursor = self._get_cursor()
self.tokenOper_check_valid(token)
newuuid = utils.GenerateUUID()
@@ -319,7 +392,7 @@ class CalendarDatabase:
loopDateTimeStart = eventDateTimeStart
loopDateTimeEnd = dt.ResolveLoopStr(loopRules, eventDateTimeStart, timezoneOffset)
self.cursor.execute('INSERT INTO calendar VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);',
cursor.execute('INSERT INTO calendar VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);',
(newuuid,
belongTo,
title,
@@ -335,134 +408,149 @@ class CalendarDatabase:
@SafeDatabaseOperation
def calendar_delete(self, token, uuid, lastChange):
cursor = self._get_cursor()
self.tokenOper_check_valid(token)
self.cursor.execute('DELETE FROM calendar WHERE [ccn_uuid] = ? AND [ccn_lastChange] = ?;', (uuid, lastChange))
if self.cursor.rowcount != 1:
raise Exception('Fail to delete due to no matched rows or too much rows.')
cursor.execute('DELETE FROM calendar WHERE [uuid] = ? AND [last_change] = ?;', (uuid, lastChange))
if cursor.rowcount != 1:
raise DbException('Fail to delete due to no matched rows or too much rows.')
return True
# =============================== collection
@SafeDatabaseOperation
def collection_getFullOwn(self, token):
cursor = self._get_cursor()
username = self.tokenOper_get_username(token)
self.cursor.execute('SELECT [ccn_uuid], [ccn_name], [ccn_lastChange] FROM collection WHERE [ccn_user] = ?;', (username, ))
return self.cursor.fetchall()
cursor.execute('SELECT [uuid], [name], [last_change] FROM collection WHERE [user] = ?;', (username, ))
return cursor.fetchall()
@SafeDatabaseOperation
def collection_getListOwn(self, token):
cursor = self._get_cursor()
username = self.tokenOper_get_username(token)
self.cursor.execute('SELECT [ccn_uuid] FROM collection WHERE [ccn_user] = ?;', (username, ))
return tuple(map(lambda x: x[0], self.cursor.fetchall()))
cursor.execute('SELECT [uuid] FROM collection WHERE [user] = ?;', (username, ))
return tuple(map(lambda x: x[0], cursor.fetchall()))
@SafeDatabaseOperation
def collection_getDetailOwn(self, token, uuid):
cursor = self._get_cursor()
username = self.tokenOper_get_username(token)
self.cursor.execute('SELECT [ccn_uuid], [ccn_name], [ccn_lastChange] FROM collection WHERE [ccn_user] = ? AND [ccn_uuid] = ?;', (username, uuid))
return self.cursor.fetchone()
cursor.execute('SELECT [uuid], [name], [last_change] FROM collection WHERE [user] = ? AND [uuid] = ?;', (username, uuid))
return cursor.fetchone()
@SafeDatabaseOperation
def collection_addOwn(self, token, newname):
cursor = self._get_cursor()
username = self.tokenOper_get_username(token)
newuuid = utils.GenerateUUID()
lastupdate = utils.GenerateUUID()
self.cursor.execute('INSERT INTO collection VALUES (?, ?, ?, ?);',
cursor.execute('INSERT INTO collection VALUES (?, ?, ?, ?);',
(newuuid, newname, username, lastupdate))
return newuuid
@SafeDatabaseOperation
def collection_updateOwn(self, token, uuid, newname, lastChange):
cursor = self._get_cursor()
self.tokenOper_check_valid(token)
lastupdate = utils.GenerateUUID()
self.cursor.execute('UPDATE collection SET [ccn_name] = ?, [ccn_lastChange] = ? WHERE [ccn_uuid] = ? AND [ccn_lastChange] = ?;', (
cursor.execute('UPDATE collection SET [name] = ?, [last_change] = ? WHERE [uuid] = ? AND [last_change] = ?;', (
newname,
lastupdate,
uuid,
lastChange
))
if self.cursor.rowcount != 1:
raise Exception('Fail to update due to no matched rows or too much rows.')
if cursor.rowcount != 1:
raise DbException('Fail to update due to no matched rows or too much rows.')
return lastupdate
@SafeDatabaseOperation
def collection_deleteOwn(self, token, uuid, lastChange):
cursor = self._get_cursor()
self.tokenOper_check_valid(token)
self.cursor.execute('DELETE FROM collection WHERE [ccn_uuid] = ? AND [ccn_lastChange] = ?;', (
cursor.execute('DELETE FROM collection WHERE [uuid] = ? AND [last_change] = ?;', (
uuid,
lastChange
))
if self.cursor.rowcount != 1:
raise Exception('Fail to delete due to no matched rows or too much rows.')
if cursor.rowcount != 1:
raise DbException('Fail to delete due to no matched rows or too much rows.')
return True
@SafeDatabaseOperation
def collection_getSharing(self, token, uuid):
cursor = self._get_cursor()
self.tokenOper_check_valid(token)
self.cursor.execute('SELECT [ccn_target] FROM share WHERE [ccn_uuid] = ?;', (uuid, ))
return tuple(map(lambda x: x[0], self.cursor.fetchall()))
cursor.execute('SELECT [target] FROM share WHERE [uuid] = ?;', (uuid, ))
return tuple(map(lambda x: x[0], cursor.fetchall()))
@SafeDatabaseOperation
def collection_deleteSharing(self, token, uuid, target, lastChange):
cursor = self._get_cursor()
self.tokenOper_check_valid(token)
lastupdate = utils.GenerateUUID()
self.cursor.execute('UPDATE collection SET [ccn_lastChange] = ?, WHERE [ccn_uuid] = ? AND [ccn_lastChange] = ?;', (lastupdate, uuid, lastChange))
if self.cursor.rowcount != 1:
raise Exception('Fail to delete due to no matched rows or too much rows.')
cursor.execute('UPDATE collection SET [last_change] = ?, WHERE [uuid] = ? AND [last_change] = ?;', (lastupdate, uuid, lastChange))
if cursor.rowcount != 1:
raise DbException('Fail to delete due to no matched rows or too much rows.')
self.cursor.execute('DELETE FROM share WHERE [ccn_uuid] = ? AND [ccn_target] = ?;', (uuid, target))
if self.cursor.rowcount != 1:
raise Exception('Fail to delete due to no matched rows or too much rows.')
cursor.execute('DELETE FROM share WHERE [uuid] = ? AND [target] = ?;', (uuid, target))
if cursor.rowcount != 1:
raise DbException('Fail to delete due to no matched rows or too much rows.')
return lastupdate
@SafeDatabaseOperation
def collection_addSharing(self, token, uuid, target, lastChange):
cursor = self._get_cursor()
self.tokenOper_check_valid(token)
lastupdate = utils.GenerateUUID()
self.cursor.execute('UPDATE collection SET [ccn_lastChange] = ? WHERE [ccn_uuid] = ? AND [ccn_lastChange] = ?;', (lastupdate, uuid, lastChange))
if self.cursor.rowcount != 1:
raise Exception('Fail to delete due to no matched rows or too much rows.')
cursor.execute('UPDATE collection SET [last_change] = ? WHERE [uuid] = ? AND [last_change] = ?;', (lastupdate, uuid, lastChange))
if cursor.rowcount != 1:
raise DbException('Fail to delete due to no matched rows or too much rows.')
self.cursor.execute('SELECT * FROM share WHERE [ccn_uuid] = ? AND [ccn_target] = ?;', (uuid, target))
if len(self.cursor.fetchall()) != 0:
raise Exception('Fail to insert duplicated item.')
self.cursor.execute('INSERT INTO share VALUES (?, ?);', (uuid, target))
cursor.execute('SELECT * FROM share WHERE [uuid] = ? AND [target] = ?;', (uuid, target))
if len(cursor.fetchall()) != 0:
raise DbException('Fail to insert duplicated item.')
cursor.execute('INSERT INTO share VALUES (?, ?);', (uuid, target))
return lastupdate
@SafeDatabaseOperation
def collection_getShared(self, token):
cursor = self._get_cursor()
username = self.tokenOper_get_username(token)
self.cursor.execute('SELECT collection.ccn_uuid, collection.ccn_name, collection.ccn_user \
cursor.execute('SELECT collection.uuid, collection.name, collection.user \
FROM share INNER JOIN collection \
ON share.ccn_uuid = collection.ccn_uuid \
WHERE share.ccn_target = ?;', (username, ))
return self.cursor.fetchall()
ON share.uuid = collection.uuid \
WHERE share.target = ?;', (username, ))
return cursor.fetchall()
# =============================== todo
@SafeDatabaseOperation
def todo_getFull(self, token):
cursor = self._get_cursor()
username = self.tokenOper_get_username(token)
self.cursor.execute('SELECT * FROM todo WHERE [ccn_belongTo] = ?;', (username, ))
return self.cursor.fetchall()
cursor.execute('SELECT * FROM todo WHERE [belong_to] = ?;', (username, ))
return cursor.fetchall()
@SafeDatabaseOperation
def todo_getList(self, token):
cursor = self._get_cursor()
username = self.tokenOper_get_username(token)
self.cursor.execute('SELECT [ccn_uuid] FROM todo WHERE [ccn_belongTo] = ?;', (username, ))
return tuple(map(lambda x: x[0], self.cursor.fetchall()))
cursor.execute('SELECT [uuid] FROM todo WHERE [belong_to] = ?;', (username, ))
return tuple(map(lambda x: x[0], cursor.fetchall()))
@SafeDatabaseOperation
def todo_getDetail(self, token, uuid):
cursor = self._get_cursor()
username = self.tokenOper_get_username(token)
self.cursor.execute('SELECT * FROM todo WHERE [ccn_belongTo] = ? AND [ccn_uuid] = ?;', (username, uuid))
return self.cursor.fetchone()
cursor.execute('SELECT * FROM todo WHERE [belong_to] = ? AND [uuid] = ?;', (username, uuid))
return cursor.fetchone()
@SafeDatabaseOperation
def todo_add(self, token):
cursor = self._get_cursor()
username = self.tokenOper_get_username(token)
newuuid = utils.GenerateUUID()
lastupdate = utils.GenerateUUID()
@@ -472,56 +560,60 @@ class CalendarDatabase:
'',
lastupdate,
)
self.cursor.execute('INSERT INTO todo VALUES (?, ?, ?, ?);', returnedData)
cursor.execute('INSERT INTO todo VALUES (?, ?, ?, ?);', returnedData)
return returnedData
@SafeDatabaseOperation
def todo_update(self, token, uuid, data, lastChange):
cursor = self._get_cursor()
# check valid token
self.tokenOper_check_valid(token)
# update
newLastChange = utils.GenerateUUID()
self.cursor.execute('UPDATE todo SET [ccn_data] = ?, [ccn_lastChange] = ? WHERE [ccn_uuid] = ? AND [ccn_lastChange] = ?;', (
cursor.execute('UPDATE todo SET [data] = ?, [last_change] = ? WHERE [uuid] = ? AND [last_change] = ?;', (
data,
newLastChange,
uuid,
lastChange
))
if self.cursor.rowcount != 1:
raise Exception('Fail to update due to no matched rows or too much rows.')
if cursor.rowcount != 1:
raise DbException('Fail to update due to no matched rows or too much rows.')
return newLastChange
@SafeDatabaseOperation
def todo_delete(self, token, uuid, lastChange):
cursor = self._get_cursor()
# check valid token
self.tokenOper_check_valid(token)
# delete
self.cursor.execute('DELETE FROM todo WHERE [ccn_uuid] = ? AND [ccn_lastChange] = ?;', (uuid, lastChange))
if self.cursor.rowcount != 1:
raise Exception('Fail to delete due to no matched rows or too much rows.')
cursor.execute('DELETE FROM todo WHERE [uuid] = ? AND [last_change] = ?;', (uuid, lastChange))
if cursor.rowcount != 1:
raise DbException('Fail to delete due to no matched rows or too much rows.')
return True
# =============================== admin
@SafeDatabaseOperation
def admin_get(self, token):
cursor = self._get_cursor()
username = self.tokenOper_get_username(token)
if not self.tokenOper_is_admin(username):
raise Exception('Permission denied.')
raise DbException('Permission denied.')
self.cursor.execute('SELECT [ccn_name], [ccn_isAdmin] FROM user;')
return tuple(map(lambda x: (x[0], x[1] == 1), self.cursor.fetchall()))
cursor.execute('SELECT [name], [is_admin] FROM user;')
return tuple(map(lambda x: (x[0], x[1] == 1), cursor.fetchall()))
@SafeDatabaseOperation
def admin_add(self, token, newname):
cursor = self._get_cursor()
username = self.tokenOper_get_username(token)
if not self.tokenOper_is_admin(username):
raise Exception('Permission denied.')
raise DbException('Permission denied.')
newpassword = utils.ComputePasswordHash(utils.GenerateUUID())
self.cursor.execute('INSERT INTO user VALUES (?, ?, ?, ?);', (
cursor.execute('INSERT INTO user VALUES (?, ?, ?, ?);', (
newname,
newpassword,
0,
@@ -531,9 +623,10 @@ class CalendarDatabase:
@SafeDatabaseOperation
def admin_update(self, token, _username, **optArgs):
cursor = self._get_cursor()
username = self.tokenOper_get_username(token)
if not self.tokenOper_is_admin(username):
raise Exception('Permission denied.')
raise DbException('Permission denied.')
# construct data
sqlList = []
@@ -542,45 +635,48 @@ class CalendarDatabase:
# analyse opt arg
cache = optArgs.get('password', None)
if cache is not None:
sqlList.append('[ccn_password] = ?')
sqlList.append('[password] = ?')
argumentsList.append(utils.ComputePasswordHash(cache))
cache = optArgs.get('isAdmin', None)
if cache is not None:
sqlList.append('[ccn_isAdmin] = ?')
sqlList.append('[is_admin] = ?')
argumentsList.append(1 if cache else 0)
# execute
argumentsList.append(_username)
self.cursor.execute('UPDATE user SET {} WHERE [ccn_name] = ?;'.format(', '.join(sqlList)),
cursor.execute('UPDATE user SET {} WHERE [name] = ?;'.format(', '.join(sqlList)),
tuple(argumentsList))
logging.debug(cache)
logging.debug(tuple(argumentsList))
if self.cursor.rowcount != 1:
raise Exception('Fail to update due to no matched rows or too much rows.')
LOGGER.debug(cache)
LOGGER.debug(tuple(argumentsList))
if cursor.rowcount != 1:
raise DbException('Fail to update due to no matched rows or too much rows.')
return True
@SafeDatabaseOperation
def admin_delete(self, token, username):
cursor = self._get_cursor()
_username = self.tokenOper_get_username(token)
if not self.tokenOper_is_admin(_username):
raise Exception('Permission denied.')
raise DbException('Permission denied.')
# delete
self.cursor.execute('DELETE FROM user WHERE [ccn_name] = ?;', (username, ))
if self.cursor.rowcount != 1:
raise Exception('Fail to delete due to no matched rows or too much rows.')
cursor.execute('DELETE FROM user WHERE [name] = ?;', (username, ))
if cursor.rowcount != 1:
raise DbException('Fail to delete due to no matched rows or too much rows.')
return True
# =============================== profile
@SafeDatabaseOperation
def profile_isAdmin(self, token):
cursor = self._get_cursor()
username = self.tokenOper_get_username(token)
return self.tokenOper_is_admin(username)
@SafeDatabaseOperation
def profile_changePassword(self, token, newpassword):
cursor = self._get_cursor()
username = self.tokenOper_get_username(token)
self.cursor.execute('UPDATE user SET [ccn_password] = ? WHERE [ccn_name] = ?;', (
cursor.execute('UPDATE user SET [password] = ? WHERE [name] = ?;', (
utils.ComputePasswordHash(newpassword),
username
))
@@ -588,23 +684,25 @@ class CalendarDatabase:
@SafeDatabaseOperation
def profile_getToken(self, token):
cursor = self._get_cursor()
username = self.tokenOper_get_username(token)
self.cursor.execute('SELECT * FROM token WHERE [ccn_user] = ?;', (
cursor.execute('SELECT * FROM token WHERE [user] = ?;', (
username,
))
return self.cursor.fetchall()
return cursor.fetchall()
@SafeDatabaseOperation
def profile_deleteToken(self, token, deleteToken):
cursor = self._get_cursor()
_username = self.tokenOper_get_username(token)
# delete
self.cursor.execute('DELETE FROM token WHERE [ccn_user] = ? AND [ccn_token] = ?;', (
cursor.execute('DELETE FROM token WHERE [user] = ? AND [token] = ?;', (
_username,
deleteToken
))
if self.cursor.rowcount != 1:
raise Exception('Fail to delete due to no matched rows or too much rows.')
if cursor.rowcount != 1:
raise DbException('Fail to delete due to no matched rows or too much rows.')
return True
+42
View File
@@ -0,0 +1,42 @@
import logging
import enum
def _build_logger() -> tuple[logging.Logger, logging.Handler]:
# Create a new logger which is independent with Flask
logger = logging.getLogger("my_console_logger")
# Avoid message was propagated to root logger or captured by Flask logger.
logger.propagate = False
# Set initial level.
logger.setLevel(logging.INFO)
# Create StreamHandler to output into stderr.
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.DEBUG)
# Set format for it.
formatter = logging.Formatter("[%(levelname)s] %(message)s")
console_handler.setFormatter(formatter)
# Add handler
logger.addHandler(console_handler)
return (logger, console_handler)
(LOGGER, CONSOLE_HANDLER) = _build_logger()
class LoggerLevel(enum.IntEnum):
DEBUG = enum.auto()
INFO = enum.auto()
def set_level(level: LoggerLevel) -> None:
logging_level: int = logging.INFO
match level:
case LoggerLevel.DEBUG:
logging_level = logging.DEBUG
case LoggerLevel.INFO:
logging_level = logging.INFO
LOGGER.setLevel(logging_level)
CONSOLE_HANDLER.setLevel(logging_level)
+406 -350
View File
@@ -1,457 +1,513 @@
from flask import Flask
# from flask import g
from flask import render_template
from flask import url_for
from flask import request
# from flask import abort
from flask import redirect
# from functools import reduce
# import json
# import os
from dataclasses import dataclass
from typing import Any, Callable, ParamSpec, TypeVar, Generic
import config
import database
import utils
from pathlib import Path
from logger import LOGGER
from database import ResponseBody
_FRONTEND_PATH = Path(__file__).resolve().parent.parent / "frontend"
app = Flask(
__name__,
static_folder=_FRONTEND_PATH / "static",
template_folder=_FRONTEND_PATH / "templates",
)
app = Flask(__name__)
calendar_db = database.CalendarDatabase()
# render_static_resources = None
# region: API Route
# =============================================database
# def get_database():
# db = getattr(g, '_database', None)
# if db is None:
# db = database.CalendarDatabase()
# db.open()
# return db
# @app.teardown_appcontext
# def close_database(exception):
# db = getattr(g, '_database', None)
# if db is not None:
# db.close()
# region: Common
# ============================================= static page route
@app.route('/', methods=['GET'])
def nospecHandle():
return redirect(url_for('web_homeHandle'))
@app.route('/web/home', methods=['GET'])
def web_homeHandle():
# UpdateStaticResources()
return render_template("home.html")
@app.route('/web/calendar', methods=['GET'])
def web_calendarHandle():
# UpdateStaticResources()
return render_template("calendar.html")
@app.route('/web/todo', methods=['GET'])
def web_todoHandle():
# UpdateStaticResources()
return render_template("todo.html")
@app.route('/web/admin', methods=['GET'])
def web_adminHandle():
# UpdateStaticResources()
return render_template("admin.html")
@app.route('/web/login', methods=['GET'])
def web_loginHandle():
# UpdateStaticResources()
return render_template("login.html")
@app.route('/web/collection', methods=['GET'])
def web_collectionHandle():
# UpdateStaticResources()
return render_template("collection.html")
@app.route('/web/eventAdd', methods=['GET'])
def web_eventAddHandle():
# UpdateStaticResources()
return render_template("event.html",
uuidPath=''
)
@app.route('/web/eventUpdate/<path:uuidPath>', methods=['GET'])
def web_eventUpdateHandle(uuidPath):
# UpdateStaticResources()
return render_template("event.html",
uuidPath = uuidPath
)
# ============================================= query page route
# ================================ common
@app.route('/api/common/salt', methods=['POST'])
@app.route("/common/salt", methods=["POST"])
def api_common_saltHandle():
return SmartDbCaller(calendar_db.common_salt,
(('username', str, False), ),
None)
return SmartDbCaller(
calendar_db.common_salt, (FormField("username", str, False),), None
)
@app.route('/api/common/login', methods=['POST'])
@app.route("/common/login", methods=["POST"])
def api_common_loginHandle():
# construct client data first
clientUa = request.user_agent.string
if request.headers.getlist("X-Forwarded-For"):
clientIp = request.headers.getlist("X-Forwarded-For")[0]
else:
clientIp = request.remote_addr
clientInfo = FetchClientNetworkInfo()
return SmartDbCaller(calendar_db.common_login,
(('username', str, False),
('password', str, False),
('clientUa', str, False),
('clientIp', str, False)),
{
'clientUa': clientUa,
'clientIp': clientIp
})
return SmartDbCaller(
calendar_db.common_login,
(
FormField("username", str, False),
FormField("password", str, False),
FormField("clientUa", str, False),
FormField("clientIp", str, False),
),
{"clientUa": clientInfo.user_agent, "clientIp": clientInfo.ip_addr},
)
@app.route('/api/common/webLogin', methods=['POST'])
@app.route("/common/webLogin", methods=["POST"])
def api_common_webLoginHandle():
# construct client data first
clientUa = request.user_agent.string
if request.headers.getlist("X-Forwarded-For"):
clientIp = request.headers.getlist("X-Forwarded-For")[0]
else:
clientIp = request.remote_addr
clientInfo = FetchClientNetworkInfo()
return SmartDbCaller(calendar_db.common_webLogin,
(('username', str, False),
('password', str, False),
('clientUa', str, False),
('clientIp', str, False)),
{
'clientUa': clientUa,
'clientIp': clientIp
})
return SmartDbCaller(
calendar_db.common_webLogin,
(
FormField("username", str, False),
FormField("password", str, False),
FormField("clientUa", str, False),
FormField("clientIp", str, False),
),
{"clientUa": clientInfo.user_agent, "clientIp": clientInfo.ip_addr},
)
@app.route('/api/common/logout', methods=['POST'])
@app.route("/common/logout", methods=["POST"])
def api_common_logoutHandle():
return SmartDbCaller(calendar_db.common_logout,
(('token', str, False), ),
None)
return SmartDbCaller(
calendar_db.common_logout, (FormField("token", str, False),), None
)
@app.route('/api/common/tokenValid', methods=['POST'])
@app.route("/common/tokenValid", methods=["POST"])
def api_common_tokenValidHandle():
return SmartDbCaller(calendar_db.common_tokenValid,
(('token', str, False), ),
None)
return SmartDbCaller(
calendar_db.common_tokenValid, (FormField("token", str, False),), None
)
# ================================ calendar
@app.route('/api/calendar/getFull', methods=['POST'])
# endregion
# region: Calendar
@app.route("/calendar/getFull", methods=["POST"])
def api_calendar_getFullHandle():
return SmartDbCaller(calendar_db.calendar_getFull,
(('token', str, False),
('startDateTime', int, False),
('endDateTime', int, False)),
None)
return SmartDbCaller(
calendar_db.calendar_getFull,
(
FormField("token", str, False),
FormField("startDateTime", int, False),
FormField("endDateTime", int, False),
),
None,
)
@app.route('/api/calendar/getList', methods=['POST'])
@app.route("/calendar/getList", methods=["POST"])
def api_calendar_getListHandle():
return SmartDbCaller(calendar_db.calendar_getList,
(('token', str, False),
('startDateTime', int, False),
('endDateTime', int, False)),
None)
return SmartDbCaller(
calendar_db.calendar_getList,
(
FormField("token", str, False),
FormField("startDateTime", int, False),
FormField("endDateTime", int, False),
),
None,
)
@app.route('/api/calendar/getDetail', methods=['POST'])
@app.route("/calendar/getDetail", methods=["POST"])
def api_calendar_getDetailHandle():
return SmartDbCaller(calendar_db.calendar_getDetail,
(('token', str, False),
('uuid', str, False)),
None)
return SmartDbCaller(
calendar_db.calendar_getDetail,
(FormField("token", str, False), FormField("uuid", str, False)),
None,
)
@app.route('/api/calendar/update', methods=['POST'])
@app.route("/calendar/update", methods=["POST"])
def api_calendar_updateHandle():
return SmartDbCaller(calendar_db.calendar_update,
(('token', str, False),
('uuid', str, False),
('belongTo', str, True),
('title', str, True),
('description', str, True),
('eventDateTimeStart', int, True),
('eventDateTimeEnd', int, True),
('loopRules', str, True),
('timezoneOffset', int, True),
('lastChange', str, False)),
None)
return SmartDbCaller(
calendar_db.calendar_update,
(
FormField("token", str, False),
FormField("uuid", str, False),
FormField("belongTo", str, True),
FormField("title", str, True),
FormField("description", str, True),
FormField("eventDateTimeStart", int, True),
FormField("eventDateTimeEnd", int, True),
FormField("loopRules", str, True),
FormField("timezoneOffset", int, True),
FormField("lastChange", str, False),
),
None,
)
@app.route('/api/calendar/add', methods=['POST'])
@app.route("/calendar/add", methods=["POST"])
def api_calendar_addHandle():
return SmartDbCaller(calendar_db.calendar_add,
(('token', str, False),
('belongTo', str, False),
('title', str, False),
('description', str, False),
('eventDateTimeStart', int, False),
('eventDateTimeEnd', int, False),
('loopRules', str, False),
('timezoneOffset', int, False)),
None)
return SmartDbCaller(
calendar_db.calendar_add,
(
FormField("token", str, False),
FormField("belongTo", str, False),
FormField("title", str, False),
FormField("description", str, False),
FormField("eventDateTimeStart", int, False),
FormField("eventDateTimeEnd", int, False),
FormField("loopRules", str, False),
FormField("timezoneOffset", int, False),
),
None,
)
@app.route('/api/calendar/delete', methods=['POST'])
@app.route("/calendar/delete", methods=["POST"])
def api_calendar_deleteHandle():
return SmartDbCaller(calendar_db.calendar_delete,
(('token', str, False),
('uuid', str, False),
('lastChange', str, False)),
None)
return SmartDbCaller(
calendar_db.calendar_delete,
(
FormField("token", str, False),
FormField("uuid", str, False),
FormField("lastChange", str, False),
),
None,
)
# ================================ collection
@app.route('/api/collection/getFullOwn', methods=['POST'])
# endregion
# region: Collection
@app.route("/collection/getFullOwn", methods=["POST"])
def api_collection_getFullOwnHandle():
return SmartDbCaller(calendar_db.collection_getFullOwn,
(('token', str, False), ),
None)
return SmartDbCaller(
calendar_db.collection_getFullOwn, (FormField("token", str, False),), None
)
@app.route('/api/collection/getListOwn', methods=['POST'])
@app.route("/collection/getListOwn", methods=["POST"])
def api_collection_getListOwnHandle():
return SmartDbCaller(calendar_db.collection_getListOwn,
(('token', str, False), ),
None)
return SmartDbCaller(
calendar_db.collection_getListOwn, (FormField("token", str, False),), None
)
@app.route('/api/collection/getDetailOwn', methods=['POST'])
@app.route("/collection/getDetailOwn", methods=["POST"])
def api_collection_getDetailOwnHandle():
return SmartDbCaller(calendar_db.collection_getDetailOwn,
(('token', str, False),
('uuid', str, False)),
None)
return SmartDbCaller(
calendar_db.collection_getDetailOwn,
(FormField("token", str, False), FormField("uuid", str, False)),
None,
)
@app.route('/api/collection/addOwn', methods=['POST'])
@app.route("/collection/addOwn", methods=["POST"])
def api_collection_addOwnHandle():
return SmartDbCaller(calendar_db.collection_addOwn,
(('token', str, False),
('name', str, False)),
None)
return SmartDbCaller(
calendar_db.collection_addOwn,
(FormField("token", str, False), FormField("name", str, False)),
None,
)
@app.route('/api/collection/updateOwn', methods=['POST'])
@app.route("/collection/updateOwn", methods=["POST"])
def api_collection_updateOwnHandle():
return SmartDbCaller(calendar_db.collection_updateOwn,
(('token', str, False),
('uuid', str, False),
('name', str, False),
('lastChange', str, False)),
None)
return SmartDbCaller(
calendar_db.collection_updateOwn,
(
FormField("token", str, False),
FormField("uuid", str, False),
FormField("name", str, False),
FormField("lastChange", str, False),
),
None,
)
@app.route('/api/collection/deleteOwn', methods=['POST'])
@app.route("/collection/deleteOwn", methods=["POST"])
def api_collection_deleteOwnHandle():
return SmartDbCaller(calendar_db.collection_deleteOwn,
(('token', str, False),
('uuid', str, False),
('lastChange', str, False)),
None)
return SmartDbCaller(
calendar_db.collection_deleteOwn,
(
FormField("token", str, False),
FormField("uuid", str, False),
FormField("lastChange", str, False),
),
None,
)
@app.route('/api/collection/getSharing', methods=['POST'])
@app.route("/collection/getSharing", methods=["POST"])
def api_collection_getSharingHandle():
return SmartDbCaller(calendar_db.collection_getSharing,
(('token', str, False),
('uuid', str, False)),
None)
return SmartDbCaller(
calendar_db.collection_getSharing,
(FormField("token", str, False), FormField("uuid", str, False)),
None,
)
@app.route('/api/collection/deleteSharing', methods=['POST'])
@app.route("/collection/deleteSharing", methods=["POST"])
def api_collection_deleteSharingHandle():
return SmartDbCaller(calendar_db.collection_deleteSharing,
(('token', str, False),
('uuid', str, False),
('target', str, False),
('lastChange', str, False)),
None)
return SmartDbCaller(
calendar_db.collection_deleteSharing,
(
FormField("token", str, False),
FormField("uuid", str, False),
FormField("target", str, False),
FormField("lastChange", str, False),
),
None,
)
@app.route('/api/collection/addSharing', methods=['POST'])
@app.route("/collection/addSharing", methods=["POST"])
def api_collection_addSharingHandle():
return SmartDbCaller(calendar_db.collection_addSharing,
(('token', str, False),
('uuid', str, False),
('target', str, False),
('lastChange', str, False)),
None)
return SmartDbCaller(
calendar_db.collection_addSharing,
(
FormField("token", str, False),
FormField("uuid", str, False),
FormField("target", str, False),
FormField("lastChange", str, False),
),
None,
)
@app.route('/api/collection/getShared', methods=['POST'])
@app.route("/collection/getShared", methods=["POST"])
def api_collection_getSharedHandle():
return SmartDbCaller(calendar_db.collection_getShared,
(('token', str, False), ),
None)
return SmartDbCaller(
calendar_db.collection_getShared, (FormField("token", str, False),), None
)
# ================================ todo
@app.route('/api/todo/getFull', methods=['POST'])
# endregion
# region: Todo
@app.route("/todo/getFull", methods=["POST"])
def api_todo_getFullHandle():
return SmartDbCaller(calendar_db.todo_getFull,
(('token', str, False), ),
None)
return SmartDbCaller(
calendar_db.todo_getFull, (FormField("token", str, False),), None
)
@app.route('/api/todo/getList', methods=['POST'])
@app.route("/todo/getList", methods=["POST"])
def api_todo_getListHandle():
return SmartDbCaller(calendar_db.todo_getList,
(('token', str, False), ),
None)
return SmartDbCaller(
calendar_db.todo_getList, (FormField("token", str, False),), None
)
@app.route('/api/todo/getDetail', methods=['POST'])
@app.route("/todo/getDetail", methods=["POST"])
def api_todo_getDetailHandle():
return SmartDbCaller(calendar_db.todo_getDetail,
(('token', str, False),
('uuid', str, False)),
None)
return SmartDbCaller(
calendar_db.todo_getDetail,
(FormField("token", str, False), FormField("uuid", str, False)),
None,
)
@app.route('/api/todo/add', methods=['POST'])
@app.route("/todo/add", methods=["POST"])
def api_todo_addHandle():
return SmartDbCaller(calendar_db.todo_add,
(('token', str, False), ),
None)
return SmartDbCaller(calendar_db.todo_add, (FormField("token", str, False),), None)
@app.route('/api/todo/update', methods=['POST'])
@app.route("/todo/update", methods=["POST"])
def api_todo_updateHandle():
return SmartDbCaller(calendar_db.todo_update,
(('token', str, False),
('uuid', str, False),
('data', str, False),
('lastChange', str, False)),
None)
return SmartDbCaller(
calendar_db.todo_update,
(
FormField("token", str, False),
FormField("uuid", str, False),
FormField("data", str, False),
FormField("lastChange", str, False),
),
None,
)
@app.route('/api/todo/delete', methods=['POST'])
@app.route("/todo/delete", methods=["POST"])
def api_todo_deleteHandle():
return SmartDbCaller(calendar_db.todo_delete,
(('token', str, False),
('uuid', str, False),
('lastChange', str, False)),
None)
return SmartDbCaller(
calendar_db.todo_delete,
(
FormField("token", str, False),
FormField("uuid", str, False),
FormField("lastChange", str, False),
),
None,
)
# ================================ admin
@app.route('/api/admin/get', methods=['POST'])
# endregion
# region: Admin
@app.route("/admin/get", methods=["POST"])
def api_admin_getHandle():
return SmartDbCaller(calendar_db.admin_get,
(('token', str, False), ),
None)
return SmartDbCaller(calendar_db.admin_get, (FormField("token", str, False),), None)
@app.route('/api/admin/add', methods=['POST'])
@app.route("/admin/add", methods=["POST"])
def api_admin_addHandle():
return SmartDbCaller(calendar_db.admin_add,
(('token', str, False),
('username', str, False)),
None)
return SmartDbCaller(
calendar_db.admin_add,
(FormField("token", str, False), FormField("username", str, False)),
None,
)
@app.route('/api/admin/update', methods=['POST'])
@app.route("/admin/update", methods=["POST"])
def api_admin_updateHandle():
return SmartDbCaller(calendar_db.admin_update,
(('token', str, False),
('username', str, False),
('password', str, True),
('isAdmin', utils.Str2Bool, True)),
None)
return SmartDbCaller(
calendar_db.admin_update,
(
FormField("token", str, False),
FormField("username", str, False),
FormField("password", str, True),
FormField("isAdmin", utils.Str2Bool, True),
),
None,
)
@app.route('/api/admin/delete', methods=['POST'])
@app.route("/admin/delete", methods=["POST"])
def api_admin_deleteHandle():
return SmartDbCaller(calendar_db.admin_delete,
(('token', str, False),
('username', str, False)),
None)
return SmartDbCaller(
calendar_db.admin_delete,
(FormField("token", str, False), FormField("username", str, False)),
None,
)
# ================================ profile
@app.route('/api/profile/isAdmin', methods=['POST'])
# endregion
# region: Profile
@app.route("/profile/isAdmin", methods=["POST"])
def api_profile_isAdminHandle():
return SmartDbCaller(calendar_db.profile_isAdmin,
(('token', str, False), ),
None)
return SmartDbCaller(
calendar_db.profile_isAdmin, (FormField("token", str, False),), None
)
@app.route('/api/profile/changePassword', methods=['POST'])
@app.route("/profile/changePassword", methods=["POST"])
def api_profile_changePasswordHandle():
return SmartDbCaller(calendar_db.profile_changePassword,
(('token', str, False),
('password', str, False)),
None)
return SmartDbCaller(
calendar_db.profile_changePassword,
(FormField("token", str, False), FormField("password", str, False)),
None,
)
@app.route('/api/profile/getToken', methods=['POST'])
@app.route("/profile/getToken", methods=["POST"])
def api_profile_getTokenHandle():
return SmartDbCaller(calendar_db.profile_getToken,
(('token', str, False), ),
None)
return SmartDbCaller(
calendar_db.profile_getToken, (FormField("token", str, False),), None
)
@app.route('/api/profile/deleteToken', methods=['POST'])
@app.route("/profile/deleteToken", methods=["POST"])
def api_profile_deleteTokenHandle():
return SmartDbCaller(calendar_db.profile_deleteToken,
(('token', str, False),
('deleteToken', str, False)),
None)
return SmartDbCaller(
calendar_db.profile_deleteToken,
(FormField("token", str, False), FormField("deleteToken", str, False)),
None,
)
# =============================================main run
'''
def UpdateStaticResources():
global render_static_resources
if render_static_resources is not None:
return
# endregion
render_static_resources = {
'url_js_localStorageAssist': url_for('static', filename='js/localStorageAssist.js'),
'url_js_i18n': url_for('static', filename='js/i18n.js'),
'url_js_api': url_for('static', filename='js/api.js'),
'url_js_headerNav': url_for('static', filename='js/headerNav.js'),
# endregion
'url_tmpl_headerNac': url_for('static', filename='tmpl/headerNav.tmpl'),
# region: Utilities
'url_js_pageHome': url_for('static', filename='js/page/home.js')
}
'''
def SmartDbCaller(dbMethod, paramTuple, extraDict):
result = (False, 'Invalid parameter', None)
optCount = 0
paramList = []
optParamDict = {}
# for each item,
# item[0] is field name.
# item[1] is type.
# item[2] is whether it is optional field
realForm = request.form.to_dict()
if extraDict is not None:
realForm.update(extraDict)
for item in paramTuple:
cache = item[1](realForm.get(item[0], None))
if item[2]:
@dataclass(frozen=True)
class ClientNetworkInfo:
user_agent: str
"""The user agent of client."""
ip_addr: str
"""The IP address of client."""
def FetchClientNetworkInfo() -> ClientNetworkInfo:
clientUa = request.user_agent.string
forwardIpList = request.headers.getlist("X-Forwarded-For")
if forwardIpList:
clientIp = forwardIpList[0]
else:
directIp = request.remote_addr
if directIp is not None:
clientIp = directIp
else:
clientIp = "0.0.0.0"
return ClientNetworkInfo(clientUa, clientIp)
@dataclass(frozen=True)
class FormField:
name: str
"""The name of form field."""
ty: Callable[[str], Any]
"""The type of form field."""
is_optional: bool
"""True if this form field is optional, otherwise false."""
def SmartDbCaller(
db_method: Callable[..., ResponseBody[Any]],
fields: tuple[FormField, ...],
padding_form: dict[str, str] | None,
) -> dict[str, Any]:
opt_param_counter = 0
lost_required: bool = False
param_list: list[Any] = []
opt_param_dict: dict[str, Any] = {}
# fetch user passed form
user_form: dict[str, str] = request.form.to_dict()
LOGGER.debug(f"User Form: {user_form}")
# overwrite user form by our padding form
if padding_form is not None:
user_form.update(padding_form)
LOGGER.debug(f"Padded User Form: {user_form}")
# check fields one by one
for field in fields:
value = user_form.get(field.name, None)
if value is not None:
value = field.ty(value)
if field.is_optional:
# optional param
if cache is not None:
optParamDict[item[0]] = cache
optCount += 1
if value is not None:
opt_param_dict[field.name] = value
opt_param_counter += 1
else:
if cache is None:
break
paramList.append(cache)
# required param
if value is None:
lost_required = True
else:
# at least one opt param
if optCount == 0 or len(optParamDict) != 0:
result = dbMethod(*paramList, **optParamDict)
param_list.append(value)
# Only execute database function if there is no lost required fields.
# And fulfill one of following requirements:
# 1. There are all required fields (optional parameter count is zero).
# 1. Or, there is some optional parameter.
LOGGER.debug(f"Has Lost Required Parameter: {lost_required}")
LOGGER.debug(f"All Optional Parameter Count: {opt_param_counter}")
LOGGER.debug(f"Available Optional Parameter Count: {len(opt_param_dict)}")
result: ResponseBody[Any]
if lost_required == False and (opt_param_counter == 0 or len(opt_param_dict) != 0):
result = db_method(*param_list, **opt_param_dict)
else:
result = ResponseBody(False, "Invalid parameter", None)
return ConstructResponseBody(result)
def ConstructResponseBody(returnedTuple):
return {
'success': returnedTuple[0],
'error': returnedTuple[1],
'data': returnedTuple[2]
}
def ConstructResponseBody(body: ResponseBody[Any]) -> dict[str, Any]:
return {"success": body.success, "error": body.error, "data": body.data}
# endregion
def run():
calendar_db.open()
app.run(port=config.get_config().web.port)
calendar_db.close()
+40 -40
View File
@@ -1,67 +1,67 @@
CREATE TABLE user(
[ccn_name] TEXT NOT NULL,
[ccn_password] TEXT NOT NULL,
[ccn_isAdmin] TINYINT NOT NULL CHECK(ccn_isAdmin = 1 OR ccn_isAdmin = 0),
[ccn_salt] INTEGER NOT NULL,
[name] TEXT NOT NULL,
[password] TEXT NOT NULL,
[is_admin] TINYINT NOT NULL CHECK(is_admin = 1 OR is_admin = 0),
[salt] INTEGER NOT NULL,
PRIMARY KEY (ccn_name)
PRIMARY KEY (name)
);
CREATE TABLE token(
[ccn_user] TEXT NOT NULL,
[ccn_token] TEXT UNIQUE NOT NULL,
[ccn_tokenExpireOn] BIGINT NOT NULL,
[ccn_ua] TEXT NOT NULL,
[ccn_ip] TEXT NOT NULL,
[user] TEXT NOT NULL,
[token] TEXT UNIQUE NOT NULL,
[token_expire_on] BIGINT NOT NULL,
[ua] TEXT NOT NULL,
[ip] TEXT NOT NULL,
FOREIGN KEY (ccn_user) REFERENCES user(ccn_name) ON DELETE CASCADE
FOREIGN KEY (user) REFERENCES user(name) ON DELETE CASCADE
);
CREATE TABLE collection(
[ccn_uuid] TEXT NOT NULL,
[ccn_name] TEXT NOT NULL,
[ccn_user] TEXT NOT NULL,
[ccn_lastChange] TEXT NOT NULL,
[uuid] TEXT NOT NULL,
[name] TEXT NOT NULL,
[user] TEXT NOT NULL,
[last_change] TEXT NOT NULL,
PRIMARY KEY (ccn_uuid),
FOREIGN KEY (ccn_user) REFERENCES user(ccn_name) ON DELETE CASCADE
PRIMARY KEY (uuid),
FOREIGN KEY (user) REFERENCES user(name) ON DELETE CASCADE
);
CREATE TABLE share(
[ccn_uuid] TEXT NOT NULL,
[ccn_target] TEXT NOT NULL,
[uuid] TEXT NOT NULL,
[target] TEXT NOT NULL,
FOREIGN KEY (ccn_uuid) REFERENCES collection(ccn_uuid) ON DELETE CASCADE
FOREIGN KEY (ccn_target) REFERENCES user(ccn_name) ON DELETE CASCADE
FOREIGN KEY (uuid) REFERENCES collection(uuid) ON DELETE CASCADE
FOREIGN KEY (target) REFERENCES user(name) ON DELETE CASCADE
);
CREATE TABLE calendar(
[ccn_uuid] TEXT NOT NULL,
[ccn_belongTo] TEXT NOT NULL,
[uuid] TEXT NOT NULL,
[belong_to] TEXT NOT NULL,
[ccn_title] TEXT NOT NULL,
[ccn_description] TEXT NOT NULL,
[ccn_lastChange] TEXT NOT NULL,
[title] TEXT NOT NULL,
[description] TEXT NOT NULL,
[last_change] TEXT NOT NULL,
[ccn_eventDateTimeStart] BIGINT NOT NULL,
[ccn_eventDateTimeEnd] BIGINT NOT NULL,
[ccn_timezoneOffset] INT NOT NULL,
[event_date_time_start] BIGINT NOT NULL,
[event_date_time_end] BIGINT NOT NULL,
[timezone_offset] INT NOT NULL,
[ccn_loopRules] TEXT NOT NULL,
[ccn_loopDateTimeStart] BIGINT NOT NULL,
[ccn_loopDateTimeEnd] BIGINT NOT NULL,
[loop_rules] TEXT NOT NULL,
[loop_date_time_start] BIGINT NOT NULL,
[loop_date_time_end] BIGINT NOT NULL,
PRIMARY KEY (ccn_uuid),
FOREIGN KEY (ccn_belongTo) REFERENCES collection(ccn_uuid) ON DELETE CASCADE
PRIMARY KEY (uuid),
FOREIGN KEY (belong_to) REFERENCES collection(uuid) ON DELETE CASCADE
);
CREATE TABLE todo(
[ccn_uuid] TEXT NOT NULL,
[ccn_belongTo] TEXT NOT NULL,
[uuid] TEXT NOT NULL,
[belong_to] TEXT NOT NULL,
[ccn_data] TEXT NOT NULL,
[ccn_lastChange] TEXT NOT NULL,
[data] TEXT NOT NULL,
[last_change] TEXT NOT NULL,
PRIMARY KEY (ccn_uuid),
FOREIGN KEY (ccn_belongTo) REFERENCES user(ccn_name) ON DELETE CASCADE
PRIMARY KEY (uuid),
FOREIGN KEY (belong_to) REFERENCES user(name) ON DELETE CASCADE
);
+34 -22
View File
@@ -3,50 +3,62 @@ import random
import uuid
import time
import math
import re
ValidUsername = set(map(lambda x:chr(x), range(48, 58, 1))) | set(map(lambda x:chr(x), range(65, 91, 1))) | set(map(lambda x:chr(x), range(97, 123, 1)))
ValidPassword = set(map(lambda x:chr(x), range(33, 127, 1)))
USERNAME_PATTERN: re.Pattern = re.compile("^[0-9A-Za-z]+$")
PASSWORD_PATTERN: re.Pattern = re.compile("^[!-~]+$")
def IsValidUsername(strl):
return (len(set(strl) - ValidUsername) == 0)
def IsValidPassword(strl):
return (len(set(strl) - ValidPassword) == 0)
def IsValidUsername(strl: str) -> bool:
return USERNAME_PATTERN.match(strl) is not None
def ComputePasswordHash(password):
def IsValidPassword(strl: str) -> bool:
return PASSWORD_PATTERN.match(strl) is not None
def ComputePasswordHash(password: str) -> str:
s = hashlib.sha256()
s.update(password.encode('utf-8'))
s.update(password.encode("utf-8"))
return s.hexdigest()
def GenerateUUID():
def GenerateUUID() -> str:
return str(uuid.uuid1())
def GenerateToken(username):
def GenerateToken(username: str) -> str:
s = hashlib.sha256()
s.update(username.encode('utf-8'))
s.update(GenerateUUID().encode('utf-8'))
s.update(username.encode("utf-8"))
s.update(GenerateUUID().encode("utf-8"))
return s.hexdigest()
def GenerateSalt():
def GenerateSalt() -> int:
return random.randint(0, 6172748)
def ComputePasswordHashWithSalt(passwordHashed, salt):
def ComputePasswordHashWithSalt(passwordHashed: str, salt: int) -> str:
s = hashlib.sha256()
s.update((passwordHashed + str(salt)).encode('utf-8'))
s.update((passwordHashed + str(salt)).encode("utf-8"))
return s.hexdigest()
def GetCurrentTimestamp():
def GetCurrentTimestamp() -> int:
return int(time.time())
def GetTokenExpireOn():
def GetTokenExpireOn() -> int:
return GetCurrentTimestamp() + 60 * 60 * 24 * 2 # add 2 day from now
def Str2Bool(strl):
return strl.lower() == 'true'
def GCD(a, b):
def Str2Bool(strl: str) -> bool:
return strl.lower() == "true"
def GCD(a: int, b: int) -> int:
return math.gcd(a, b)
def LCM(a, b):
return int(a * b / GCD(a, b))
def LCM(a: int, b: int) -> int:
return (a * b) // GCD(a, b)
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

+8
View File
@@ -0,0 +1,8 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100
+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
+39
View File
@@ -0,0 +1,39 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/
# Vite
*.timestamp-*-*.mjs
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue"],
"env": {
"browser": true
},
"categories": {
"correctness": "error"
}
}
+48
View File
@@ -0,0 +1,48 @@
# coleaf-frontend
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Recommended Browser Setup
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
- Firefox:
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
pnpm install
```
### Compile and Hot-Reload for Development
```sh
pnpm dev
```
### Type-Check, Compile and Minify for Production
```sh
pnpm build
```
### Lint with [ESLint](https://eslint.org/)
```sh
pnpm lint
```
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+23
View File
@@ -0,0 +1,23 @@
import { globalIgnores } from 'eslint/config'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
import pluginVue from 'eslint-plugin-vue'
import pluginOxlint from 'eslint-plugin-oxlint'
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
// import { configureVueProject } from '@vue/eslint-config-typescript'
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
export default defineConfigWithVueTs(
{
name: 'app/files-to-lint',
files: ['**/*.{vue,ts,mts,tsx}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
...pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
)
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>coconut-leaf</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+47
View File
@@ -0,0 +1,47 @@
{
"name": "coleaf-frontend",
"version": "2.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint": "run-s lint:*",
"lint:oxlint": "oxlint . --fix",
"lint:eslint": "eslint . --fix --cache"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.2.0",
"@fortawesome/free-solid-svg-icons": "^7.2.0",
"@fortawesome/vue-fontawesome": "^3.2.0",
"axios": "1.14.0",
"bulma": "0.9.1",
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1",
"vue": "^3.5.32",
"vue-router": "^5.0.4"
},
"devDependencies": {
"@tsconfig/node24": "^24.0.4",
"@types/node": "^24.12.2",
"@vitejs/plugin-vue": "^6.0.6",
"@vue/eslint-config-typescript": "^14.7.0",
"@vue/tsconfig": "^0.9.1",
"eslint": "^10.2.1",
"eslint-plugin-oxlint": "~1.60.0",
"eslint-plugin-vue": "~10.8.0",
"jiti": "^2.6.1",
"npm-run-all2": "^8.0.4",
"oxlint": "~1.60.0",
"typescript": "~6.0.0",
"vite": "^8.0.8",
"vite-plugin-vue-devtools": "^8.1.1",
"vue-tsc": "^3.2.6"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
}
+3584
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+40
View File
@@ -0,0 +1,40 @@
// 导入 Bulma
@charset "utf-8";
@import "bulma/bulma.sass";
// coconut-leaf 全局样式
// 卡片样式(作用于集合列表,Todo列表项等)
div.paperbox-item {
display: flex;
flex-flow: row;
align-items: flex-start;
padding: 1.25rem;
margin-bottom: 1.25rem;
}
div.paperbox-item-words {
flex-grow: 1;
flex-basis: 0;
word-break: break-all;
}
div.paperbox-item-icon {
margin-left: 0.75rem;
}
// 按钮列表样式
div.button-list {
display: flex;
flex-flow: row;
flex-wrap: wrap;
}
div.button-list>* {
margin-right: 0.75rem;
margin-bottom: 0.75rem;
margin-left: 0 !important;
}
+92
View File
@@ -0,0 +1,92 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useLanguageStore } from './stores/language';
import { useTokenStore } from './stores/token';
import MessageBox from '@/components/MessageBox.vue';
import { logout as apiCommonLogout } from './api/common';
import { goToHome } from '@/router';
const language = useLanguageStore();
const token = useTokenStore();
const isBurgerActive = ref<boolean>(false);
const messagebox = ref<InstanceType<typeof MessageBox>>();
const logout = async () => {
const tokenStore = useTokenStore();
const rv = await apiCommonLogout(tokenStore.currentToken);
if (rv) {
// OK. We logged out.
// Clear token.
tokenStore.logout();
// And go to Home page
goToHome();
} else {
// Show logout error.
messagebox.value?.show("Fail to logout due to unknow reason. Consider refreshing page to solve problem.");
}
}
// Process burger menu.
// This is copied from Bulma website and modified for Vue.
// Check for click events on the navbar burger icon
const toggleBurger = () => {
// Toggle the "is-active" class on both the "navbar-burger" and the "navbar-menu"
isBurgerActive.value = !isBurgerActive.value
}
</script>
<template>
<nav class="navbar has-shadow is-spaced bd-navbar" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<router-link class="navbar-item" to="/">
<img src="/favicon.ico"><b style="margin:0 0 0 14px;">coconut-leaf</b>
</router-link>
<a role="button" class="navbar-burger burger" :class="{ 'is-active': isBurgerActive }" @click="toggleBurger"
aria-label="menu" aria-expanded="false" data-target="coleaf-navbar">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div id="coleaf-navbar" class="navbar-menu" :class="{ 'is-active': isBurgerActive }">
<div class="navbar-start">
<router-link class="navbar-item" to="/home">Home</router-link>
<router-link v-if="token.isLoggedIn" class="navbar-item" to="/collection">Collection</router-link>
<router-link v-if="token.isLoggedIn" class="navbar-item" to="/calendar">Calendar</router-link>
<router-link v-if="token.isLoggedIn" class="navbar-item" to="/todo">Todo</router-link>
<router-link v-if="token.isLoggedIn" class="navbar-item" to="/admin">Admin</router-link>
</div>
<div class="navbar-end">
<p class="navbar-item">
<router-link v-if="!token.isLoggedIn" class="button is-primary" to="/login">Login</router-link>
</p>
<p class="navbar-item">
<a v-if="token.isLoggedIn" class="button is-primary" @click="logout">Logout</a>
</p>
<div class="navbar-item has-dropdown is-hoverable">
<a v-if="language.isEnglish" class="navbar-link">English</a>
<a v-else-if="language.isSimplifiedChinese" class="navbar-link">简体中文</a>
<div class="navbar-dropdown">
<a @click="language.changeToEnglish()" class="navbar-item">English</a>
<a @click="language.changeToSimplifiedChinese()" class="navbar-item">简体中文</a>
</div>
</div>
</div>
</div>
</nav>
<!-- The output result of router -->
<router-view></router-view>
<MessageBox ref="messagebox" />
</template>
<style scoped></style>
+63
View File
@@ -0,0 +1,63 @@
import { apiWrapper, boolApiWrapper } from './index';
/** A raw admin user row as returned by the API (positional from SQL columns) */
export type AdminUserRow = [
name: string,
isAdmin: boolean,
];
/**
* Fetch all users (admin only).
*
* @param token - The auth token (must belong to an admin user)
* @returns An array of {@link AdminUserRow} on success, or `undefined` on failure
*/
export async function get(token: string): Promise<AdminUserRow[] | undefined> {
return apiWrapper<AdminUserRow[]>('/api/admin/get', { token });
}
/**
* Create a new user (admin only).
*
* @param token - The auth token (must belong to an admin user)
* @param username - The new username
* @returns A single {@link AdminUserRow} of the newly created user on success, or `undefined` on failure
*/
export async function add(token: string, username: string): Promise<AdminUserRow | undefined> {
return apiWrapper<AdminUserRow>('/api/admin/add', { token, username });
}
/**
* Update an existing user's password and/or admin status (admin only).
*
* @param token - The auth token (must belong to an admin user)
* @param username - The target user's username
* @param password - (optional) The new password (plaintext, will be hashed server-side)
* @param isAdmin - (optional) Whether the user should be an admin
* @returns `true` on success, `false` on failure (or if no changes were provided)
*/
export async function update(
token: string,
username: string,
password?: string,
isAdmin?: boolean,
): Promise<boolean> {
const data: Record<string, any> = { token, username };
if (password !== undefined) data.password = password;
if (isAdmin !== undefined) data.isAdmin = isAdmin;
if (Object.keys(data).length <= 2) return false;
return boolApiWrapper('/api/admin/update', data);
}
/**
* Delete a user (admin only).
*
* @param token - The auth token (must belong to an admin user)
* @param username - The username to delete
* @returns `true` on success, `false` on failure
*/
export async function del(token: string, username: string): Promise<boolean> {
return boolApiWrapper('/api/admin/delete', { token, username });
}
+172
View File
@@ -0,0 +1,172 @@
import { apiWrapper, boolApiWrapper } from './index';
/** A raw calendar row as returned by the API (positional from SQL columns) */
export type CalendarRow = [
uuid: string,
belongTo: string,
title: string,
description: string,
lastChange: string,
eventDateTimeStart: number,
eventDateTimeEnd: number,
timezoneOffset: number,
loopRules: string,
loopDateTimeStart: number,
loopDateTimeEnd: number,
];
/** Serialized description object stored in the `description` column */
export interface CalendarDescription {
description: string;
color: string;
}
/** Default color used when deserialization fails */
const DEFAULT_COLOR = '#3388ff';
/**
* Serialize a calendar description and color into a JSON string
* for storage in the `description` column.
*
* @param description - The description text
* @param color - The color string (e.g. `#ff0000`)
* @returns The JSON-serialized string
*/
export function serializeDescription(description: string, color: string): string {
return JSON.stringify({ description, color });
}
/**
* Deserialize a JSON description string back into an object.
* Returns a default object on parse errors.
*
* @param str - The JSON string to deserialize
* @returns The parsed {@link CalendarDescription} object
*/
export function deserializeDescription(str: string): CalendarDescription {
try {
return JSON.parse(str) as CalendarDescription;
} catch {
return { description: '', color: DEFAULT_COLOR };
}
}
/**
* Fetch all calendar events within the given time range for the current user.
*
* @param token - The auth token
* @param startDateTime - Start of the time range (unix timestamp)
* @param endDateTime - End of the time range (unix timestamp)
* @returns An array of {@link CalendarRow} on success, or `undefined` on failure
*/
export async function getFull(token: string, startDateTime: number, endDateTime: number): Promise<CalendarRow[] | undefined> {
return apiWrapper<CalendarRow[]>('/api/calendar/getFull', {
token,
startDateTime,
endDateTime,
});
}
/**
* Fetch the full detail of a single calendar event by UUID.
*
* @param token - The auth token
* @param uuid - The event UUID
* @returns A single {@link CalendarRow} on success, or `undefined` on failure
*/
export async function getDetail(token: string, uuid: string): Promise<CalendarRow | undefined> {
return apiWrapper<CalendarRow>('/api/calendar/getDetail', {
token,
uuid,
});
}
/**
* Update an existing calendar event. Only the fields provided will be updated.
*
* @param token - The auth token
* @param uuid - The event UUID
* @param lastChange - The last known `lastChange` value (optimistic concurrency)
* @param belongTo - (optional) Collection UUID this event belongs to
* @param title - (optional) Event title
* @param description - (optional) Event description (serialized JSON)
* @param eventDateTimeStart - (optional) Start time (unix timestamp)
* @param eventDateTimeEnd - (optional) End time (unix timestamp)
* @param loopRules - (optional) Loop rules string
* @param timezoneOffset - (optional) Timezone offset in minutes
* @returns The new `lastChange` value on success, or `undefined` on failure
*/
export async function update(
token: string,
uuid: string,
lastChange: string,
belongTo?: string,
title?: string,
description?: string,
eventDateTimeStart?: number,
eventDateTimeEnd?: number,
loopRules?: string,
timezoneOffset?: number,
): Promise<string | undefined> {
const data: Record<string, any> = { token, uuid, lastChange };
if (belongTo !== undefined) data.belongTo = belongTo;
if (title !== undefined) data.title = title;
if (description !== undefined) data.description = description;
if (eventDateTimeStart !== undefined) data.eventDateTimeStart = eventDateTimeStart;
if (eventDateTimeEnd !== undefined) data.eventDateTimeEnd = eventDateTimeEnd;
if (loopRules !== undefined) data.loopRules = loopRules;
if (timezoneOffset !== undefined) data.timezoneOffset = timezoneOffset;
return apiWrapper<string>('/api/calendar/update', data);
}
/**
* Create a new calendar event.
*
* @param token - The auth token
* @param belongTo - Collection UUID this event belongs to
* @param title - Event title
* @param description - Event description (serialized JSON)
* @param eventDateTimeStart - Start time (unix timestamp)
* @param eventDateTimeEnd - End time (unix timestamp)
* @param loopRules - Loop rules string
* @param timezoneOffset - Timezone offset in minutes
* @returns The new event UUID on success, or `undefined` on failure
*/
export async function add(
token: string,
belongTo: string,
title: string,
description: string,
eventDateTimeStart: number,
eventDateTimeEnd: number,
loopRules: string,
timezoneOffset: number,
): Promise<string | undefined> {
return apiWrapper<string>('/api/calendar/add', {
token,
belongTo,
title,
description,
eventDateTimeStart,
eventDateTimeEnd,
loopRules,
timezoneOffset,
});
}
/**
* Delete a calendar event.
*
* @param token - The auth token
* @param uuid - The event UUID
* @param lastChange - The last known `lastChange` value (optimistic concurrency)
* @returns `true` on success, `false` on failure
*/
export async function del(token: string, uuid: string, lastChange: string): Promise<boolean> {
return boolApiWrapper('/api/calendar/delete', {
token,
uuid,
lastChange,
});
}
+119
View File
@@ -0,0 +1,119 @@
import { apiWrapper, boolApiWrapper } from './index';
/** A raw collection row as returned by the API (positional from SQL columns) */
export type CollectionRow = [
uuid: string,
name: string,
lastChange: string,
];
/** A raw shared collection row as returned by the API */
export type SharedCollectionRow = [
uuid: string,
name: string,
user: string,
];
/**
* Fetch all collections owned by the current user.
*
* @param token - The auth token
* @returns An array of {@link CollectionRow} on success, or `undefined` on failure
*/
export async function getFullOwn(token: string): Promise<CollectionRow[] | undefined> {
return apiWrapper<CollectionRow[]>('/api/collection/getFullOwn', { token });
}
/**
* Fetch the detail of a single owned collection by UUID.
*
* @param token - The auth token
* @param uuid - The collection UUID
* @returns A single {@link CollectionRow} on success, or `undefined` on failure
*/
export async function getDetailOwn(token: string, uuid: string): Promise<CollectionRow | undefined> {
return apiWrapper<CollectionRow>('/api/collection/getDetailOwn', { token, uuid });
}
/**
* Create a new collection.
*
* @param token - The auth token
* @param name - The collection name
* @returns The new collection UUID on success, or `undefined` on failure
*/
export async function addOwn(token: string, name: string): Promise<string | undefined> {
return apiWrapper<string>('/api/collection/addOwn', { token, name });
}
/**
* Update an existing collection's name.
*
* @param token - The auth token
* @param uuid - The collection UUID
* @param name - The new collection name
* @param lastChange - The last known `lastChange` value (optimistic concurrency)
* @returns The new `lastChange` value on success, or `undefined` on failure
*/
export async function updateOwn(token: string, uuid: string, name: string, lastChange: string): Promise<string | undefined> {
return apiWrapper<string>('/api/collection/updateOwn', { token, uuid, name, lastChange });
}
/**
* Delete an owned collection.
*
* @param token - The auth token
* @param uuid - The collection UUID
* @param lastChange - The last known `lastChange` value (optimistic concurrency)
* @returns `true` on success, `false` on failure
*/
export async function deleteOwn(token: string, uuid: string, lastChange: string): Promise<boolean> {
return boolApiWrapper('/api/collection/deleteOwn', { token, uuid, lastChange });
}
/**
* Fetch all users this collection is shared with.
*
* @param token - The auth token
* @param uuid - The collection UUID
* @returns An array of target usernames on success, or `undefined` on failure
*/
export async function getSharing(token: string, uuid: string): Promise<string[] | undefined> {
return apiWrapper<string[]>('/api/collection/getSharing', { token, uuid });
}
/**
* Remove a sharing target from a collection.
*
* @param token - The auth token
* @param uuid - The collection UUID
* @param target - The username to unshare with
* @param lastChange - The last known `lastChange` value (optimistic concurrency)
* @returns The new `lastChange` value on success, or `undefined` on failure
*/
export async function deleteSharing(token: string, uuid: string, target: string, lastChange: string): Promise<string | undefined> {
return apiWrapper<string>('/api/collection/deleteSharing', { token, uuid, target, lastChange });
}
/**
* Add a sharing target to a collection.
*
* @param token - The auth token
* @param uuid - The collection UUID
* @param target - The username to share with
* @param lastChange - The last known `lastChange` value (optimistic concurrency)
* @returns The new `lastChange` value on success, or `undefined` on failure
*/
export async function addSharing(token: string, uuid: string, target: string, lastChange: string): Promise<string | undefined> {
return apiWrapper<string>('/api/collection/addSharing', { token, uuid, target, lastChange });
}
/**
* Fetch all collections shared with the current user.
*
* @param token - The auth token
* @returns An array of {@link SharedCollectionRow} on success, or `undefined` on failure
*/
export async function getShared(token: string): Promise<SharedCollectionRow[] | undefined> {
return apiWrapper<SharedCollectionRow[]>('/api/collection/getShared', { token });
}
+59
View File
@@ -0,0 +1,59 @@
import { apiWrapper, boolApiWrapper } from './index';
// /**
// * Login via salt-challenge mechanism.
// *
// * First fetches a salt from the server, then computes the salted password hash
// * and sends it to complete login. Returns the auth token on success.
// *
// * @param username - The username
// * @param password - The plaintext password
// * @returns The auth token string on success, or `undefined` on failure
// */
// export async function login(username: string, password: string): Promise<string | undefined> {
// const salt = await apiWrapper<number>('/api/common/salt', { username });
// if (salt === undefined) return undefined;
// const token = await apiWrapper<string>('/api/common/login', {
// username,
// password: ComputePasswordWithSalt(password, salt.toString()),
// });
// return token;
// }
/**
* Web login using password hash comparison (no salt challenge).
*
* @param username - The username
* @param password - The plaintext password
* @returns The auth token string on success, or `undefined` on failure
*/
export async function webLogin(username: string, password: string): Promise<string | undefined> {
const token = await apiWrapper<string>('/api/common/webLogin', {
username,
password,
});
return token;
}
/**
* Logout and invalidate the given token on the server.
*
* @param token - The auth token to invalidate
* @returns `true` on success, `false` on failure
*/
export async function logout(token: string): Promise<boolean> {
return boolApiWrapper('/api/common/logout', { token });
}
/**
* Check whether the given token is still valid.
*
* @param token - The auth token to validate
* @returns `true` if the token is valid, `false` otherwise
*/
export async function tokenValid(token: string): Promise<boolean> {
return boolApiWrapper('/api/common/tokenValid', { token });
}
+64
View File
@@ -0,0 +1,64 @@
/** Response interface for all API calls */
export interface ApiResponse<T = any> {
success: boolean;
error: string;
data: T;
}
/**
* Generic API wrapper that performs a POST request with URL-encoded form data.
* Returns the `data` field on success, or `undefined` on failure.
*
* @param url - The API endpoint URL
* @param data - Key-value pairs to send as form data
* @returns The response data on success, or `undefined` on failure
*/
export async function apiWrapper<T>(url: string, data: Record<string, any>): Promise<T | undefined> {
try {
const params = new URLSearchParams();
Object.entries(data).forEach(([key, value]) => {
params.append(key, String(value));
});
const response = await fetch(url, {
method: "POST",
mode: "cors",
cache: "no-cache",
credentials: "same-origin",
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
redirect: "follow",
referrerPolicy: "no-referrer",
body: params.toString(),
});
if (!response.ok) {
console.error(`HTTP failed: ${response.status}`);
}
const payload = await response.json() as ApiResponse<T>;
if (payload.success) {
return payload.data;
} else {
console.error(`API failed: ${payload.error}`);
return undefined;
}
} catch (error) {
console.error(`Fetch failed: ${error}`);
return undefined;
}
}
/**
* Boolean API wrapper. Calls {@link apiWrapper} and returns `true` if the
* response is not `undefined`, otherwise `false`.
*
* @param url - The API endpoint URL
* @param data - Key-value pairs to send as form data
* @returns `true` on success, `false` on failure
*/
export async function boolApiWrapper(url: string, data: Record<string, any>): Promise<boolean> {
const rv = await apiWrapper<null>(url, data);
return rv !== undefined;
}
+52
View File
@@ -0,0 +1,52 @@
import { apiWrapper, boolApiWrapper } from './index';
/** A raw token row as returned by the API (positional from SQL columns) */
export type TokenRow = [
user: string,
token: string,
tokenExpireOn: number,
ua: string,
ip: string,
];
/**
* Check whether the current user is an admin.
*
* @param token - The auth token
* @returns `true` if the user is an admin, `false` otherwise
*/
export async function isAdmin(token: string): Promise<boolean> {
return boolApiWrapper('/api/profile/isAdmin', { token });
}
/**
* Change the current user's password.
*
* @param token - The auth token
* @param password - The new password (plaintext, will be hashed server-side)
* @returns `true` on success, `false` on failure
*/
export async function changePassword(token: string, password: string): Promise<boolean> {
return boolApiWrapper('/api/profile/changePassword', { token, password });
}
/**
* Fetch all tokens associated with the current user.
*
* @param token - The auth token
* @returns An array of {@link TokenRow} on success, or `undefined` on failure
*/
export async function getToken(token: string): Promise<TokenRow[] | undefined> {
return apiWrapper<TokenRow[]>('/api/profile/getToken', { token });
}
/**
* Delete a specific token belonging to the current user.
*
* @param token - The auth token
* @param deleteToken - The token string to delete
* @returns `true` on success, `false` on failure
*/
export async function deleteToken(token: string, deleteToken: string): Promise<boolean> {
return boolApiWrapper('/api/profile/deleteToken', { token, deleteToken });
}
+54
View File
@@ -0,0 +1,54 @@
import { apiWrapper, boolApiWrapper } from './index';
/** A raw todo row as returned by the API (positional from SQL columns) */
export type TodoRow = [
uuid: string,
belongTo: string,
data: string,
lastChange: string,
];
/**
* Fetch all todo items belonging to the current user.
*
* @param token - The auth token
* @returns An array of {@link TodoRow} on success, or `undefined` on failure
*/
export async function getFull(token: string): Promise<TodoRow[] | undefined> {
return apiWrapper<TodoRow[]>('/api/todo/getFull', { token });
}
/**
* Create a new empty todo item.
*
* @param token - The auth token
* @returns A single {@link TodoRow} of the newly created item on success, or `undefined` on failure
*/
export async function add(token: string): Promise<TodoRow | undefined> {
return apiWrapper<TodoRow>('/api/todo/add', { token });
}
/**
* Update an existing todo item's data.
*
* @param token - The auth token
* @param uuid - The todo UUID
* @param data - The new todo data (serialized string)
* @param lastChange - The last known `lastChange` value (optimistic concurrency)
* @returns The new `lastChange` value on success, or `undefined` on failure
*/
export async function update(token: string, uuid: string, data: string, lastChange: string): Promise<string | undefined> {
return apiWrapper<string>('/api/todo/update', { token, uuid, data, lastChange });
}
/**
* Delete a todo item.
*
* @param token - The auth token
* @param uuid - The todo UUID
* @param lastChange - The last known `lastChange` value (optimistic concurrency)
* @returns `true` on success, `false` on failure
*/
export async function del(token: string, uuid: string, lastChange: string): Promise<boolean> {
return boolApiWrapper('/api/todo/delete', { token, uuid, lastChange });
}
+673
View File
@@ -0,0 +1,673 @@
<script lang="ts">
export enum TabType {
Year,
Month,
Day,
Hour,
Minute
}
</script>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import {
MAX_DATETIME,
MIN_DATETIME,
MIN_YEAR,
MAX_YEAR,
MONTH_DAY_COUNT,
dayOfWeek,
isLeapYear
} from '@/utils/datetime';
import { MONTH_NAMES, WEEK_NAMES } from '@/utils/calendar-names';
const DIAL_PLATE_WIDTH: number = 200;
const DIAL_PLATE_RADIUS: number = DIAL_PLATE_WIDTH / 2;
const DIAL_PLATE_HOUR_INNER_PERCENT: number = 0.6;
const DIAL_PLATE_HOUR_OUTTER_PERCENT: number = 0.8;
const DIAL_PLATE_HOUR_DISTINGUISH_PERCENT: number = 0.7;
const DIAL_PLATE_MINUTE_PERCENT: number = 0.8;
const DIAL_PLATE_HOUR_RESOLUTION: number = Math.PI * 2 / 12;
const DIAL_PLATE_MINUTE_RESOLUTION: number = Math.PI * 2 / 60;
const NBSP: string = '\u00A0';
const isVisible = ref(false);
const currentTab = ref<TabType>(TabType.Year);
const mode = ref<TabType>(TabType.Day);
const svgFontScale = ref<number>(1);
let enableHourDrag = false;
let enableMinuteDrag = false;
const internalDateTime = ref<Date>(new Date());
const displayCacheDateTime = ref<Date>(new Date());
const pickerContainerRef = ref<HTMLElement>();
const hourSvgRef = ref<SVGSVGElement>();
const minuteSvgRef = ref<SVGSVGElement>();
// region: Exported API and Signals
const emit = defineEmits<{
(e: 'confirm', date: Date): void
(e: 'cancel'): void
}>();
const clampDateTime = (d: Date): void => {
if (d < MIN_DATETIME) d.setTime(MIN_DATETIME.getTime());
if (d >= MAX_DATETIME) d.setTime(MAX_DATETIME.getTime());
};
const switchTab = (newTab: TabType): void => {
displayCacheDateTime.value = new Date(internalDateTime.value);
currentTab.value = newTab;
};
const modal = (initialDate: Date, _mode: TabType): void => {
mode.value = _mode;
const d = new Date(initialDate);
clampDateTime(d);
internalDateTime.value = new Date(d);
displayCacheDateTime.value = new Date(d);
isVisible.value = true;
switchTab(_mode);
};
const confirm = (): void => {
isVisible.value = false;
emit('confirm', new Date(internalDateTime.value));
};
const cancel = (): void => {
isVisible.value = false;
emit('cancel');
};
defineExpose({ modal });
// endregion
// region: Mutator helpers
// Vue does not track in-place Date mutation, so every change clones, mutates,
// clamps, and reassigns the ref to trigger reactivity.
const updateDisplay = (mutator: (d: Date) => void): void => {
const d = new Date(displayCacheDateTime.value);
mutator(d);
clampDateTime(d);
displayCacheDateTime.value = d;
};
const updateInternal = (mutator: (d: Date) => void): void => {
const d = new Date(internalDateTime.value);
mutator(d);
clampDateTime(d);
internalDateTime.value = d;
};
// endregion
// region: Computed views (replaces legacy imperative RefreshDisplay)
const visibleTabs = computed<Set<TabType>>(() => {
const s = new Set<TabType>();
if (mode.value >= TabType.Year) s.add(TabType.Year);
if (mode.value >= TabType.Month) s.add(TabType.Month);
if (mode.value >= TabType.Day) s.add(TabType.Day);
if (mode.value >= TabType.Hour) s.add(TabType.Hour);
if (mode.value >= TabType.Minute) s.add(TabType.Minute);
return s;
});
const headerParts = computed(() => ({
year: internalDateTime.value.getFullYear(),
month: internalDateTime.value.getMonth() + 1,
day: internalDateTime.value.getDate(),
hour: internalDateTime.value.getHours(),
minute: internalDateTime.value.getMinutes(),
}));
const yearPageStart = computed<number>(() => {
const y = displayCacheDateTime.value.getFullYear();
return Math.floor((y - MIN_YEAR) / 12) * 12 + MIN_YEAR;
});
const yearCells = computed<{ value: number | undefined; picked: boolean }[]>(() => {
const start = yearPageStart.value;
const internalYear = internalDateTime.value.getFullYear();
const cells: { value: number | undefined; picked: boolean }[] = [];
for (let i = 0; i < 12; i++) {
const counter = start + i;
if (counter < MAX_YEAR) {
cells.push({ value: counter, picked: counter === internalYear });
} else {
cells.push({ value: undefined, picked: false });
}
}
return cells;
});
const yearRows = computed<{ value: number | undefined; picked: boolean }[][]>(() => {
const cells = yearCells.value;
const rows: { value: number | undefined; picked: boolean }[][] = [];
for (let i = 0; i < 3; i++) rows.push(cells.slice(i * 4, i * 4 + 4));
return rows;
});
const yearTitle = computed<string>(() => {
const start = yearPageStart.value;
return `${start} - ${Math.min(start + 12, MAX_YEAR)}`;
});
const pickedMonthIndex = computed<number>(() => {
if (internalDateTime.value.getFullYear() === displayCacheDateTime.value.getFullYear()) {
return internalDateTime.value.getMonth();
}
return -1;
});
const monthCells = computed<{ index: number; name: string; picked: boolean }[]>(() => {
const picked = pickedMonthIndex.value;
const cells: { index: number; name: string; picked: boolean }[] = [];
for (let i = 0; i < 12; i++) {
cells.push({ index: i, name: MONTH_NAMES[i]!, picked: i === picked });
}
return cells;
});
const monthRows = computed<{ index: number; name: string; picked: boolean }[][]>(() => {
const cells = monthCells.value;
const rows: { index: number; name: string; picked: boolean }[][] = [];
for (let i = 0; i < 3; i++) rows.push(cells.slice(i * 4, i * 4 + 4));
return rows;
});
const monthTitle = computed<number>(() => displayCacheDateTime.value.getFullYear());
const dayGrid = computed<{ value: number | undefined; picked: boolean }[][]>(() => {
const y = displayCacheDateTime.value.getFullYear();
const m = displayCacheDateTime.value.getMonth() + 1;
const firstDow = dayOfWeek(y, m, 1);
const daysInMonth = MONTH_DAY_COUNT[m - 1]! + (m === 2 && isLeapYear(y) ? 1 : 0);
const internalDay = internalDateTime.value.getDate();
const rows: { value: number | undefined; picked: boolean }[][] = [];
let counter = -firstDow;
for (let i = 0; i < 6; i++) {
const row: { value: number | undefined; picked: boolean }[] = [];
for (let j = 0; j < 7; j++, counter++) {
if (counter < 0 || counter >= daysInMonth) {
row.push({ value: undefined, picked: false });
} else {
const dayNum = counter + 1;
row.push({ value: dayNum, picked: dayNum === internalDay });
}
}
rows.push(row);
}
return rows;
});
const dayTitle = computed<string>(() => {
const y = displayCacheDateTime.value.getFullYear();
const m = displayCacheDateTime.value.getMonth();
return `${y} - ${MONTH_NAMES[m]!}`;
});
const hourHand = computed(() => {
const h = displayCacheDateTime.value.getHours();
const angle = (3 - h) * DIAL_PLATE_HOUR_RESOLUTION;
const radius = DIAL_PLATE_RADIUS * (h < 12 ? DIAL_PLATE_HOUR_OUTTER_PERCENT : DIAL_PLATE_HOUR_INNER_PERCENT);
const x = Math.cos(angle) * radius + DIAL_PLATE_RADIUS;
const y = (-Math.sin(angle) * radius) + DIAL_PLATE_RADIUS;
return { x2: x, y2: y, cx: x, cy: y };
});
const minuteHand = computed(() => {
const mi = displayCacheDateTime.value.getMinutes();
const angle = (15 - mi) * DIAL_PLATE_MINUTE_RESOLUTION;
const radius = DIAL_PLATE_RADIUS * DIAL_PLATE_MINUTE_PERCENT;
const x = Math.cos(angle) * radius + DIAL_PLATE_RADIUS;
const y = (-Math.sin(angle) * radius) + DIAL_PLATE_RADIUS;
return { x2: x, y2: y, cx: x, cy: y };
});
// endregion
// region: Navigation and click handlers
const prevNextYear = (isPrev: boolean): void => {
updateDisplay((d) => d.setFullYear(d.getFullYear() + (isPrev ? -12 : 12)));
};
// NOTE: legacy PrevNextMonth actually shifts the YEAR (the month panel browses years).
const prevNextMonth = (isPrev: boolean): void => {
updateDisplay((d) => d.setFullYear(d.getFullYear() + (isPrev ? -1 : 1)));
};
// NOTE: legacy PrevNextDay actually shifts the MONTH (the day panel browses months).
const prevNextDay = (isPrev: boolean): void => {
updateDisplay((d) => d.setMonth(d.getMonth() + (isPrev ? -1 : 1)));
};
const clickYear = (value: number | undefined): void => {
if (value === undefined) return;
updateInternal((d) => d.setFullYear(value));
if (mode.value !== TabType.Year) switchTab(TabType.Month);
};
const clickMonth = (monthIndex: number): void => {
updateInternal((d) => {
d.setFullYear(displayCacheDateTime.value.getFullYear());
d.setMonth(monthIndex);
});
if (mode.value !== TabType.Month) switchTab(TabType.Day);
};
const clickDay = (day: number | undefined): void => {
if (day === undefined) return;
updateInternal((d) => {
d.setFullYear(displayCacheDateTime.value.getFullYear());
d.setMonth(displayCacheDateTime.value.getMonth());
d.setDate(day);
});
if (mode.value !== TabType.Day) switchTab(TabType.Hour);
};
// endregion
// region: Dial drag interaction
const getUniformedXY = (e: MouseEvent | TouchEvent, el: Element): { x: number; y: number } => {
const rect = el.getBoundingClientRect();
const halfWidth = rect.width / 2;
const halfHeight = rect.height / 2;
const halfSquare = Math.min(rect.width, rect.height) / 2;
let clientX = 0;
let clientY = 0;
const te = e as TouchEvent;
const me = e as MouseEvent;
if (te.targetTouches && te.targetTouches.length >= 1) {
clientX = te.targetTouches[0]!.clientX;
clientY = te.targetTouches[0]!.clientY;
} else {
clientX = me.clientX;
clientY = me.clientY;
}
const x = ((clientX - rect.left - halfWidth) / halfSquare) * DIAL_PLATE_RADIUS;
const y = -(((clientY - rect.top - halfHeight) / halfSquare) * DIAL_PLATE_RADIUS);
return { x, y };
};
const startDragHour = (): void => { enableHourDrag = true; };
const draggingHour = (e: MouseEvent | TouchEvent): void => {
if (!enableHourDrag || !hourSvgRef.value) return;
const { x, y } = getUniformedXY(e, hourSvgRef.value);
const distance = Math.sqrt(x * x + y * y);
let angle = Math.acos(x / distance);
if (y < 0) angle = Math.PI * 2 - angle;
angle += DIAL_PLATE_HOUR_RESOLUTION / 2;
if (angle > Math.PI * 2) angle -= Math.PI * 2;
let number = Math.floor(angle / DIAL_PLATE_HOUR_RESOLUTION);
if (number >= 12) number = 11;
number = (15 - number) % 12;
if (distance < DIAL_PLATE_RADIUS * DIAL_PLATE_HOUR_DISTINGUISH_PERCENT) number += 12;
if (displayCacheDateTime.value.getHours() !== number) {
updateDisplay((d) => d.setHours(number));
}
e.preventDefault();
};
const stopDragHour = (): void => {
enableHourDrag = false;
updateInternal((d) => d.setHours(displayCacheDateTime.value.getHours()));
if (mode.value !== TabType.Hour) switchTab(TabType.Minute);
};
const startDragMinute = (): void => { enableMinuteDrag = true; };
const draggingMinute = (e: MouseEvent | TouchEvent): void => {
if (!enableMinuteDrag || !minuteSvgRef.value) return;
const { x, y } = getUniformedXY(e, minuteSvgRef.value);
const distance = Math.sqrt(x * x + y * y);
let angle = Math.acos(x / distance);
if (y < 0) angle = Math.PI * 2 - angle;
angle += DIAL_PLATE_MINUTE_RESOLUTION / 2;
if (angle > Math.PI * 2) angle -= Math.PI * 2;
let number = Math.floor(angle / DIAL_PLATE_MINUTE_RESOLUTION);
if (number >= 60) number = 59;
number = (75 - number) % 60;
if (displayCacheDateTime.value.getMinutes() !== number) {
updateDisplay((d) => d.setMinutes(number));
}
e.preventDefault();
};
const stopDragMinute = (): void => {
enableMinuteDrag = false;
updateInternal((d) => d.setMinutes(displayCacheDateTime.value.getMinutes()));
};
// endregion
// region: Lifecycle - SVG font-size scaling
let resizeObserver: ResizeObserver | null = null;
onMounted(() => {
resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const w = entry.contentRect.width;
const h = entry.contentRect.height;
if (w > 0 && h > 0) svgFontScale.value = 200 / Math.min(w, h);
}
});
if (pickerContainerRef.value) resizeObserver.observe(pickerContainerRef.value);
});
onUnmounted(() => {
resizeObserver?.disconnect();
resizeObserver = null;
});
// endregion
</script>
<template>
<div class="modal" :class="{ 'is-active': isVisible }"
style="float: left; position: fixed; top: 0; bottom: 0; left: 0; right: 0;">
<div class="modal-background"></div>
<div class="modal-card" style="height: 70%;">
<header class="modal-card-head pickerHeader">
<div v-show="visibleTabs.has(TabType.Year)" @click="switchTab(TabType.Year)">
<small>Year</small><span>{{ headerParts.year }}</span>
</div>
<div v-show="visibleTabs.has(TabType.Month)" @click="switchTab(TabType.Month)">
<small>Month</small><span>{{ headerParts.month }}</span>
</div>
<div v-show="visibleTabs.has(TabType.Day)" @click="switchTab(TabType.Day)">
<small>Day</small><span>{{ headerParts.day }}</span>
</div>
<div v-show="visibleTabs.has(TabType.Hour)" @click="switchTab(TabType.Hour)">
<small>Hour</small><span>{{ headerParts.hour }}</span>
</div>
<div v-show="visibleTabs.has(TabType.Minute)" @click="switchTab(TabType.Minute)">
<small>Minute</small><span>{{ headerParts.minute }}</span>
</div>
</header>
<div class="modal-card-body pickerContainer" ref="pickerContainerRef">
<div v-show="currentTab === TabType.Year">
<nav class="level is-mobile">
<div class="level-left">
<div class="level-item control">
<a class="button" @click="prevNextYear(true)">
<span class="icon is-small"><font-awesome-icon icon="fas fa-chevron-circle-left"></font-awesome-icon></span>
</a>
</div>
</div>
<div class="level-item">{{ yearTitle }}</div>
<div class="level-right">
<div class="level-item control">
<a class="button" @click="prevNextYear(false)">
<span class="icon is-small"><font-awesome-icon icon="fas fa-chevron-circle-right"></font-awesome-icon></span>
</a>
</div>
</div>
</nav>
<div class="perfectTable">
<div v-for="(row, ri) in yearRows" :key="ri">
<div v-for="(cell, ci) in row" :key="ci"
:class="{ picked: cell.picked }"
@click="clickYear(cell.value)">{{ cell.value ?? NBSP }}</div>
</div>
</div>
</div>
<div v-show="currentTab === TabType.Month">
<nav class="level is-mobile">
<div class="level-left">
<div class="level-item control">
<a class="button" @click="prevNextMonth(true)">
<span class="icon is-small"><font-awesome-icon icon="fas fa-chevron-circle-left"></font-awesome-icon></span>
</a>
</div>
</div>
<div class="level-item">{{ monthTitle }}</div>
<div class="level-right">
<div class="level-item control">
<a class="button" @click="prevNextMonth(false)">
<span class="icon is-small"><font-awesome-icon icon="fas fa-chevron-circle-right"></font-awesome-icon></span>
</a>
</div>
</div>
</nav>
<div class="perfectTable">
<div v-for="(row, ri) in monthRows" :key="ri">
<div v-for="(cell, ci) in row" :key="ci"
:class="{ picked: cell.picked }"
@click="clickMonth(cell.index)">{{ cell.name }}</div>
</div>
</div>
</div>
<div v-show="currentTab === TabType.Day">
<nav class="level is-mobile">
<div class="level-left">
<div class="level-item control">
<a class="button" @click="prevNextDay(true)">
<span class="icon is-small"><font-awesome-icon icon="fas fa-chevron-circle-left"></font-awesome-icon></span>
</a>
</div>
</div>
<div class="level-item">{{ dayTitle }}</div>
<div class="level-right">
<div class="level-item control">
<a class="button" @click="prevNextDay(false)">
<span class="icon is-small"><font-awesome-icon icon="fas fa-chevron-circle-right"></font-awesome-icon></span>
</a>
</div>
</div>
</nav>
<div class="perfectTable">
<div>
<div v-for="(name, wi) in WEEK_NAMES" :key="wi">{{ name }}</div>
</div>
<div v-for="(row, ri) in dayGrid" :key="ri">
<div v-for="(cell, ci) in row" :key="ci"
:class="{ picked: cell.picked }"
@click="clickDay(cell.value)">{{ cell.value ?? NBSP }}</div>
</div>
</div>
</div>
<svg v-show="currentTab === TabType.Hour" ref="hourSvgRef"
xmlns="http://www.w3.org/2000/svg" version="1.1"
preserveAspectRatio="xMidYMid" viewBox="0 0 200 200"
:style="{ fontSize: svgFontScale + 'em' }"
@mousedown="startDragHour" @mousemove="draggingHour" @mouseup="stopDragHour"
@touchstart="startDragHour" @touchmove="draggingHour" @touchend="stopDragHour">
<circle cx="100.000000" cy="100.000000" r="100.000000" type="background"></circle>
<line x1="100" y1="100" :x2="hourHand.x2" :y2="hourHand.y2"></line>
<circle :cx="hourHand.cx" :cy="hourHand.cy" r="1em" type="symbol"></circle>
<text x="100.000000" y="20.000000">0</text>
<text x="140.000000" y="30.717968">1</text>
<text x="169.282032" y="60.000000">2</text>
<text x="180.000000" y="100.000000">3</text>
<text x="169.282032" y="140.000000">4</text>
<text x="140.000000" y="169.282032">5</text>
<text x="100.000000" y="180.000000">6</text>
<text x="60.000000" y="169.282032">7</text>
<text x="30.717968" y="140.000000">8</text>
<text x="20.000000" y="100.000000">9</text>
<text x="30.717968" y="60.000000">10</text>
<text x="60.000000" y="30.717968">11</text>
<text x="100.000000" y="40.000000">12</text>
<text x="130.000000" y="48.038476">13</text>
<text x="151.961524" y="70.000000">14</text>
<text x="160.000000" y="100.000000">15</text>
<text x="151.961524" y="130.000000">16</text>
<text x="130.000000" y="151.961524">17</text>
<text x="100.000000" y="160.000000">18</text>
<text x="70.000000" y="151.961524">19</text>
<text x="48.038476" y="130.000000">20</text>
<text x="40.000000" y="100.000000">21</text>
<text x="48.038476" y="70.000000">22</text>
<text x="70.000000" y="48.038476">23</text>
</svg>
<svg v-show="currentTab === TabType.Minute" ref="minuteSvgRef"
xmlns="http://www.w3.org/2000/svg" version="1.1"
preserveAspectRatio="xMidYMid" viewBox="0 0 200 200"
:style="{ fontSize: svgFontScale + 'em' }"
@mousedown="startDragMinute" @mousemove="draggingMinute" @mouseup="stopDragMinute"
@touchstart="startDragMinute" @touchmove="draggingMinute" @touchend="stopDragMinute">
<circle cx="100.000000" cy="100.000000" r="100.000000" type="background"></circle>
<line x1="100" y1="100" :x2="minuteHand.x2" :y2="minuteHand.y2"></line>
<circle :cx="minuteHand.cx" :cy="minuteHand.cy" r="1em" type="symbol"></circle>
<text x="100.000000" y="20.000000">0</text>
<text x="140.000000" y="30.717968">5</text>
<text x="169.282032" y="60.000000">10</text>
<text x="180.000000" y="100.000000">15</text>
<text x="169.282032" y="140.000000">20</text>
<text x="140.000000" y="169.282032">25</text>
<text x="100.000000" y="180.000000">30</text>
<text x="60.000000" y="169.282032">35</text>
<text x="30.717968" y="140.000000">40</text>
<text x="20.000000" y="100.000000">45</text>
<text x="30.717968" y="60.000000">50</text>
<text x="60.000000" y="30.717968">55</text>
</svg>
</div>
<footer class="modal-card-foot">
<a class="button is-success" @click="confirm">
<span>OK</span>
</a>
<a class="button" @click="cancel">
<span>Cancel</span>
</a>
</footer>
</div>
</div>
</template>
<style scoped>
div.perfectTable {
display: flex;
flex-flow: column;
}
/*
div.perfectTable > div > div:nth-child(1) {
border-left: 1px solid black;
}
div.perfectTable > div:nth-child(1) > div {
border-top: 1px solid black;
}
*/
div.perfectTable>div>div {
/*border-top: 0 solid black;
border-left: 0 solid black;
border-right: 1px solid black;
border-bottom: 1px solid black;
padding: 0.75em;*/
flex-grow: 1;
flex-basis: 0;
flex-shrink: 0;
overflow: hidden;
display: flex;
justify-content: center;
padding-top: 1rem;
padding-bottom: 1rem;
}
div.perfectTable>div {
display: flex;
flex-flow: row;
}
div.perfectTable>div>div.picked {
background: hsl(171, 100%, 41%);
color: #fff;
}
div.pickerContainer {
display: flex;
align-items: center;
flex-flow: row;
justify-content: center;
}
div.pickerContainer>div {
flex-grow: 1;
flex-shrink: 1;
flex-basis: 0;
}
div.pickerContainer>svg {
width: 100%;
height: 100%;
}
div.pickerContainer>svg>text {
dominant-baseline: middle;
text-anchor: middle;
user-select: none;
cursor: default;
}
div.pickerContainer>svg>circle[type=background] {
stroke-width: 0;
fill: #d0d0d0;
}
div.pickerContainer>svg>circle[type=symbol] {
stroke-width: 0;
fill: hsl(171, 100%, 41%);
/* $primary */
}
div.pickerContainer>svg>line {
stroke-width: 0.125em;
stroke: hsl(171, 100%, 41%);
/* $primary */
}
header.pickerHeader {
display: flex;
flex-flow: row;
flex-grow: 0;
flex-basis: 0;
flex-shrink: 0;
justify-content: space-between;
}
header.pickerHeader>div {
display: flex;
flex-flow: column;
flex-grow: 1;
flex-basis: 0;
flex-shrink: 0;
}
</style>
+51
View File
@@ -0,0 +1,51 @@
<script setup lang="ts">
import { ref } from 'vue';
const isVisible = ref(false);
const title = ref<string>("");
const content = ref<string>("");
const emit = defineEmits<{
(e: 'closed', isOk: boolean): void
}>()
const show = (_content: string, _title?: string) => {
title.value = _title ?? "Notification";
content.value = _content;
isVisible.value = true;
}
const close = () => {
isVisible.value = false;
emit('closed', false);
}
const ok = () => {
isVisible.value = false;
emit('closed', true);
}
defineExpose({
show
})
</script>
<template>
<div class="modal" :class="{ 'is-active': isVisible }" style="float: left; position: fixed; top: 0; bottom: 0; left: 0; right: 0;">
<div class="modal-background"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">{{ title }}</p>
<button class="delete" aria-label="close" @click="close"></button>
</header>
<div class="modal-card-body">
<p>{{ content }}</p>
</div>
<footer class="modal-card-foot">
<button class="button is-success" @click="ok">OK</button>
</footer>
</div>
</div>
</template>
<style scoped></style>
@@ -0,0 +1,310 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import DateTimePicker, { TabType } from '@/components/DateTimePicker.vue';
import { resolveLoopRules4UI, getDayInMonth } from '@/utils/datetime';
import { WEEK_NAMES } from '@/utils/calendar-names';
import { format } from '@/utils/utils';
type LoopMethod = 'never' | 'day' | 'week' | 'month' | 'year';
type StopMethod = 'forever' | 'datetime' | 'times';
const props = defineProps<{
modelValue: string;
startDate: Date;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>();
const stopPicker = ref<InstanceType<typeof DateTimePicker>>();
const loopMethod = ref<LoopMethod>('never');
const daySpan = ref<number>(1);
const weekSpan = ref<number>(1);
const monthSpan = ref<number>(1);
const yearSpan = ref<number>(1);
// default: today's weekday checked (Monday-first 0-6)
const initialWeekday = (new Date().getDay() + 6) % 7;
const weekChecks = ref<boolean[]>(Array.from({ length: 7 }, (_, i) => i === initialWeekday));
const monthMode = ref<'A' | 'B' | 'C' | 'D'>('A');
const isStrict = ref<boolean>(true);
const stopMethod = ref<StopMethod>('forever');
const stopTimes = ref<number>(1);
const stopDateTime = ref<Date>(new Date());
// region: Serialize / parse
const buildString = (): string => {
let loopPart = '';
switch (loopMethod.value) {
case 'never':
return '';
case 'day':
loopPart = `D${daySpan.value}`;
break;
case 'week': {
const checks = weekChecks.value.map(b => b ? 'T' : 'F').join('');
loopPart = `W${checks}${weekSpan.value}`;
break;
}
case 'month':
loopPart = `M${isStrict.value ? 'S' : 'R'}${monthMode.value}${monthSpan.value}`;
break;
case 'year':
loopPart = `Y${isStrict.value ? 'S' : 'R'}${yearSpan.value}`;
break;
}
let stopPart = '';
switch (stopMethod.value) {
case 'forever':
stopPart = '-F';
break;
case 'datetime':
stopPart = `-D${Math.floor(stopDateTime.value.getTime() / 60000)}`;
break;
case 'times':
stopPart = `-T${stopTimes.value}`;
break;
}
return loopPart + stopPart;
};
const serialized = computed<string>(() => buildString());
// Cast helpers: the parsed rule tuples are a union; index access is loose here.
const parseFromModel = (val: string): void => {
if (val === serialized.value) return;
if (val === '') {
loopMethod.value = 'never';
return;
}
const parsed = resolveLoopRules4UI(val);
if (typeof parsed === 'undefined') {
loopMethod.value = 'never';
return;
}
const loopRule = parsed[0];
const stopRule = parsed[1];
switch (loopRule[0]) {
case 0:
loopMethod.value = 'year';
isStrict.value = loopRule[1];
yearSpan.value = loopRule[2];
break;
case 1:
loopMethod.value = 'month';
isStrict.value = loopRule[1];
monthMode.value = loopRule[2];
monthSpan.value = loopRule[3];
break;
case 2:
loopMethod.value = 'week';
weekChecks.value = [loopRule[1], loopRule[2], loopRule[3], loopRule[4], loopRule[5], loopRule[6], loopRule[7]];
weekSpan.value = loopRule[8];
break;
case 3:
loopMethod.value = 'day';
daySpan.value = loopRule[1];
break;
}
switch (stopRule[0]) {
case 0:
stopMethod.value = 'forever';
break;
case 1:
stopMethod.value = 'datetime';
// NOTE: treated as a plain UTC instant (day-precision stop), deviating
// from the legacy timezone-shifted display. See migration notes.
stopDateTime.value = new Date(stopRule[1] * 60000);
break;
case 2:
stopMethod.value = 'times';
stopTimes.value = stopRule[1];
break;
}
};
watch(() => props.modelValue, (val) => parseFromModel(val), { immediate: true });
watch(serialized, (val) => {
if (val !== props.modelValue) emit('update:modelValue', val);
});
// endregion
const showLoopStop = computed(() => loopMethod.value !== 'never');
const showStrictMode = computed(() => loopMethod.value === 'month' || loopMethod.value === 'year');
const monthOptionTexts = computed(() => {
const d = getDayInMonth(
props.startDate.getFullYear(),
props.startDate.getMonth() + 1,
props.startDate.getDate()
);
return {
A: format('Day {0} in month', d[0]),
B: format('Day {0} from the end of the month', d[1]),
C: format('Day {1} in week {0}', d[2], d[3] + 1),
D: format('Day {1} in week {0} from the end of the month', d[4], d[5] + 1),
};
});
const stopDateTimeText = computed(() => stopDateTime.value.toLocaleDateString());
const openStopPicker = () => {
stopPicker.value?.modal(stopDateTime.value, TabType.Day);
};
const onStopPickerConfirm = (date: Date) => {
stopDateTime.value = date;
};
</script>
<template>
<section class="section">
<h2 class="subtitle">Event Loop</h2>
<div class="button-list">
<label class="radio">
<input type="radio" value="never" v-model="loopMethod">
<span>Never</span>
</label>
<label class="radio">
<input type="radio" value="day" v-model="loopMethod">
<span>Day</span>
</label>
<label class="radio">
<input type="radio" value="week" v-model="loopMethod">
<span>Week</span>
</label>
<label class="radio">
<input type="radio" value="month" v-model="loopMethod">
<span>Month</span>
</label>
<label class="radio">
<input type="radio" value="year" v-model="loopMethod">
<span>Year</span>
</label>
</div>
<div v-if="loopMethod === 'day'">
<div class="field">
<label class="label">Day span</label>
<div class="control">
<input v-model.number="daySpan" class="input spanpicker" type="number" min="1" max="100" step="1">
</div>
</div>
</div>
<div v-if="loopMethod === 'week'">
<div class="field">
<label class="label">Week span</label>
<div class="control">
<input v-model.number="weekSpan" class="input spanpicker" type="number" min="1" max="100" step="1">
</div>
</div>
<div class="field">
<label class="label">Week options</label>
<div class="button-list">
<label v-for="(name, i) in WEEK_NAMES" :key="i" class="checkbox">
<input type="checkbox" v-model="weekChecks[i]">
<span>{{ name }}</span>
</label>
</div>
</div>
</div>
<div v-if="loopMethod === 'month'">
<div class="field">
<label class="label">Month span</label>
<div class="control">
<input v-model.number="monthSpan" class="input spanpicker" type="number" min="1" max="100" step="1">
</div>
</div>
<div class="field">
<label class="label">Month mode</label>
<div class="button-list">
<label class="radio">
<input type="radio" value="A" v-model="monthMode">
<span>{{ monthOptionTexts.A }}</span>
</label>
<label class="radio">
<input type="radio" value="B" v-model="monthMode">
<span>{{ monthOptionTexts.B }}</span>
</label>
<label class="radio">
<input type="radio" value="C" v-model="monthMode">
<span>{{ monthOptionTexts.C }}</span>
</label>
<label class="radio">
<input type="radio" value="D" v-model="monthMode">
<span>{{ monthOptionTexts.D }}</span>
</label>
</div>
</div>
</div>
<div v-if="loopMethod === 'year'">
<div class="field">
<label class="label">Year span</label>
<div class="control">
<input v-model.number="yearSpan" class="input spanpicker" type="number" min="1" max="100" step="1">
</div>
</div>
</div>
</section>
<section v-if="showStrictMode" class="section">
<h2 class="subtitle">Strict Mode in Event Loop</h2>
<p>You can choose strict mode or rough mode in following content. This is only effect on looped event.</p>
<div class="button-list">
<label class="radio">
<input type="radio" :value="true" v-model="isStrict">
<span>Strict Mode. If ordered day is not existing, skip it.</span>
</label>
<label class="radio">
<input type="radio" :value="false" v-model="isStrict">
<span>Rough mode. If ordered day is not existing, choose the day closing with original day to arrange event.</span>
</label>
</div>
</section>
<section v-if="showLoopStop" class="section">
<h2 class="subtitle">Event Loop Stop</h2>
<div class="button-list">
<label class="radio">
<input type="radio" value="forever" v-model="stopMethod">
<span>Forever</span>
</label>
<label class="radio">
<input type="radio" value="datetime" v-model="stopMethod">
<span>Date Time</span>
</label>
<label class="radio">
<input type="radio" value="times" v-model="stopMethod">
<span>Times</span>
</label>
</div>
<div v-if="stopMethod === 'datetime'">
<a class="button" @click="openStopPicker">
<span>{{ stopDateTimeText }}</span>
</a>
</div>
<div v-if="stopMethod === 'times'">
<div class="field">
<div class="control">
<input v-model.number="stopTimes" class="input spanpicker" type="number" min="1" max="100" step="1">
</div>
</div>
</div>
</section>
<DateTimePicker ref="stopPicker" @confirm="onStopPickerConfirm" />
</template>
<style scoped>
.section {
border-top: 1px solid rgba(219, 219, 219, .5);
padding-top: 1.25rem;
margin-top: 1.25rem;
}
</style>
@@ -0,0 +1,110 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { DisplayDay } from './types';
import { format } from '@/utils/utils';
const props = defineProps<{
cells: DisplayDay[];
weekNames: string[];
}>();
const emit = defineEmits<{
(e: 'event-click', uuid: string): void
}>();
const rows = computed<DisplayDay[][]>(() => {
const r: DisplayDay[][] = [];
for (let i = 0; i < 6; i++) r.push(props.cells.slice(i * 7, i * 7 + 7));
return r;
});
const overflowText = (count: number): string => {
return format('{0} items', count.toString());
};
</script>
<template>
<div class="calendar-grid">
<div class="calendar-grid-header">
<div v-for="(name, i) in weekNames" :key="i" :class="{ weekend: i >= 5 }"><b>{{ name }}</b></div>
</div>
<div v-for="(row, ri) in rows" :key="ri" class="calendar-grid-row">
<div v-for="(cell, ci) in row" :key="ci" class="calendar-grid-cell"
:class="{ 'not-current-month': !cell.isCurrentMonth }">
<p class="cell-title">
<b>{{ cell.day }}</b>
<span>{{ cell.subcalendar }}</span>
</p>
<div v-for="(e, ei) in cell.events.slice(0, 4)" :key="ei" class="event-bar"
:style="{ background: e.color }" @click="emit('event-click', e.uuid)"></div>
<p v-if="cell.events.length > 4" class="cell-overflow">{{ overflowText(cell.events.length) }}</p>
</div>
</div>
</div>
</template>
<style scoped>
.calendar-grid {
display: flex;
flex-flow: column;
}
.calendar-grid-header,
.calendar-grid-row {
display: flex;
flex-flow: row;
}
.calendar-grid-cell {
flex-grow: 1;
flex-basis: 0;
flex-shrink: 0;
border-top: 1px solid black;
border-left: 1px solid black;
border-right: 1px solid black;
border-bottom: 1px solid black;
padding: 0.75em;
display: flex;
flex-flow: column;
align-items: flex-start;
overflow: hidden;
}
.calendar-grid-cell.not-current-month {
background: #d0d0d0;
}
/* remove the double border between adjacent cells */
.calendar-grid-row .calendar-grid-cell:nth-child(n+2) {
border-left: 0;
}
.calendar-grid-row:nth-child(n+2) .calendar-grid-cell {
border-top: 0;
}
.cell-title {
margin-bottom: 0.25rem;
}
.cell-overflow {
margin-top: 0.2rem;
}
.event-bar {
border: 1px solid black;
border-radius: 2px;
margin-top: 0.2rem;
height: 0.75rem;
width: 100%;
cursor: pointer;
}
.weekend {
color: red;
}
</style>
@@ -0,0 +1,36 @@
<script setup lang="ts">
defineProps<{
name: string;
subtitle?: string;
isShow: boolean;
}>();
const emit = defineEmits<{
(e: 'toggle'): void
}>();
</script>
<template>
<div class="paperbox-item card">
<div class="paperbox-item-words">
<p v-if="subtitle === undefined"><b>{{ name }}</b></p>
<div v-else>
<b>{{ name }}</b>
<p>
<span>Shared by: </span>
<span>{{ subtitle }}</span>
</p>
</div>
</div>
<div class="paperbox-item-icon control" @click="emit('toggle')">
<a class="button">
<span class="icon is-small">
<font-awesome-icon :icon="isShow ? 'fas fa-eye' : 'fas fa-eye-slash'"></font-awesome-icon>
</span>
</a>
</div>
</div>
</template>
<style scoped></style>
@@ -0,0 +1,112 @@
<script setup lang="ts">
import type { DisplayDay } from './types';
import { MONTH_NAMES, WEEK_NAMES } from '@/utils/calendar-names';
defineProps<{
days: DisplayDay[];
}>();
const emit = defineEmits<{
(e: 'event-click', uuid: string): void
}>();
</script>
<template>
<div class="schedule-list">
<div v-for="(day, di) in days" :key="di" class="schedule-day container">
<div class="schedule-day-words">
<b>{{ MONTH_NAMES[day.month - 1] }}</b>
<b>{{ day.day }}</b>
<b>{{ WEEK_NAMES[day.dayOfWeek - 1] }}</b>
</div>
<div class="schedule-event-list">
<template v-for="(ev, ei) in day.events" :key="ei">
<div v-if="ev.isVisible" class="schedule-event-outter card" @click="emit('event-click', ev.uuid)">
<div class="schedule-event-color" :style="{ background: ev.color }"></div>
<div class="schedule-event-inner">
<div class="schedule-event-words">
<p class="level-item"><b>{{ ev.title }}</b></p>
<p class="level-item">{{ ev.description }}</p>
<p class="level-item"><span>{{ ev.start }}</span>-<span>{{ ev.end }}</span></p>
<p v-if="ev.loopText !== ''">
<span class="icon is-small"><font-awesome-icon icon="fas fa-retweet"></font-awesome-icon></span>
<span>{{ ev.loopText }}</span>
</p>
</div>
<div class="schedule-event-icon">
<span v-if="ev.isLocked" class="icon is-small">
<font-awesome-icon icon="fas fa-lock"></font-awesome-icon>
</span>
<span v-if="ev.timezoneWarning" class="icon is-small">
<font-awesome-icon icon="fas fa-globe"></font-awesome-icon>
</span>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
</template>
<style scoped>
.schedule-day {
display: flex;
flex-flow: column;
}
.schedule-day-words {
margin-top: 0.75rem;
margin-bottom: 0.75rem;
}
.schedule-day-words b {
margin-right: 0.5rem;
}
.schedule-event-list {
display: flex;
flex-flow: column;
}
.schedule-event-outter {
display: flex;
flex-flow: row;
align-items: flex-start;
margin-bottom: 1.25rem;
cursor: pointer;
}
.schedule-event-inner {
display: flex;
flex-flow: row;
align-items: flex-start;
padding: 1.25rem;
}
.schedule-event-words {
display: flex;
flex-flow: column;
align-items: flex-start;
flex-grow: 1;
flex-basis: 0;
word-break: break-all;
}
.schedule-event-icon {
margin-left: 0.75rem;
}
.schedule-event-color {
width: 0.75rem;
height: 100%;
min-height: 2rem;
}
.schedule-list .schedule-day:nth-child(n+2) {
border-top: 1px solid rgba(219, 219, 219, .5);
}
</style>
+26
View File
@@ -0,0 +1,26 @@
/** A single analyzed event occurrence placed into a calendar day cell. */
export interface DisplayEvent {
uuid: string;
belongTo: string;
title: string;
description: string;
color: string;
isVisible: boolean;
isLocked: boolean;
loopText: string;
timezoneWarning: boolean;
start: string;
end: string;
}
/** A single day cell in the 6x7 calendar grid, carrying its events. */
export interface DisplayDay {
/** 1-12 */
month: number;
day: number;
/** 1-7, Monday = 1 */
dayOfWeek: number;
isCurrentMonth: boolean;
subcalendar: string;
events: DisplayEvent[];
}
@@ -0,0 +1,83 @@
<script setup lang="ts">
import { ref } from 'vue';
const props = defineProps({
uuid: {
type: String,
required: true
},
name: {
type: String,
required: true
}
})
const emit = defineEmits<{
(e: 'delete', uuid: string): void
(e: 'share', uuid: string): void
(e: 'update', uuid: string, name: string): void
}>()
const isEditing = ref<boolean>(false);
const editingName = ref<string>("");
const editItem = () => {
isEditing.value = true;
editingName.value = props.name;
}
const shareItem = () => {
emit('share', props.uuid);
}
const deleteItem = () => {
emit('delete', props.uuid);
}
const updateItem = () => {
const new_name = editingName.value;
editingName.value = "";
isEditing.value = false;
emit('update', props.uuid, new_name);
}
const cancelUpdateItem = () => {
editingName.value = "";
isEditing.value = false;
}
</script>
<template>
<div class="paperbox-item card">
<div class="paperbox-item-words">
<p v-show="!isEditing">{{ name }}</p>
<div v-show="isEditing" class="control">
<input v-model="editingName" class="input" type="text"></input>
</div>
</div>
<div class="paperbox-item-icon control" v-show="!isEditing" @click="editItem">
<a class="button"><span class="icon is-small"><font-awesome-icon icon="fas fa-pen"></font-awesome-icon></span></a>
</div>
<div class="paperbox-item-icon control" v-show="!isEditing" @click="shareItem">
<a class="button"><span class="icon is-small"><font-awesome-icon
icon="fas fa-share"></font-awesome-icon></span></a>
</div>
<div class="paperbox-item-icon control" v-show="!isEditing" @click="deleteItem">
<a class="button"><span class="icon is-small"><font-awesome-icon
icon="fas fa-trash"></font-awesome-icon></span></a>
</div>
<div class="paperbox-item-icon control" v-show="isEditing" @click="updateItem">
<button class="button"><span class="icon is-small"><font-awesome-icon
icon="fas fa-check"></font-awesome-icon></span></button>
</div>
<div class="paperbox-item-icon control" v-show="isEditing" @click="cancelUpdateItem">
<button class="button"><span class="icon is-small"><font-awesome-icon
icon="fas fa-times"></font-awesome-icon></span></button>
</div>
</div>
</template>
<style scoped></style>
@@ -0,0 +1,32 @@
<script setup lang="ts">
const props = defineProps({
username: {
type: String,
required: true
}
})
const emit = defineEmits<{
(e: 'delete', username: string): void
}>()
const deleteItem = () => {
emit('delete', props.username);
};
</script>
<template>
<div class="paperbox-item card">
<div class="paperbox-item-words">
<p>{{ username }}</p>
</div>
<div class="paperbox-item-icon control" @click="deleteItem">
<a class="button"><span class="icon is-small"><font-awesome-icon icon="fas fa-trash"></font-awesome-icon></span></a>
</div>
</div>
</template>
<style scoped></style>
+25
View File
@@ -0,0 +1,25 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { library } from '@fortawesome/fontawesome-svg-core'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { faUser, faLock, faPen, faShare, faTrash, faCheck, faTimes, faPlus, faSync, faChevronCircleLeft, faChevronCircleRight, faEye, faEyeSlash, faRetweet, faGlobe } from '@fortawesome/free-solid-svg-icons'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import App from './App.vue'
import router from './router'
import '../public/index.scss'
const pinia = createPinia();
pinia.use(piniaPluginPersistedstate);
const app = createApp(App);
app.use(pinia);
app.use(router);
library.add(faUser, faLock, faPen, faShare, faTrash, faCheck, faTimes, faPlus, faSync, faChevronCircleLeft, faChevronCircleRight, faEye, faEyeSlash, faRetweet, faGlobe);
app.component('font-awesome-icon', FontAwesomeIcon);
app.mount('#app');
+60
View File
@@ -0,0 +1,60 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useTokenStore } from '@/stores/token'
import Home from '@/views/Home.vue'
import Collection from '@/views/Collection.vue'
import Calendar from '@/views/Calendar.vue'
import CalendarEvent from '@/views/CalendarEvent.vue'
import Todo from '@/views/Todo.vue'
import Admin from '@/views/Admin.vue'
import Login from '@/views/Login.vue'
import NotFound from '@/views/NotFound.vue'
const routes = [
{ path: '/home', name: "Home", component: Home },
{ path: '/collection', name: "Collection", meta: { requireLoggedInCheck: true }, component: Collection },
{ path: '/calendar', name: "Calendar", meta: { requireLoggedInCheck: true }, component: Calendar },
{ path: '/todo', name: "Todo", meta: { requireLoggedInCheck: true }, component: Todo },
{ path: '/admin', name: "Admin", meta: { requireLoggedInCheck: true }, component: Admin },
{ path: '/calendar/event', name: "CalendarEventAdd", meta: { requireLoggedInCheck: true }, component: CalendarEvent },
{ path: '/calendar/event/:uuid', name: "CalendarEventUpdate", meta: { requireLoggedInCheck: true }, component: CalendarEvent },
{ path: '/login', name: "Login", meta: { requireLoggedOutCheck: true }, component: Login },
{ path: '/404', name: "NotFound", component: NotFound },
{ path: '/', name: "Default", redirect: '/home' },
{ path: '/:pathMatch(.*)*', redirect: '/404' },
];
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: routes,
});
router.beforeEach((to, from) => {
// Only check for those flagged.
const token = useTokenStore();
if (to.meta.requireLoggedInCheck) {
if (!token.isLoggedIn) {
return { name: 'Default', replace: true };
}
} else if (to.meta.requireLoggedOutCheck) {
if (token.isLoggedIn) {
return { name: 'Default', replace: true };
}
} else {
return true;
}
})
export const goToHome = () => {
router.push({ name: 'Home' })
}
export const goToCalendar = () => {
router.push({ name: 'Calendar' })
}
export default router
+35
View File
@@ -0,0 +1,35 @@
import { defineStore } from 'pinia'
import { Language } from '@/utils/i18n'
interface LanguageState {
language: Language
}
export const useLanguageStore = defineStore('language', {
state: (): LanguageState => ({
language: Language.English
}),
getters: {
isEnglish: (state) => state.language === Language.English,
isSimplifiedChinese: (state) => state.language === Language.SimplifiedChinese,
},
actions: {
changeLanguage(lang: Language) {
this.language = lang;
},
changeToEnglish() {
this.changeLanguage(Language.English);
},
changeToSimplifiedChinese() {
this.changeLanguage(Language.SimplifiedChinese);
},
},
persist: {
key: 'ccn-i18n',
storage: localStorage,
pick: ['language'],
},
})
+31
View File
@@ -0,0 +1,31 @@
import { defineStore } from 'pinia'
interface TokenState {
token: string | null
}
export const useTokenStore = defineStore('token', {
state: (): TokenState => ({
token: null,
}),
getters: {
isLoggedIn: (state) => typeof state.token === 'string',
currentToken: (state) => state.token as string,
},
actions: {
login(token: string) {
this.token = token;
},
logout() {
this.token = null;
},
},
persist: {
key: 'ccn-token',
storage: localStorage,
pick: ['token'],
},
})
+8
View File
@@ -0,0 +1,8 @@
export const MONTH_NAMES: string[] = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
];
export const WEEK_NAMES: string[] = [
'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'
];
+570
View File
@@ -0,0 +1,570 @@
import { lcm, format } from "./utils";
import { universalGetDayOfWeek } from "./i18n";
// YYC MARK:
// This file is synchronized with "dt.py".
// If this file or dt.py have bugs, all code should be changed together.
export const MONTH_DAY_COUNT: number[] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
export const MIN_YEAR: number = 1950;
export const MAX_YEAR: number = 2200;
export const MIN_DATETIME = new Date(Date.UTC(MIN_YEAR, 0, 1, 0, 0, 0, 0));
export const MAX_DATETIME = new Date(Date.UTC(MAX_YEAR, 0, 1, 0, 0, 0, 0));
export const MIN_TIMESTAMP = Math.floor(MIN_DATETIME.getTime() / 60000);
export const MAX_TIMESTAMP = Math.floor(MAX_DATETIME.getTime() / 60000);
export const DAY1_SPAN: number = 60 * 24;
export const DAY7_SPAN: number = 7 * DAY1_SPAN;
const PRECOMPILED_LOOP_RULES = {
year: new RegExp(/^Y([SR]{1})([1-9]\d*)$/),
month: new RegExp(/^M([SR]{1})([ABCD]{1})([1-9]\d*)$/),
week: new RegExp(/^W([TF]{7})([1-9]\d*)$/),
day: new RegExp(/^D([1-9]\d*)$/)
};
const PRECOMPILED_LOOP_STOP_RULES = {
infinity: new RegExp(/^F$/),
datetime: new RegExp(/^D([1-9]\d*|0)$/),
times: new RegExp(/^T([1-9]\d*)$/)
};
// region: Core Functions
/** Year loop: [type=0, isStrict, yearSpan] */
type YearLoopRule = [0, boolean, number];
/** Month loop: [type=1, isStrict, mode, monthSpan] */
type MonthLoopRule = [1, boolean, 'A' | 'B' | 'C' | 'D', number];
/** Week loop: [type=2, 7 weekday booleans, weekSpan] */
type WeekLoopRule = [2, boolean, boolean, boolean, boolean, boolean, boolean, boolean, number];
/** Day loop: [type=3, daySpan] */
type DayLoopRule = [3, number];
type LoopRule = YearLoopRule | MonthLoopRule | WeekLoopRule | DayLoopRule;
/** Infinity stop: [type=0] */
type InfinityStopRule = [0];
/** Datetime stop: [type=1, timestamp] */
type DatetimeStopRule = [1, number];
/** Times stop: [type=2, times] */
type TimesStopRule = [2, number];
type LoopStopRule = InfinityStopRule | DatetimeStopRule | TimesStopRule;
/**
* Parses a raw loop rule string (e.g. "YS2-F") into structured rule arrays
* for UI display purposes.
* @param strl - The loop rule string in format "loopRule-stopRule".
* @returns A tuple of [LoopRule, LoopStopRule] on success, or undefined
* when the string is empty, malformed, or contains no valid rule.
*/
export function resolveLoopRules4UI(strl: string): [LoopRule, LoopStopRule] | undefined {
if (strl == '') return undefined;
const sp = strl.split('-');
if (sp.length != 2) return undefined;
let loopRules: LoopRule | undefined = undefined;
let loopStopRules: LoopStopRule | undefined = undefined;
let match: RegExpExecArray | null;
if ((match = PRECOMPILED_LOOP_RULES.year.exec(sp[0]!)) !== null) {
loopRules = [0, match[1]! == 'S', parseInt(match[2]!)];
} else if ((match = PRECOMPILED_LOOP_RULES.month.exec(sp[0]!)) !== null) {
loopRules = [1, match[1]! == 'S', match[2]! as 'A' | 'B' | 'C' | 'D', parseInt(match[3]!)];
} else if ((match = PRECOMPILED_LOOP_RULES.week.exec(sp[0]!)) !== null) {
const w = match[1]!;
loopRules = [2, w[0] == 'T', w[1] == 'T', w[2] == 'T', w[3] == 'T', w[4] == 'T', w[5] == 'T', w[6] == 'T', parseInt(match[2]!)] as WeekLoopRule;
} else if ((match = PRECOMPILED_LOOP_RULES.day.exec(sp[0]!)) !== null) {
loopRules = [3, parseInt(match[1]!)];
} else return undefined;
if ((match = PRECOMPILED_LOOP_STOP_RULES.infinity.exec(sp[1]!)) !== null) {
loopStopRules = [0];
} else if ((match = PRECOMPILED_LOOP_STOP_RULES.datetime.exec(sp[1]!)) !== null) {
loopStopRules = [1, parseInt(match[1]!)];
} else if ((match = PRECOMPILED_LOOP_STOP_RULES.times.exec(sp[1]!)) !== null) {
loopStopRules = [2, parseInt(match[1]!)];
} else return undefined;
return [loopRules, loopStopRules];
}
/** A pair of [start, end] timestamps (in minutes) representing a single event occurrence. */
type TimeRange = [number, number];
/**
* Resolves a loop rule string into concrete event date ranges by expanding
* the loop pattern across the detection window.
* @remark loopDateTimeStart's value does not correspond with the database;
* it is calculated by the program and should point to the closet
* potential event start datetime.
* @remark loopDateTimeEnd is performed like loopDateTimeStart and
* it indicates the possible time point of the tail of legal event.
* @remark clampStartDateTime is the real clamp datetime of event start
* datetime.
* @remark loopDateTimeStart is the start datetime for detection.
* @remark In this section, all times should be analysed with
* `Date((time + timezoneOffset) * 60000)` and use `.getUTC...()`
* functions.
* @param loopRules - The loop rule string (e.g. "YS2-F").
* @param loopDateTimeStart - Start of the detection window (minutes).
* @param loopDateTimeEnd - End of the detection window (minutes).
* @param eventDateTimeStart - Original event start datetime (minutes).
* @param eventDateTimeEnd - Original event end datetime (minutes).
* @param timezoneOffset - Timezone offset in minutes.
* @param clampStartDateTime - Earliest allowed start datetime (minutes).
* @returns An array of [start, end] timestamp pairs representing every
* occurrence within the window, or undefined if the rule is invalid.
*/
export function resolveLoopRules2Event(
fullLoopRules: string,
loopDateTimeStart: number,
loopDateTimeEnd: number,
eventDateTimeStart: number,
eventDateTimeEnd: number,
timezoneOffset: number,
clampStartDateTime: number): TimeRange[] | undefined {
if (fullLoopRules == '') return [
[Math.max(eventDateTimeStart, clampStartDateTime),
Math.max(loopDateTimeEnd, eventDateTimeEnd)]
];
const sp = fullLoopRules.split('-');
if (sp.length != 2) return undefined;
const loopRules = sp[0]!; // we don't need consider stop flag
const result: TimeRange[] = [];
// compute offset and duration
const eventDateTime = new Date((eventDateTimeStart + timezoneOffset) * 60000);
eventDateTime.setUTCHours(0, 0, 0, 0);
const eventOffset = eventDateTimeStart - (Math.floor(eventDateTime.getTime() / 60000) - timezoneOffset);
const eventDuration = eventDateTimeEnd - eventDateTimeStart;
const detectDateTime = new Date(loopDateTimeStart * 60000);
detectDateTime.setUTCHours(0, 0, 0, 0);
const originalYear = eventDateTime.getUTCFullYear();
const originalMonth = eventDateTime.getUTCMonth() + 1;
const originalDay = eventDateTime.getUTCDate();
// compute event
let match: RegExpExecArray | null;
if ((match = PRECOMPILED_LOOP_RULES.year.exec(loopRules)) !== null) {
const isStrict = match[1]! == 'S';
const loopSpan = parseInt(match[2]!);
const yearCount = detectDateTime.getFullYear() - originalYear;
const isSpecial = (originalMonth == 2 && originalDay == 29);
const realLoopSpan = (isSpecial && isStrict) ? lcm(4, loopSpan) : loopSpan;
//let fullSpanCount = Math.floor(yearCount / realLoopSpan);
const remainYear = yearCount % realLoopSpan;
//detectDateTime.setUTCFullYear(fullSpanCount + detectDateTime.getUTCFullYear(), 1, 1);
if (remainYear != 0)
detectDateTime.setUTCFullYear(realLoopSpan - remainYear + detectDateTime.getUTCFullYear(), 1 - 1, 1);
let skipFlag = false;
while (Math.floor(detectDateTime.getTime() / 60000) + eventOffset - timezoneOffset <= loopDateTimeEnd) {
skipFlag = false;
if (isSpecial) {
// is special day, 29 Feb
// try set it in 29 Feb
if (isStrict) {
if (isLeapYear(detectDateTime.getUTCFullYear())) detectDateTime.setUTCMonth(2 - 1, 29);
else skipFlag = true; // order skip
} else {
if (isLeapYear(detectDateTime.getUTCFullYear())) detectDateTime.setUTCMonth(2 - 1, 29);
else detectDateTime.setUTCMonth(2 - 1, 28);
}
} else detectDateTime.setUTCMonth(originalMonth - 1, originalDay);
if (!skipFlag) {
result.push(
[Math.floor(detectDateTime.getTime() / 60000) + eventOffset - timezoneOffset,
Math.floor(detectDateTime.getTime() / 60000) + eventOffset + eventDuration - timezoneOffset]
);
}
detectDateTime.setUTCFullYear(realLoopSpan + detectDateTime.getUTCFullYear());
}
} else if ((match = PRECOMPILED_LOOP_RULES.month.exec(loopRules)) !== null) {
const isStrict = match[1]! == 'S';
const loopMethod = match[2]!;
const loopSpan = parseInt(match[3]!);
const monthsCountValue = monthsCount(detectDateTime.getUTCFullYear(), detectDateTime.getUTCMonth() + 1) -
monthsCount(originalYear, originalMonth);
//let fullSpanCount = Math.floor(monthsCountValue / loopSpan);
const remainMonth = monthsCountValue % loopSpan;
//detectDateTime.setUTCMonth(fullSpanCount * loopSpan + detectDateTime.getUTCMonth(), 1);
detectDateTime.setUTCDate(1);
if (remainMonth != 0)
detectDateTime.setUTCMonth(loopSpan - remainMonth + detectDateTime.getUTCMonth(), 1);
while (Math.floor(detectDateTime.getTime() / 60000) + eventOffset - timezoneOffset <= loopDateTimeEnd) {
const data = getRemanagedDayInMonth(originalYear, originalMonth, originalDay, detectDateTime.getUTCFullYear(), detectDateTime.getUTCMonth() + 1, isStrict);
let predictedDay: number | undefined = undefined;
switch (loopMethod) {
case 'A':
if (typeof (data[0]) !== 'undefined') predictedDay = data[0];
break;
case 'B':
if (typeof (data[1]) !== 'undefined') predictedDay = data[1];
break;
case 'C':
if (typeof (data[2]) !== 'undefined') predictedDay = data[2];
break;
case 'D':
if (typeof (data[3]) !== 'undefined') predictedDay = data[3];
break;
}
if (typeof (predictedDay) !== 'undefined') {
detectDateTime.setUTCDate(predictedDay);
result.push(
[Math.floor(detectDateTime.getTime() / 60000) + eventOffset - timezoneOffset,
Math.floor(detectDateTime.getTime() / 60000) + eventOffset + eventDuration - timezoneOffset]
);
}
detectDateTime.setUTCMonth(loopSpan + detectDateTime.getUTCMonth(), 1);
}
} else if ((match = PRECOMPILED_LOOP_RULES.week.exec(loopRules)) !== null) {
const loopSpan = parseInt(match[2]!);
const weekOption: boolean[] = [];
let weekEventCount = 0
for (let i = 0; i < 7; i++) {
weekOption.push(match[1]![i] == 'T');
if (match[1]![i] == 'T') weekEventCount++;
}
const originalWeek = dayOfWeek(originalYear, originalMonth, originalDay);
// try insert original event
if (!weekOption[originalWeek]) {
result.push(
[eventDateTimeStart, eventDateTimeEnd]
);
}
const daysCountValue = daysCount(detectDateTime.getUTCFullYear(), detectDateTime.getUTCMonth() + 1, detectDateTime.getDate()) -
daysCount(originalYear, originalMonth, originalDay);
//let fullSpanCount = Math.floor(daysCountValue / (7 * loopSpan));
const remainFullSpanCount = Math.floor((daysCountValue % (7 * loopSpan)) / 7);
const remainDays = (daysCountValue % (7 * loopSpan)) % 7;
//detectDateTime.setUTCDate((7 * loopSpan * fullSpanCount) + detectDateTime.getUTCDate());
if (remainFullSpanCount != 0) {
detectDateTime.setUTCDate((loopSpan - remainFullSpanCount) * 7 + detectDateTime.getUTCDate());
}
let weekCounter = remainDays;
while (Math.floor(detectDateTime.getTime() / 60000) + eventOffset - timezoneOffset <= loopDateTimeEnd) {
if (weekOption[(weekCounter + originalWeek) % 7])
result.push(
[Math.floor(detectDateTime.getTime() / 60000) + eventOffset - timezoneOffset,
Math.floor(detectDateTime.getTime() / 60000) + eventOffset + eventDuration - timezoneOffset]
);
weekCounter = (weekCounter + 1) % 7;
detectDateTime.setUTCDate(detectDateTime.getUTCDate() + 1);
if (weekCounter == 0)
detectDateTime.setUTCDate(detectDateTime.getUTCDate() + (loopSpan - 1) * 7);
}
} else if ((match = PRECOMPILED_LOOP_RULES.day.exec(loopRules)) !== null) {
const loopSpan = parseInt(match[1]!);
const daysCountValue = daysCount(detectDateTime.getUTCFullYear(), detectDateTime.getUTCMonth() + 1, detectDateTime.getUTCDate()) -
daysCount(originalYear, originalMonth, originalDay);
//let fullSpanCount = Math.floor(daysCountValue / loopSpan);
const remainDays = daysCountValue % loopSpan;
//detectDateTime.setUTCDate(fullSpanCount * loopSpan + detectDateTime.getUTCDate());
if (remainDays != 0)
detectDateTime.setUTCDate(loopSpan - remainDays + detectDateTime.getUTCDate());
while (Math.floor(detectDateTime.getTime() / 60000) + eventOffset - timezoneOffset <= loopDateTimeEnd) {
result.push(
[Math.floor(detectDateTime.getTime() / 60000) + eventOffset - timezoneOffset,
Math.floor(detectDateTime.getTime() / 60000) + eventOffset + eventDuration - timezoneOffset]
);
detectDateTime.setUTCDate(detectDateTime.getUTCDate() + loopSpan);
}
} else return undefined;
// clamp item
const realResult: TimeRange[] = [];
for (const i of result) {
const start = i[0];
const end = i[1];
if (end > clampStartDateTime && start <= loopDateTimeEnd)
realResult.push([Math.max(start, clampStartDateTime), Math.min(end, loopDateTimeEnd)]);
}
return realResult;
}
/**
* Resolves a loop rule string into a human-readable i18n-localised text
* description suitable for display in the UI.
* @param strl - The loop rule string in format "loopRule-stopRule".
* @param startDateTime - Reference start datetime (minutes) for computing
* contextual fields (e.g. day-in-month info).
* @param timezoneOffset - Timezone offset in minutes.
* @returns A concatenated human-readable string, or an empty string when
* the input is empty or cannot be parsed.
*/
export function resolveLoopRules4Text(strl: string, startDateTime: number, timezoneOffset: number): string {
if (strl == '') return "";
const sp = strl.split('-');
if (sp.length != 2) return "";
let loopRules: string;
let loopStopRules: string;
const datetimeInstance = new Date((startDateTime + timezoneOffset) * 60000)
let match: RegExpExecArray | null;
if ((match = PRECOMPILED_LOOP_RULES.year.exec(sp[0]!)) !== null) {
if (match[1]! == 'S')
loopRules = "严格模式。";
else
loopRules = "宽松模式。";
loopRules += format("每{0}年于{1}循环一次。",
parseInt(match[2]!), datetimeInstance.toLocaleDateString(undefined, { timeZone: "UTC" })
);
} else if ((match = PRECOMPILED_LOOP_RULES.month.exec(sp[0]!)) !== null) {
if (match[1]! == 'S')
loopRules = "严格模式。";
else
loopRules = "宽松模式。";
const dayInMonth = getDayInMonth(
datetimeInstance.getUTCFullYear(),
datetimeInstance.getUTCMonth() + 1,
datetimeInstance.getUTCDate());
switch (match[2]!) {
case 'A':
loopRules = format("每{0}月的第{1}日循环一次。",
parseInt(match[3]!), dayInMonth[0]
);
break;
case 'B':
loopRules = format("每{0}月的倒数第{1}日循环一次。",
parseInt(match[3]!), dayInMonth[1]
);
break;
case 'C':
loopRules = format("每{0}月的第{1}个星期{2}循环一次。",
parseInt(match[3]!), dayInMonth[2], dayInMonth[3]
);
break;
case 'D':
loopRules = format("每{0}月的倒数第{1}个星期{2}循环一次。",
parseInt(match[3]!), dayInMonth[4], dayInMonth[5]
);
break;
}
} else if ((match = PRECOMPILED_LOOP_RULES.week.exec(sp[0]!)) !== null) {
const weekOfDayCache = [];
for (let i = 0; i < 7; i++) {
if (match[1]![i] == 'T')
weekOfDayCache.push(universalGetDayOfWeek(i));
}
loopRules = format("每{0}周的{1}循环一次。",
parseInt(match[2]!), weekOfDayCache.join(', ')
);
} else if ((match = PRECOMPILED_LOOP_RULES.day.exec(sp[0]!)) !== null) {
loopRules = format("每{0}天循环一次。", parseInt(match[1]!));
} else return "";
if ((match = PRECOMPILED_LOOP_STOP_RULES.infinity.exec(sp[1]!)) !== null) {
loopStopRules = "永远循环。";
} else if ((match = PRECOMPILED_LOOP_STOP_RULES.datetime.exec(sp[1]!)) !== null) {
loopStopRules = format("到{0}停止循环。",
new Date(parseInt(match[1]!)).toLocaleDateString()
);
} else if ((match = PRECOMPILED_LOOP_STOP_RULES.times.exec(sp[1]!)) !== null) {
loopStopRules = format("循环{0}次。", parseInt(match[1]!));
} else return "";
return (loopRules + loopStopRules);
}
// endregion
// region: Utility Functions
function leapYearCountEx(endYear: number, includeThis: boolean, baseYear: number, includeBase: boolean): number {
if (!includeThis) endYear--;
if (includeBase) baseYear--;
let endly = Math.floor(endYear / 4);
endly -= Math.floor(endYear / 100);
endly += Math.floor(endYear / 400);
let basely = Math.floor(baseYear / 4);
basely -= Math.floor(baseYear / 100);
basely += Math.floor(baseYear / 400);
return (endly - basely);
}
function daysCount(year: number, month: number, day: number): number {
const ly = leapYearCountEx(year, false, 1, true);
let days = 365 * (year - 1);
days += ly;
for (let index = 1; index < month; index++)
days += MONTH_DAY_COUNT[index - 1]!;
if (month > 2 && isLeapYear(year))
days += 1;
days += day - 1;
return days;
}
function monthsCount(year: number, month: number): number {
return (year - 1) * 12 + (month - 1);
}
export function dayOfWeek(year: number, month: number, day: number): number {
return daysCount(year, month, day) % 7;
}
type DayInMonthInfo = [
/** The day count to this day, counting from month head to tail. */
daysForward: number,
/** The day count to this day, counting from month tail to head. */
daysBackward: number,
/** The count of the week this day located, counting from month head to tail. */
weeksForward: number,
/** The day count in this week. (for `weeksForward` using) */
weeksForwardDayOfWeek: number,
/** The count of the week this day located, counting from month tail to head. */
weeksBackward: number,
/** The day count in this week. (for `weeksBackward` using) */
weeksBackwardDayOfWeek: number,
];
export function getDayInMonth(year: number, month: number, day: number): DayInMonthInfo {
const days = MONTH_DAY_COUNT[month - 1]! + ((month == 2 && isLeapYear(year)) ? 1 : 0);
const firstDayOfWeek = dayOfWeek(year, month, 1);
const bilateralDayOfWeek = (firstDayOfWeek + day - 1) % 7;
const dayForwards = day;
const dayBackwards = days - day + 1;
const weeksForward = Math.floor((dayForwards - 1) / 7) + 1;
const weeksBackwards = Math.floor((dayBackwards - 1) / 7) + 1;
return [dayForwards, dayBackwards, weeksForward, bilateralDayOfWeek, weeksBackwards, bilateralDayOfWeek];
}
// YYC MARK:
// I use Japanese word youbi (ようび) to present
// the concept of "the day of week" exactly.
type RemanagedDayInMonth = [
/** Count forward by days (Day N) */
forwardByDayCount: number | undefined,
/** Count backward by days (Nth day from the end) */
backwardByDayCount: number | undefined,
/** Count forward by weeks (Week N, Youbi X) */
forwardByWeekCount: number | undefined,
/** Count backward by weeks (Nth week from the end, Youbi X) */
backwardByWeekCount: number | undefined,
];
/**
* Pass in an old date and a new month, and it will calculate
* the new date corresponding to that old date in the new month
* using four different calendar rules:
* @param oldYear
* @param oldMonth
* @param oldDay
* @param newYear
* @param newMonth
* @param isStrict
* @returns
*/
function getRemanagedDayInMonth(oldYear: number, oldMonth: number, oldDay: number, newYear: number, newMonth: number, isStrict: boolean): RemanagedDayInMonth {
const ddata = getDayInMonth(oldYear, oldMonth, oldDay);
const mdata = getMonthWeekStatistics(newYear, newMonth);
const days = MONTH_DAY_COUNT[newMonth - 1]! + ((newMonth == 2 && isLeapYear(newYear)) ? 1 : 0);
const firstDayOfWeek = dayOfWeek(newYear, newMonth, 1);
//let lastDayOfWeek = (firstDayOfWeek + days - 1) % 7;
let methodA = undefined;
let methodB = undefined;
if (isStrict) {
methodA = ddata[0] > days ? undefined : ddata[0];
methodB = ddata[1] > days ? undefined : (days - ddata[1] + 1);
} else {
methodA = Math.min(ddata[0], days);
methodB = days - Math.min(ddata[1], days) + 1;
}
let methodC = undefined;
if (ddata[2] <= mdata[ddata[3]]! || !isStrict) {
const targetWeek = Math.min(ddata[2], mdata[ddata[3]]!);
methodC = 1 + (targetWeek - 1) * 7 + ((ddata[3] + 7 - firstDayOfWeek) % 7);
}
let methodD = undefined;
if (ddata[4] <= mdata[ddata[5]]! || !isStrict) {
// convert to type c and calc
const targetWeek = mdata[ddata[5]]! - Math.min(ddata[4], mdata[ddata[5]]!) + 1;
methodD = 1 + (targetWeek - 1) * 7 + ((ddata[5] + 7 - firstDayOfWeek) % 7);
}
return [methodA, methodB, methodC, methodD];
}
type MonthWeekStatistics = [
/** The count of Youbi1 presented in this month. */
youbi1: number,
/** The count of Youbi2 presented in this month. */
youbi2: number,
/** The count of Youbi3 presented in this month. */
youbi3: number,
/** The count of Youbi4 presented in this month. */
youbi4: number,
/** The count of Youbi5 presented in this month. */
youbi5: number,
/** The count of Youbi6 presented in this month. */
youbi6: number,
/** The count of Youbi7 presented in this month. */
youbi7: number,
];
function getMonthWeekStatistics(year: number, month: number): MonthWeekStatistics {
const days = MONTH_DAY_COUNT[month - 1]! + ((month == 2 && isLeapYear(year)) ? 1 : 0);
const firstDayOfWeek = dayOfWeek(year, month, 1);
const result: MonthWeekStatistics = [4, 4, 4, 4, 4, 4, 4];
let remain = days % 7;
let week = firstDayOfWeek;
while (remain > 0) {
result[week % 7]! += 1;
week++;
remain--;
}
return result;
}
export function isLeapYear(year: number): boolean {
let isLeap = false;
if (year % 4 == 0) isLeap = true;
if (year % 100 == 0) isLeap = false;
if (year % 400 == 0) isLeap = true;
return isLeap;
}
// endregion
+66
View File
@@ -0,0 +1,66 @@
export enum Language {
English,
SimplifiedChinese,
}
/**
*
* @param month Zero-based month.
* @returns
*/
export function universalGetMonth(month: number): string {
switch (month) {
case 0:
return "January";
case 1:
return "February";
case 2:
return "March";
case 3:
return "April";
case 4:
return "May";
case 5:
return "June";
case 6:
return "July";
case 7:
return "August";
case 8:
return "September";
case 9:
return "October";
case 10:
return "November";
case 11:
return "December";
default:
return ""
}
}
/**
*
* @param dayOfWeek Zero-based day of week
* @returns
*/
export function universalGetDayOfWeek(dayOfWeek: number): string {
switch (dayOfWeek) {
case 0:
return "Monday";
case 1:
return "Tuesday";
case 2:
return "Wednesday";
case 3:
return "Thursday";
case 4:
return "Friday";
case 5:
return "Saturday";
case 6:
return "Sunday";
default:
return "";
}
}
+40
View File
@@ -0,0 +1,40 @@
export const DEFAULT_COLOR: string = '#536dfe';
export function gcd(a: number, b: number): number {
if (b == 0) return a;
return gcd(b, a % b);
}
export function lcm(a: number, b: number): number {
return a / gcd(a, b) * b;
}
/**
* `{n}`
* @param str `null`/`undefined`/
* @param args
* @returns
*/
export function format(str: string, ...args: any[]): string {
// 若 str 为假值(null/undefined/''),直接返回空字符串
if (!str) return '';
return str.replace(/\{(\d+)\}/g, (match, index) => {
const idx = parseInt(index, 10);
const value = args[idx];
// 若参数存在且不为 null/undefined,则转为字符串,否则保留占位符
return value != null ? String(value) : match;
});
}
/**
* Get Monday-first the day of week from given date instead of Sunday-first.
* @param date The date for getting weekday.
* @returns The zero-based weekday. 0 stands for Monday.
*/
export function getWeekday(date: Date): number {
const day = date.getDay();
if (day == 0) return 6;
else return day - 1;
}
+8
View File
@@ -0,0 +1,8 @@
<script setup lang="ts"></script>
<template>
<h1>Congratulations</h1>
<p>This is admin.</p>
</template>
<style scoped></style>
+344
View File
@@ -0,0 +1,344 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useTokenStore } from '@/stores/token';
import MessageBox from '@/components/MessageBox.vue';
import DateTimePicker, { TabType } from '@/components/DateTimePicker.vue';
import CalendarGrid from '@/components/calendar/CalendarGrid.vue';
import ScheduleList from '@/components/calendar/ScheduleList.vue';
import CollectionToggleItem from '@/components/calendar/CollectionToggleItem.vue';
import type { DisplayDay, DisplayEvent } from '@/components/calendar/types';
import {
getFull as apiCalendarGetFull,
deserializeDescription,
type CalendarRow,
} from '@/api/calendar';
import {
getFullOwn as apiCollectionGetFullOwn,
getShared as apiCollectionGetShared,
type CollectionRow,
type SharedCollectionRow,
} from '@/api/collection';
import {
DAY1_SPAN,
dayOfWeek,
resolveLoopRules2Event,
resolveLoopRules4Text,
} from '@/utils/datetime';
import { MONTH_NAMES, WEEK_NAMES } from '@/utils/calendar-names';
const token = useTokenStore();
const router = useRouter();
const messagebox = ref<InstanceType<typeof MessageBox>>();
const picker = ref<InstanceType<typeof DateTimePicker>>();
const activeTab = ref<number>(1);
// The viewed month cursor. Only year + month are meaningful; the time-of-day
// participates in the grid start timestamp exactly like the legacy code.
const currentMonth = ref<Date>(new Date());
// Raw event rows covering the current grid window.
const eventsCache = ref<CalendarRow[]>([]);
// Collection lists for tab 2.
const ownedCollections = ref<CollectionRow[]>([]);
const sharedCollections = ref<SharedCollectionRow[]>([]);
// Per-collection visibility flags, keyed by collection uuid. Default (absent)
// means hidden, matching the legacy behaviour where missing cache entries are
// treated as false.
const ownedVisible = ref<Map<string, boolean>>(new Map());
const sharedVisible = ref<Map<string, boolean>>(new Map());
// region: Computed grid window and analyzed display data
const gridYear = computed(() => currentMonth.value.getFullYear());
const gridMonth = computed(() => currentMonth.value.getMonth() + 1);
const firstDow = computed(() => dayOfWeek(gridYear.value, gridMonth.value, 1));
const gridStartTimestamp = computed(() =>
Math.floor(currentMonth.value.getTime() / 60000) - firstDow.value * DAY1_SPAN
);
const gridEndTimestamp = computed(() => gridStartTimestamp.value + DAY1_SPAN * 6 * 7 - 1);
const textMonth = computed(() => `${gridYear.value} - ${MONTH_NAMES[gridMonth.value - 1]}`);
const myTimezoneOffset = computed(() => -(new Date().getTimezoneOffset()));
// Port of ccn_calendar_calendar_Analyse: build the 42-cell display cache,
// expand loop events and split occurrences that cross day boundaries.
const displayDays = computed<DisplayDay[]>(() => {
const startTimestamp = gridStartTimestamp.value;
const endTimestamp = gridEndTimestamp.value;
const gottenMonth = gridMonth.value;
const myTz = myTimezoneOffset.value;
// Build the 42 day cells.
const days: DisplayDay[] = [];
const cursor = new Date(startTimestamp * 60000);
for (let i = 0; i < 6 * 7; i++) {
days.push({
month: cursor.getMonth() + 1,
day: cursor.getDate(),
dayOfWeek: (cursor.getDay() === 0 ? 6 : cursor.getDay() - 1) + 1,
isCurrentMonth: (cursor.getMonth() + 1) === gottenMonth,
subcalendar: '',
events: [],
});
cursor.setTime(cursor.getTime() + DAY1_SPAN * 60000);
}
// Expand and place each event.
for (const item of eventsCache.value) {
const deserialized = deserializeDescription(item[3]);
const minStartTimestamp = startTimestamp - (item[6] - item[5]);
const result = resolveLoopRules2Event(
item[8],
item[9] < minStartTimestamp ? minStartTimestamp : item[9],
Math.min(item[10], endTimestamp),
item[5],
item[6],
item[7],
startTimestamp
);
if (typeof result === 'undefined') continue;
const loopText = resolveLoopRules4Text(item[8], item[5], item[7]);
const timezoneWarning = myTz !== item[7];
for (const it of result) {
const eventDateTime = new Date(it[0] * 60000);
let count = Math.floor((it[0] - startTimestamp) / DAY1_SPAN);
let exitFlag = false;
while (count < 6 * 7) {
// NOTE: legacy indexes the owned cache by the *event* uuid (item[0]),
// which is collection-keyed, so this is always false. Kept faithful.
const isLocked = ownedVisible.value.has(item[0]);
const belongVisible = ownedVisible.value.get(item[1]) || sharedVisible.value.get(item[1]) || false;
const eventItem: DisplayEvent = {
uuid: item[0],
belongTo: item[1],
title: item[2],
description: deserialized.description,
color: deserialized.color,
isVisible: belongVisible,
isLocked,
loopText,
timezoneWarning,
start: eventDateTime.toLocaleTimeString(),
end: '',
};
eventDateTime.setHours(23, 59, 0, 0);
if (it[1] <= Math.floor(eventDateTime.getTime() / 60000)) {
exitFlag = true;
eventDateTime.setTime(it[1] * 60000);
}
eventItem.end = eventDateTime.toLocaleTimeString();
days[count]!.events.push(eventItem);
if (exitFlag) break;
eventDateTime.setMinutes(eventDateTime.getMinutes() + 1, 0, 0);
count++;
}
}
}
return days;
});
// endregion
// region: Data fetching
const refreshEvents = async () => {
const rv = await apiCalendarGetFull(token.currentToken, gridStartTimestamp.value, gridEndTimestamp.value);
eventsCache.value = typeof rv === 'undefined' ? [] : rv;
};
const refreshCollections = async () => {
const newOwned = new Map<string, boolean>();
const newShared = new Map<string, boolean>();
const sharedRv = await apiCollectionGetShared(token.currentToken);
sharedCollections.value = typeof sharedRv === 'undefined' ? [] : sharedRv;
if (sharedCollections.value.length > 0) {
for (const item of sharedCollections.value) {
newShared.set(item[0], true);
}
}
const ownedRv = await apiCollectionGetFullOwn(token.currentToken);
ownedCollections.value = typeof ownedRv === 'undefined' ? [] : ownedRv;
if (ownedCollections.value.length > 0) {
for (const item of ownedCollections.value) {
newOwned.set(item[0], true);
}
}
ownedVisible.value = newOwned;
sharedVisible.value = newShared;
};
// endregion
// region: Navigation
const prevMonth = () => {
const d = new Date(currentMonth.value);
d.setMonth(d.getMonth() - 1);
currentMonth.value = d;
};
const nextMonth = () => {
const d = new Date(currentMonth.value);
d.setMonth(d.getMonth() + 1);
currentMonth.value = d;
};
const today = () => {
currentMonth.value = new Date();
};
const jump = () => {
picker.value?.modal(currentMonth.value, TabType.Month);
};
const onPickerConfirm = (date: Date) => {
currentMonth.value = date;
};
const addEvent = () => {
router.push({ name: 'CalendarEventAdd' });
};
const editEvent = (uuid: string) => {
router.push({ name: 'CalendarEventUpdate', params: { uuid } });
};
// endregion
// region: Collection visibility toggles
const toggleOwned = (uuid: string) => {
const m = new Map(ownedVisible.value);
m.set(uuid, !m.get(uuid));
ownedVisible.value = m;
};
const toggleShared = (uuid: string) => {
const m = new Map(sharedVisible.value);
m.set(uuid, !m.get(uuid));
sharedVisible.value = m;
};
// endregion
watch(currentMonth, () => { refreshEvents(); });
onMounted(async () => {
await refreshCollections();
await refreshEvents();
});
</script>
<template>
<div class="container" style="margin-top: 20px;">
<div class="tabs">
<ul>
<li :class="{ 'is-active': activeTab === 1 }" @click="activeTab = 1"><a>Calendar</a></li>
<li :class="{ 'is-active': activeTab === 2 }" @click="activeTab = 2"><a>Collection</a></li>
<li :class="{ 'is-active': activeTab === 3 }" @click="activeTab = 3"><a>Display</a></li>
</ul>
</div>
</div>
<!-- Tab 1: Calendar -->
<div v-show="activeTab === 1" class="container" style="margin-top: 20px;">
<nav class="level is-mobile">
<div class="level-left">
<div class="level-item control">
<a class="button" @click="prevMonth">
<span class="icon is-small"><font-awesome-icon icon="fas fa-chevron-circle-left"></font-awesome-icon></span>
</a>
</div>
</div>
<div class="level-item control">
<a class="button" @click="jump">
<span>{{ textMonth }}</span>
</a>
</div>
<div class="level-right">
<div class="level-item control">
<a class="button" @click="nextMonth">
<span class="icon is-small"><font-awesome-icon icon="fas fa-chevron-circle-right"></font-awesome-icon></span>
</a>
</div>
</div>
</nav>
<nav class="level is-mobile">
<div class="level-item control">
<a class="button is-info" @click="today">Today</a>
</div>
<div class="level-item control">
<a class="button is-primary" @click="addEvent">Add...</a>
</div>
</nav>
<div class="card" style="padding: 1.25rem;">
<CalendarGrid :cells="displayDays" :week-names="WEEK_NAMES" @event-click="editEvent" />
</div>
<div class="container" style="padding: 1.25rem; display: flex; flex-flow: column; margin-top: 1.25rem;">
<h1 class="title">Schedule</h1>
<ScheduleList :days="displayDays" @event-click="editEvent" />
</div>
</div>
<!-- Tab 2: Collection -->
<div v-show="activeTab === 2" class="container" style="margin-top: 20px;">
<div class="control" style="margin: 0.75rem;">
<a class="button is-primary" @click="refreshCollections">
<span class="icon is-small"><font-awesome-icon icon="fas fa-sync"></font-awesome-icon></span>
</a>
</div>
<h1 class="title">My collections</h1>
<div style="display: flex; flex-flow: column; margin-top: 1.25rem; margin-bottom: 1.25rem;">
<CollectionToggleItem v-for="item in ownedCollections" :key="item[0]" :name="item[1]"
:is-show="ownedVisible.get(item[0]) || false" @toggle="toggleOwned(item[0])" />
</div>
<h1 class="title">Shared collections</h1>
<div style="display: flex; flex-flow: column; margin-top: 1.25rem; margin-bottom: 1.25rem;">
<CollectionToggleItem v-for="item in sharedCollections" :key="item[0]" :name="item[1]" :subtitle="item[2]"
:is-show="sharedVisible.get(item[0]) || false" @toggle="toggleShared(item[0])" />
</div>
</div>
<!-- Tab 3: Display (legacy dead UI, reproduced as-is) -->
<div v-show="activeTab === 3" class="container" style="margin-top: 20px;">
<div class="field">
<label class="label">The first day of week</label>
<div class="control">
<div class="select">
<select>
<option v-for="(name, i) in WEEK_NAMES" :key="i">{{ name }}</option>
</select>
</div>
</div>
</div>
<div class="field">
<label class="label">Sub-Calendar</label>
<div class="control">
<div class="select">
<select>
<option>Chinese Lunisolar Calendar</option>
</select>
</div>
</div>
</div>
</div>
<MessageBox ref="messagebox" />
<DateTimePicker ref="picker" @confirm="onPickerConfirm" />
</template>
<style scoped></style>

Some files were not shown because too many files have changed in this diff Show More