Compare commits
24
Commits
v1.1
..
0f674d0483
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f674d0483 | ||
|
|
e6fc06c0f9 | ||
|
|
91317c9eb7 | ||
|
|
b1b42ab2fb | ||
|
|
18f73b3084 | ||
|
|
fcc022d070 | ||
|
|
fda2fe9ec9 | ||
|
|
ebe7ea5f56 | ||
|
|
c4b68400b3 | ||
|
|
d7df194a12 | ||
|
|
35fee0f473 | ||
|
|
e484ded5be | ||
|
|
2a280dcba0 | ||
|
|
6337ae432d | ||
|
|
826cbf18b1 | ||
|
|
167c83f7d4 | ||
|
|
078e61e993 | ||
|
|
bdee3b3efa | ||
|
|
37b08927a7 | ||
|
|
46f2d69800 | ||
|
|
24790d8e69 | ||
|
|
07205396c8 | ||
|
|
433c6cf2f8 | ||
|
|
37435eeb66 |
+10
@@ -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重写后端。
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
@@ -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))
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Migration
|
||||||
|
|
||||||
|
This directory contains the migration scripts for the database.
|
||||||
@@ -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
@@ -1,15 +1,17 @@
|
|||||||
import sys
|
import sys
|
||||||
import logging
|
|
||||||
from argparse import ArgumentParser
|
from argparse import ArgumentParser
|
||||||
from typing import cast
|
from typing import cast
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import server
|
import server
|
||||||
import config
|
import config
|
||||||
import utils
|
import utils
|
||||||
import database
|
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?")
|
print("What is the first username of this calendar system?")
|
||||||
cache = input()
|
cache = input()
|
||||||
while not utils.IsValidUsername(cache):
|
while not utils.IsValidUsername(cache):
|
||||||
@@ -26,15 +28,15 @@ def GetUsernamePassword():
|
|||||||
|
|
||||||
return (username, password)
|
return (username, password)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
print("Coconut-leaf")
|
# Set as INFO level in default first,
|
||||||
print("A self-host, multi-account calendar system.")
|
# and we will change it once we load the configuration file.
|
||||||
print("Project: https://github.com/yyc12345/coconut-leaf")
|
logger.set_level(LoggerLevel.INFO)
|
||||||
print("===================")
|
|
||||||
|
|
||||||
# Receive arguments
|
# Receive arguments
|
||||||
parser = ArgumentParser(description="Coconut-leaf")
|
parser = ArgumentParser(
|
||||||
|
description="The server of light, self-host and multi-account calendar system."
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-c",
|
"-c",
|
||||||
"--config",
|
"--config",
|
||||||
@@ -54,16 +56,22 @@ if __name__ == "__main__":
|
|||||||
)
|
)
|
||||||
args = parser.parse_args()
|
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
|
# Load config file
|
||||||
try:
|
try:
|
||||||
config.setup_config(cast(Path, args.config))
|
config.setup_config(cast(Path, args.config))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error loading config file: {e}")
|
LOGGER.critical(f"Error loading config file: {e}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# Setup logging level
|
# Change logging level again according to whether enable debug mode
|
||||||
logging_level = logging.DEBUG if config.get_config().others.debug else logging.INFO
|
logging_level = LoggerLevel.DEBUG if config.get_config().others.debug else LoggerLevel.INFO
|
||||||
logging.basicConfig(format='[%(levelname)s] %(message)s', level=logging_level)
|
logger.set_level(logging_level)
|
||||||
|
|
||||||
# Initialize the calendar system if needed
|
# Initialize the calendar system if needed
|
||||||
if cast(bool, args.init):
|
if cast(bool, args.init):
|
||||||
@@ -72,5 +80,5 @@ if __name__ == "__main__":
|
|||||||
calendar.init(*gotten_data)
|
calendar.init(*gotten_data)
|
||||||
calendar.close()
|
calendar.close()
|
||||||
|
|
||||||
logging.info("Staring server...")
|
LOGGER.info("Staring server...")
|
||||||
server.run()
|
server.run()
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ path = "coconut-leaf.db"
|
|||||||
# database = "coconut_leaf"
|
# database = "coconut_leaf"
|
||||||
|
|
||||||
[web]
|
[web]
|
||||||
port = 8888
|
port = 8848
|
||||||
|
|
||||||
[others]
|
[others]
|
||||||
auto-token-clean-duration = 86400
|
auto-token-clean-duration = 86400
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"database-type": "sqlite",
|
|
||||||
"database-config": {
|
|
||||||
"user": "",
|
|
||||||
"password": "",
|
|
||||||
"db": "",
|
|
||||||
"url": "",
|
|
||||||
"port": 3306
|
|
||||||
},
|
|
||||||
"web": {
|
|
||||||
"port": 8888
|
|
||||||
},
|
|
||||||
"debug": true
|
|
||||||
}
|
|
||||||
+258
-160
@@ -1,54 +1,81 @@
|
|||||||
import config
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import utils
|
|
||||||
import threading
|
import threading
|
||||||
import logging
|
|
||||||
import dt
|
|
||||||
from typing import cast
|
from typing import cast
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Callable, ParamSpec, TypeVar, Generic
|
||||||
|
|
||||||
def SafeDatabaseOperation(func):
|
import dt
|
||||||
def wrapper(self: 'CalendarDatabase', *args, **kwargs):
|
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()
|
cfg = config.get_config()
|
||||||
|
|
||||||
with self.mutex:
|
with self.mutex:
|
||||||
# check database and acquire cursor
|
# try to fetching database and allocate database cursor
|
||||||
try:
|
try:
|
||||||
self.check_database()
|
db = self._get_db()
|
||||||
self.cursor = self.db.cursor()
|
self._allocate_cursor()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.cursor = None
|
self._free_cursor()
|
||||||
if cfg.others.debug:
|
if cfg.others.debug:
|
||||||
logging.exception(e)
|
LOGGER.exception(e)
|
||||||
return (False, str(e), None)
|
return ResponseBody(False, str(e), None)
|
||||||
|
|
||||||
# do real data work
|
# do real data work
|
||||||
try:
|
try:
|
||||||
currentTime = utils.GetCurrentTimestamp()
|
currentTime = utils.GetCurrentTimestamp()
|
||||||
if currentTime - self.latestClean > cfg.others.auto_token_clean_duration:
|
if currentTime - self.latestClean > cfg.others.auto_token_clean_duration:
|
||||||
self.latestClean = currentTime
|
self.latestClean = currentTime
|
||||||
logging.info('Cleaning outdated token...')
|
LOGGER.info('Cleaning outdated token...')
|
||||||
self.tokenOper_clean()
|
self.tokenOper_clean()
|
||||||
|
|
||||||
result = (True, '', func(self, *args, **kwargs))
|
result = ResponseBody(True, '', inner(*args, **kwargs))
|
||||||
self.cursor.close()
|
self._free_cursor()
|
||||||
self.cursor = None
|
db.commit()
|
||||||
self.db.commit()
|
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.cursor.close()
|
self._free_cursor()
|
||||||
self.cursor = None
|
db.rollback()
|
||||||
self.db.rollback()
|
|
||||||
if cfg.others.debug:
|
if cfg.others.debug:
|
||||||
logging.exception(e)
|
LOGGER.exception(e)
|
||||||
return (False, str(e), None)
|
return ResponseBody(False, str(e), None)
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
class CalendarDatabase:
|
class CalendarDatabase:
|
||||||
|
|
||||||
db: sqlite3.Connection
|
db: sqlite3.Connection | None
|
||||||
cursor: sqlite3.Cursor
|
cursor: sqlite3.Cursor | None
|
||||||
mutex: threading.Lock
|
mutex: threading.Lock
|
||||||
latestClean: int
|
latestClean: int
|
||||||
|
|
||||||
@@ -59,8 +86,8 @@ class CalendarDatabase:
|
|||||||
self.latestClean = 0
|
self.latestClean = 0
|
||||||
|
|
||||||
def open(self):
|
def open(self):
|
||||||
if (self.is_database_valid()):
|
if (self.db is not None):
|
||||||
raise Exception('Databade is opened')
|
raise DbException('Database is already opened')
|
||||||
|
|
||||||
cfg = config.get_config()
|
cfg = config.get_config()
|
||||||
match cfg.database.driver:
|
match cfg.database.driver:
|
||||||
@@ -69,13 +96,13 @@ class CalendarDatabase:
|
|||||||
self.db.execute('PRAGMA encoding = "UTF-8";')
|
self.db.execute('PRAGMA encoding = "UTF-8";')
|
||||||
self.db.execute('PRAGMA foreign_keys = ON;')
|
self.db.execute('PRAGMA foreign_keys = ON;')
|
||||||
case config.DatabaseDriver.MYSQL:
|
case config.DatabaseDriver.MYSQL:
|
||||||
raise Exception('Not implemented database')
|
raise DbException('Not implemented database')
|
||||||
case _:
|
case _:
|
||||||
raise Exception('Unknow database type')
|
raise DbException('Unknow database type')
|
||||||
|
|
||||||
def init(self, username, password):
|
def init(self, username: str, password: str):
|
||||||
if (self.is_database_valid()):
|
if (self.db is not None):
|
||||||
raise Exception('Database is opened')
|
raise DbException('Database is already opened')
|
||||||
|
|
||||||
# establish tables
|
# establish tables
|
||||||
cfg = config.get_config()
|
cfg = config.get_config()
|
||||||
@@ -85,44 +112,74 @@ class CalendarDatabase:
|
|||||||
case config.DatabaseDriver.SQLITE:
|
case config.DatabaseDriver.SQLITE:
|
||||||
sql_file = backend_sql_path / 'sqlite.sql'
|
sql_file = backend_sql_path / 'sqlite.sql'
|
||||||
case config.DatabaseDriver.MYSQL:
|
case config.DatabaseDriver.MYSQL:
|
||||||
raise Exception('Not implemented database')
|
raise DbException('Not implemented database')
|
||||||
case _:
|
case _:
|
||||||
raise Exception('Unknow database type')
|
raise DbException('Unknow database type')
|
||||||
|
|
||||||
self.open()
|
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:
|
with open(sql_file, 'r', encoding='utf-8') as fsql:
|
||||||
cursor.executescript(fsql.read())
|
cursor.executescript(fsql.read())
|
||||||
|
# add default user in user table
|
||||||
# finish init
|
|
||||||
cursor.execute('INSERT INTO user VALUES (?, ?, ?, ?);', (
|
cursor.execute('INSERT INTO user VALUES (?, ?, ?, ?);', (
|
||||||
username,
|
username,
|
||||||
utils.ComputePasswordHash(password),
|
utils.ComputePasswordHash(password),
|
||||||
1,
|
1,
|
||||||
utils.GenerateSalt()
|
utils.GenerateSalt()
|
||||||
))
|
))
|
||||||
cursor.close()
|
|
||||||
self.db.commit()
|
self._free_cursor()
|
||||||
|
|
||||||
|
# commit to database
|
||||||
|
db.commit()
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
self.check_database()
|
if (self.db is None):
|
||||||
self.db.close()
|
LOGGER.warning('Try to close null database.')
|
||||||
self.db = None
|
else:
|
||||||
|
self._free_cursor()
|
||||||
|
self.db.close()
|
||||||
|
self.db = None
|
||||||
|
|
||||||
def check_database(self):
|
def _get_db(self) -> sqlite3.Connection:
|
||||||
if (not self.is_database_valid()):
|
if (self.db is None):
|
||||||
raise Exception('Databade is None')
|
raise DbException('There is no opened database')
|
||||||
|
else:
|
||||||
|
return self.db
|
||||||
|
|
||||||
def is_database_valid(self):
|
def _allocate_cursor(self) -> None:
|
||||||
return not (self.db == 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
|
# ======================= token related internal operation
|
||||||
def tokenOper_clean(self):
|
def tokenOper_clean(self):
|
||||||
# remove outdated token
|
# 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):
|
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(),
|
utils.GetTokenExpireOn(),
|
||||||
token
|
token
|
||||||
))
|
))
|
||||||
@@ -131,16 +188,18 @@ class CalendarDatabase:
|
|||||||
self.tokenOper_get_username(token)
|
self.tokenOper_get_username(token)
|
||||||
|
|
||||||
def tokenOper_is_admin(self, username):
|
def tokenOper_is_admin(self, username):
|
||||||
self.cursor.execute('SELECT [ccn_isAdmin] FROM user WHERE [ccn_name] = ?;',(username, ))
|
cursor = self._get_cursor()
|
||||||
cache = self.cursor.fetchone()[0]
|
cursor.execute('SELECT [is_admin] FROM user WHERE [name] = ?;',(username, ))
|
||||||
|
cache = cursor.fetchone()[0]
|
||||||
return cache == 1
|
return cache == 1
|
||||||
|
|
||||||
def tokenOper_get_username(self, token):
|
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,
|
token,
|
||||||
utils.GetCurrentTimestamp()
|
utils.GetCurrentTimestamp()
|
||||||
))
|
))
|
||||||
result = self.cursor.fetchone()[0]
|
result = cursor.fetchone()[0]
|
||||||
# need postpone expire on time
|
# need postpone expire on time
|
||||||
self.tokenOper_postpone_expireOn(token)
|
self.tokenOper_postpone_expireOn(token)
|
||||||
return result
|
return result
|
||||||
@@ -150,8 +209,9 @@ class CalendarDatabase:
|
|||||||
|
|
||||||
@SafeDatabaseOperation
|
@SafeDatabaseOperation
|
||||||
def common_salt(self, username):
|
def common_salt(self, username):
|
||||||
|
cursor = self._get_cursor()
|
||||||
salt = utils.GenerateSalt()
|
salt = utils.GenerateSalt()
|
||||||
self.cursor.execute('UPDATE user SET [ccn_salt] = ? WHERE [ccn_name] = ?;', (
|
cursor.execute('UPDATE user SET [salt] = ? WHERE [name] = ?;', (
|
||||||
salt,
|
salt,
|
||||||
username
|
username
|
||||||
))
|
))
|
||||||
@@ -159,16 +219,17 @@ class CalendarDatabase:
|
|||||||
|
|
||||||
@SafeDatabaseOperation
|
@SafeDatabaseOperation
|
||||||
def common_login(self, username, password, clientUa, clientIp):
|
def common_login(self, username, password, clientUa, clientIp):
|
||||||
self.cursor.execute('SELECT [ccn_password], [ccn_salt] FROM user WHERE [ccn_name] = ?;', (username, ))
|
cursor = self._get_cursor()
|
||||||
(gotten_salt, gotten_password) = self.cursor.fetchone()
|
cursor.execute('SELECT [password], [salt] FROM user WHERE [name] = ?;', (username, ))
|
||||||
|
(gotten_salt, gotten_password) = cursor.fetchone()
|
||||||
|
|
||||||
if password == utils.ComputePasswordHashWithSalt(gotten_password, gotten_salt):
|
if password == utils.ComputePasswordHashWithSalt(gotten_password, gotten_salt):
|
||||||
token = utils.GenerateToken(username)
|
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
|
utils.GenerateSalt(), # regenerate a new slat to prevent re-login try
|
||||||
username
|
username
|
||||||
))
|
))
|
||||||
self.cursor.execute('INSERT INTO token VALUES (?, ?, ?, ?, ?);', (
|
cursor.execute('INSERT INTO token VALUES (?, ?, ?, ?, ?);', (
|
||||||
username,
|
username,
|
||||||
token,
|
token,
|
||||||
utils.GetTokenExpireOn(), # add 2 day from now
|
utils.GetTokenExpireOn(), # add 2 day from now
|
||||||
@@ -178,15 +239,21 @@ class CalendarDatabase:
|
|||||||
return token
|
return token
|
||||||
else:
|
else:
|
||||||
# throw a exception to indicate fail to login
|
# throw a exception to indicate fail to login
|
||||||
raise Exception('Login authentication failed')
|
raise DbException('Login authentication failed')
|
||||||
|
|
||||||
@SafeDatabaseOperation
|
@SafeDatabaseOperation
|
||||||
def common_webLogin(self, username, password, clientUa, clientIp):
|
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)
|
token = utils.GenerateToken(username)
|
||||||
self.cursor.execute('INSERT INTO token VALUES (?, ?, ?, ?, ?);', (
|
cursor.execute('INSERT INTO token VALUES (?, ?, ?, ?, ?);', (
|
||||||
username,
|
username,
|
||||||
token,
|
token,
|
||||||
utils.GetTokenExpireOn(), # add 2 day from now
|
utils.GetTokenExpireOn(), # add 2 day from now
|
||||||
@@ -196,12 +263,13 @@ class CalendarDatabase:
|
|||||||
return token
|
return token
|
||||||
else:
|
else:
|
||||||
# throw a exception to indicate fail to login
|
# throw a exception to indicate fail to login
|
||||||
raise Exception('Login authentication failed')
|
raise DbException('Login authentication failed')
|
||||||
|
|
||||||
@SafeDatabaseOperation
|
@SafeDatabaseOperation
|
||||||
def common_logout(self, token):
|
def common_logout(self, token):
|
||||||
|
cursor = self._get_cursor()
|
||||||
self.tokenOper_check_valid(token)
|
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
|
return True
|
||||||
|
|
||||||
@SafeDatabaseOperation
|
@SafeDatabaseOperation
|
||||||
@@ -212,40 +280,44 @@ class CalendarDatabase:
|
|||||||
# =============================== calendar
|
# =============================== calendar
|
||||||
@SafeDatabaseOperation
|
@SafeDatabaseOperation
|
||||||
def calendar_getFull(self, token, startDateTime, endDateTime):
|
def calendar_getFull(self, token, startDateTime, endDateTime):
|
||||||
|
cursor = self._get_cursor()
|
||||||
username = self.tokenOper_get_username(token)
|
username = self.tokenOper_get_username(token)
|
||||||
self.cursor.execute('SELECT calendar.* FROM calendar INNER JOIN collection \
|
cursor.execute('SELECT calendar.* FROM calendar INNER JOIN collection \
|
||||||
ON collection.ccn_uuid = calendar.ccn_belongTo \
|
ON collection.uuid = calendar.belong_to \
|
||||||
WHERE (collection.ccn_user = ? AND calendar.ccn_loopDateTimeEnd >= ? AND calendar.ccn_loopDateTimeStart - (calendar.ccn_eventDateTimeEnd - calendar.ccn_eventDateTimeStart) <= ?);',
|
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))
|
(username, startDateTime, endDateTime))
|
||||||
return self.cursor.fetchall()
|
return cursor.fetchall()
|
||||||
|
|
||||||
@SafeDatabaseOperation
|
@SafeDatabaseOperation
|
||||||
def calendar_getList(self, token, startDateTime, endDateTime):
|
def calendar_getList(self, token, startDateTime, endDateTime):
|
||||||
|
cursor = self._get_cursor()
|
||||||
username = self.tokenOper_get_username(token)
|
username = self.tokenOper_get_username(token)
|
||||||
self.cursor.execute('SELECT calendar.ccn_uuid FROM calendar INNER JOIN collection \
|
cursor.execute('SELECT calendar.uuid FROM calendar INNER JOIN collection \
|
||||||
ON collection.ccn_uuid = calendar.ccn_belongTo \
|
ON collection.uuid = calendar.belong_to \
|
||||||
WHERE (collection.ccn_user = ? AND calendar.ccn_loopDateTimeEnd >= ? AND calendar.ccn_loopDateTimeStart - (calendar.ccn_eventDateTimeEnd - calendar.ccn_eventDateTimeStart) <= ?);',
|
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))
|
(username, startDateTime, endDateTime))
|
||||||
return tuple(map(lambda x: x[0], self.cursor.fetchall()))
|
return tuple(map(lambda x: x[0], cursor.fetchall()))
|
||||||
|
|
||||||
@SafeDatabaseOperation
|
@SafeDatabaseOperation
|
||||||
def calendar_getDetail(self, token, uuid):
|
def calendar_getDetail(self, token, uuid):
|
||||||
|
cursor = self._get_cursor()
|
||||||
self.tokenOper_check_valid(token)
|
self.tokenOper_check_valid(token)
|
||||||
self.cursor.execute('SELECT * FROM calendar WHERE [ccn_uuid] = ?;', (uuid, ))
|
cursor.execute('SELECT * FROM calendar WHERE [uuid] = ?;', (uuid, ))
|
||||||
return self.cursor.fetchone()
|
return cursor.fetchone()
|
||||||
|
|
||||||
@SafeDatabaseOperation
|
@SafeDatabaseOperation
|
||||||
def calendar_update(self, token, uuid, lastChange, **optArgs):
|
def calendar_update(self, token, uuid, lastChange, **optArgs):
|
||||||
|
cursor = self._get_cursor()
|
||||||
self.tokenOper_check_valid(token)
|
self.tokenOper_check_valid(token)
|
||||||
|
|
||||||
# get prev data
|
# get prev data
|
||||||
self.cursor.execute('SELECT * FROM calendar WHERE [ccn_uuid] = ? AND [ccn_lastChange] = ?;', (uuid, lastChange))
|
cursor.execute('SELECT * FROM calendar WHERE [uuid] = ? AND [last_change] = ?;', (uuid, lastChange))
|
||||||
analyseData = list(self.cursor.fetchone())
|
analyseData = list(cursor.fetchone())
|
||||||
|
|
||||||
# construct update data
|
# construct update data
|
||||||
lastupdate = utils.GenerateUUID()
|
lastupdate = utils.GenerateUUID()
|
||||||
sqlList = [
|
sqlList = [
|
||||||
'[ccn_lastChange] = ?',
|
'[last_change] = ?',
|
||||||
]
|
]
|
||||||
argumentsList = [
|
argumentsList = [
|
||||||
lastupdate,
|
lastupdate,
|
||||||
@@ -256,44 +328,44 @@ class CalendarDatabase:
|
|||||||
|
|
||||||
cache = optArgs.get('belongTo', None)
|
cache = optArgs.get('belongTo', None)
|
||||||
if cache is not None:
|
if cache is not None:
|
||||||
sqlList.append('[ccn_belongTo] = ?')
|
sqlList.append('[belong_to] = ?')
|
||||||
argumentsList.append(cache)
|
argumentsList.append(cache)
|
||||||
cache = optArgs.get('title', None)
|
cache = optArgs.get('title', None)
|
||||||
if cache is not None:
|
if cache is not None:
|
||||||
sqlList.append('[ccn_title] = ?')
|
sqlList.append('[title] = ?')
|
||||||
argumentsList.append(cache)
|
argumentsList.append(cache)
|
||||||
cache = optArgs.get('description', None)
|
cache = optArgs.get('description', None)
|
||||||
if cache is not None:
|
if cache is not None:
|
||||||
sqlList.append('[ccn_description] = ?')
|
sqlList.append('[description] = ?')
|
||||||
argumentsList.append(cache)
|
argumentsList.append(cache)
|
||||||
cache = optArgs.get('eventDateTimeStart', None)
|
cache = optArgs.get('eventDateTimeStart', None)
|
||||||
if cache is not None:
|
if cache is not None:
|
||||||
sqlList.append('[ccn_eventDateTimeStart] = ?')
|
sqlList.append('[event_date_time_start] = ?')
|
||||||
argumentsList.append(cache)
|
argumentsList.append(cache)
|
||||||
reAnalyseLoop = True
|
reAnalyseLoop = True
|
||||||
analyseData[5] = cache
|
analyseData[5] = cache
|
||||||
cache = optArgs.get('eventDateTimeEnd', None)
|
cache = optArgs.get('eventDateTimeEnd', None)
|
||||||
if cache is not None:
|
if cache is not None:
|
||||||
sqlList.append('[ccn_eventDateTimeEnd] = ?')
|
sqlList.append('[event_date_time_end] = ?')
|
||||||
argumentsList.append(cache)
|
argumentsList.append(cache)
|
||||||
cache = optArgs.get('loopRules', None)
|
cache = optArgs.get('loopRules', None)
|
||||||
if cache is not None:
|
if cache is not None:
|
||||||
sqlList.append('[ccn_loopRules] = ?')
|
sqlList.append('[loop_rules] = ?')
|
||||||
argumentsList.append(cache)
|
argumentsList.append(cache)
|
||||||
reAnalyseLoop = True
|
reAnalyseLoop = True
|
||||||
analyseData[8] = cache
|
analyseData[8] = cache
|
||||||
cache = optArgs.get('timezoneOffset', None)
|
cache = optArgs.get('timezoneOffset', None)
|
||||||
if cache is not None:
|
if cache is not None:
|
||||||
sqlList.append('[ccn_timezoneOffset] = ?')
|
sqlList.append('[timezone_offset] = ?')
|
||||||
argumentsList.append(cache)
|
argumentsList.append(cache)
|
||||||
reAnalyseLoop = True
|
reAnalyseLoop = True
|
||||||
analyseData[7] = cache
|
analyseData[7] = cache
|
||||||
|
|
||||||
if reAnalyseLoop:
|
if reAnalyseLoop:
|
||||||
# re-compute loop data and upload it into list
|
# re-compute loop data and upload it into list
|
||||||
sqlList.append('[ccn_loopDateTimeStart] = ?')
|
sqlList.append('[loop_date_time_start] = ?')
|
||||||
argumentsList.append(analyseData[5])
|
argumentsList.append(analyseData[5])
|
||||||
sqlList.append('[ccn_loopDateTimeEnd] = ?')
|
sqlList.append('[loop_date_time_end] = ?')
|
||||||
argumentsList.append(str(dt.ResolveLoopStr(
|
argumentsList.append(str(dt.ResolveLoopStr(
|
||||||
analyseData[8],
|
analyseData[8],
|
||||||
analyseData[5],
|
analyseData[5],
|
||||||
@@ -302,14 +374,15 @@ class CalendarDatabase:
|
|||||||
|
|
||||||
# execute
|
# execute
|
||||||
argumentsList.append(uuid)
|
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))
|
tuple(argumentsList))
|
||||||
if self.cursor.rowcount != 1:
|
if cursor.rowcount != 1:
|
||||||
raise Exception('Fail to update due to no matched rows or too much rows.')
|
raise DbException('Fail to update due to no matched rows or too much rows.')
|
||||||
return lastupdate
|
return lastupdate
|
||||||
|
|
||||||
@SafeDatabaseOperation
|
@SafeDatabaseOperation
|
||||||
def calendar_add(self, token, belongTo, title, description, eventDateTimeStart, eventDateTimeEnd, loopRules, timezoneOffset):
|
def calendar_add(self, token, belongTo, title, description, eventDateTimeStart, eventDateTimeEnd, loopRules, timezoneOffset):
|
||||||
|
cursor = self._get_cursor()
|
||||||
self.tokenOper_check_valid(token)
|
self.tokenOper_check_valid(token)
|
||||||
|
|
||||||
newuuid = utils.GenerateUUID()
|
newuuid = utils.GenerateUUID()
|
||||||
@@ -319,7 +392,7 @@ class CalendarDatabase:
|
|||||||
loopDateTimeStart = eventDateTimeStart
|
loopDateTimeStart = eventDateTimeStart
|
||||||
loopDateTimeEnd = dt.ResolveLoopStr(loopRules, eventDateTimeStart, timezoneOffset)
|
loopDateTimeEnd = dt.ResolveLoopStr(loopRules, eventDateTimeStart, timezoneOffset)
|
||||||
|
|
||||||
self.cursor.execute('INSERT INTO calendar VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);',
|
cursor.execute('INSERT INTO calendar VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);',
|
||||||
(newuuid,
|
(newuuid,
|
||||||
belongTo,
|
belongTo,
|
||||||
title,
|
title,
|
||||||
@@ -335,134 +408,149 @@ class CalendarDatabase:
|
|||||||
|
|
||||||
@SafeDatabaseOperation
|
@SafeDatabaseOperation
|
||||||
def calendar_delete(self, token, uuid, lastChange):
|
def calendar_delete(self, token, uuid, lastChange):
|
||||||
|
cursor = self._get_cursor()
|
||||||
self.tokenOper_check_valid(token)
|
self.tokenOper_check_valid(token)
|
||||||
self.cursor.execute('DELETE FROM calendar WHERE [ccn_uuid] = ? AND [ccn_lastChange] = ?;', (uuid, lastChange))
|
cursor.execute('DELETE FROM calendar WHERE [uuid] = ? AND [last_change] = ?;', (uuid, lastChange))
|
||||||
if self.cursor.rowcount != 1:
|
if cursor.rowcount != 1:
|
||||||
raise Exception('Fail to delete due to no matched rows or too much rows.')
|
raise DbException('Fail to delete due to no matched rows or too much rows.')
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# =============================== collection
|
# =============================== collection
|
||||||
@SafeDatabaseOperation
|
@SafeDatabaseOperation
|
||||||
def collection_getFullOwn(self, token):
|
def collection_getFullOwn(self, token):
|
||||||
|
cursor = self._get_cursor()
|
||||||
username = self.tokenOper_get_username(token)
|
username = self.tokenOper_get_username(token)
|
||||||
self.cursor.execute('SELECT [ccn_uuid], [ccn_name], [ccn_lastChange] FROM collection WHERE [ccn_user] = ?;', (username, ))
|
cursor.execute('SELECT [uuid], [name], [last_change] FROM collection WHERE [user] = ?;', (username, ))
|
||||||
return self.cursor.fetchall()
|
return cursor.fetchall()
|
||||||
|
|
||||||
@SafeDatabaseOperation
|
@SafeDatabaseOperation
|
||||||
def collection_getListOwn(self, token):
|
def collection_getListOwn(self, token):
|
||||||
|
cursor = self._get_cursor()
|
||||||
username = self.tokenOper_get_username(token)
|
username = self.tokenOper_get_username(token)
|
||||||
self.cursor.execute('SELECT [ccn_uuid] FROM collection WHERE [ccn_user] = ?;', (username, ))
|
cursor.execute('SELECT [uuid] FROM collection WHERE [user] = ?;', (username, ))
|
||||||
return tuple(map(lambda x: x[0], self.cursor.fetchall()))
|
return tuple(map(lambda x: x[0], cursor.fetchall()))
|
||||||
|
|
||||||
@SafeDatabaseOperation
|
@SafeDatabaseOperation
|
||||||
def collection_getDetailOwn(self, token, uuid):
|
def collection_getDetailOwn(self, token, uuid):
|
||||||
|
cursor = self._get_cursor()
|
||||||
username = self.tokenOper_get_username(token)
|
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))
|
cursor.execute('SELECT [uuid], [name], [last_change] FROM collection WHERE [user] = ? AND [uuid] = ?;', (username, uuid))
|
||||||
return self.cursor.fetchone()
|
return cursor.fetchone()
|
||||||
|
|
||||||
@SafeDatabaseOperation
|
@SafeDatabaseOperation
|
||||||
def collection_addOwn(self, token, newname):
|
def collection_addOwn(self, token, newname):
|
||||||
|
cursor = self._get_cursor()
|
||||||
username = self.tokenOper_get_username(token)
|
username = self.tokenOper_get_username(token)
|
||||||
newuuid = utils.GenerateUUID()
|
newuuid = utils.GenerateUUID()
|
||||||
lastupdate = utils.GenerateUUID()
|
lastupdate = utils.GenerateUUID()
|
||||||
self.cursor.execute('INSERT INTO collection VALUES (?, ?, ?, ?);',
|
cursor.execute('INSERT INTO collection VALUES (?, ?, ?, ?);',
|
||||||
(newuuid, newname, username, lastupdate))
|
(newuuid, newname, username, lastupdate))
|
||||||
return newuuid
|
return newuuid
|
||||||
|
|
||||||
@SafeDatabaseOperation
|
@SafeDatabaseOperation
|
||||||
def collection_updateOwn(self, token, uuid, newname, lastChange):
|
def collection_updateOwn(self, token, uuid, newname, lastChange):
|
||||||
|
cursor = self._get_cursor()
|
||||||
self.tokenOper_check_valid(token)
|
self.tokenOper_check_valid(token)
|
||||||
|
|
||||||
lastupdate = utils.GenerateUUID()
|
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,
|
newname,
|
||||||
lastupdate,
|
lastupdate,
|
||||||
uuid,
|
uuid,
|
||||||
lastChange
|
lastChange
|
||||||
))
|
))
|
||||||
if self.cursor.rowcount != 1:
|
if cursor.rowcount != 1:
|
||||||
raise Exception('Fail to update due to no matched rows or too much rows.')
|
raise DbException('Fail to update due to no matched rows or too much rows.')
|
||||||
return lastupdate
|
return lastupdate
|
||||||
|
|
||||||
@SafeDatabaseOperation
|
@SafeDatabaseOperation
|
||||||
def collection_deleteOwn(self, token, uuid, lastChange):
|
def collection_deleteOwn(self, token, uuid, lastChange):
|
||||||
|
cursor = self._get_cursor()
|
||||||
self.tokenOper_check_valid(token)
|
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,
|
uuid,
|
||||||
lastChange
|
lastChange
|
||||||
))
|
))
|
||||||
if self.cursor.rowcount != 1:
|
if cursor.rowcount != 1:
|
||||||
raise Exception('Fail to delete due to no matched rows or too much rows.')
|
raise DbException('Fail to delete due to no matched rows or too much rows.')
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@SafeDatabaseOperation
|
@SafeDatabaseOperation
|
||||||
def collection_getSharing(self, token, uuid):
|
def collection_getSharing(self, token, uuid):
|
||||||
|
cursor = self._get_cursor()
|
||||||
self.tokenOper_check_valid(token)
|
self.tokenOper_check_valid(token)
|
||||||
self.cursor.execute('SELECT [ccn_target] FROM share WHERE [ccn_uuid] = ?;', (uuid, ))
|
cursor.execute('SELECT [target] FROM share WHERE [uuid] = ?;', (uuid, ))
|
||||||
return tuple(map(lambda x: x[0], self.cursor.fetchall()))
|
return tuple(map(lambda x: x[0], cursor.fetchall()))
|
||||||
|
|
||||||
@SafeDatabaseOperation
|
@SafeDatabaseOperation
|
||||||
def collection_deleteSharing(self, token, uuid, target, lastChange):
|
def collection_deleteSharing(self, token, uuid, target, lastChange):
|
||||||
|
cursor = self._get_cursor()
|
||||||
self.tokenOper_check_valid(token)
|
self.tokenOper_check_valid(token)
|
||||||
|
|
||||||
lastupdate = utils.GenerateUUID()
|
lastupdate = utils.GenerateUUID()
|
||||||
self.cursor.execute('UPDATE collection SET [ccn_lastChange] = ?, WHERE [ccn_uuid] = ? AND [ccn_lastChange] = ?;', (lastupdate, uuid, lastChange))
|
cursor.execute('UPDATE collection SET [last_change] = ?, WHERE [uuid] = ? AND [last_change] = ?;', (lastupdate, uuid, lastChange))
|
||||||
if self.cursor.rowcount != 1:
|
if cursor.rowcount != 1:
|
||||||
raise Exception('Fail to delete due to no matched rows or too much rows.')
|
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))
|
cursor.execute('DELETE FROM share WHERE [uuid] = ? AND [target] = ?;', (uuid, target))
|
||||||
if self.cursor.rowcount != 1:
|
if cursor.rowcount != 1:
|
||||||
raise Exception('Fail to delete due to no matched rows or too much rows.')
|
raise DbException('Fail to delete due to no matched rows or too much rows.')
|
||||||
|
|
||||||
return lastupdate
|
return lastupdate
|
||||||
|
|
||||||
@SafeDatabaseOperation
|
@SafeDatabaseOperation
|
||||||
def collection_addSharing(self, token, uuid, target, lastChange):
|
def collection_addSharing(self, token, uuid, target, lastChange):
|
||||||
|
cursor = self._get_cursor()
|
||||||
self.tokenOper_check_valid(token)
|
self.tokenOper_check_valid(token)
|
||||||
|
|
||||||
lastupdate = utils.GenerateUUID()
|
lastupdate = utils.GenerateUUID()
|
||||||
self.cursor.execute('UPDATE collection SET [ccn_lastChange] = ? WHERE [ccn_uuid] = ? AND [ccn_lastChange] = ?;', (lastupdate, uuid, lastChange))
|
cursor.execute('UPDATE collection SET [last_change] = ? WHERE [uuid] = ? AND [last_change] = ?;', (lastupdate, uuid, lastChange))
|
||||||
if self.cursor.rowcount != 1:
|
if cursor.rowcount != 1:
|
||||||
raise Exception('Fail to delete due to no matched rows or too much rows.')
|
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))
|
cursor.execute('SELECT * FROM share WHERE [uuid] = ? AND [target] = ?;', (uuid, target))
|
||||||
if len(self.cursor.fetchall()) != 0:
|
if len(cursor.fetchall()) != 0:
|
||||||
raise Exception('Fail to insert duplicated item.')
|
raise DbException('Fail to insert duplicated item.')
|
||||||
self.cursor.execute('INSERT INTO share VALUES (?, ?);', (uuid, target))
|
cursor.execute('INSERT INTO share VALUES (?, ?);', (uuid, target))
|
||||||
|
|
||||||
return lastupdate
|
return lastupdate
|
||||||
|
|
||||||
@SafeDatabaseOperation
|
@SafeDatabaseOperation
|
||||||
def collection_getShared(self, token):
|
def collection_getShared(self, token):
|
||||||
|
cursor = self._get_cursor()
|
||||||
username = self.tokenOper_get_username(token)
|
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 \
|
FROM share INNER JOIN collection \
|
||||||
ON share.ccn_uuid = collection.ccn_uuid \
|
ON share.uuid = collection.uuid \
|
||||||
WHERE share.ccn_target = ?;', (username, ))
|
WHERE share.target = ?;', (username, ))
|
||||||
return self.cursor.fetchall()
|
return cursor.fetchall()
|
||||||
|
|
||||||
# =============================== todo
|
# =============================== todo
|
||||||
@SafeDatabaseOperation
|
@SafeDatabaseOperation
|
||||||
def todo_getFull(self, token):
|
def todo_getFull(self, token):
|
||||||
|
cursor = self._get_cursor()
|
||||||
username = self.tokenOper_get_username(token)
|
username = self.tokenOper_get_username(token)
|
||||||
self.cursor.execute('SELECT * FROM todo WHERE [ccn_belongTo] = ?;', (username, ))
|
cursor.execute('SELECT * FROM todo WHERE [belong_to] = ?;', (username, ))
|
||||||
return self.cursor.fetchall()
|
return cursor.fetchall()
|
||||||
|
|
||||||
@SafeDatabaseOperation
|
@SafeDatabaseOperation
|
||||||
def todo_getList(self, token):
|
def todo_getList(self, token):
|
||||||
|
cursor = self._get_cursor()
|
||||||
username = self.tokenOper_get_username(token)
|
username = self.tokenOper_get_username(token)
|
||||||
self.cursor.execute('SELECT [ccn_uuid] FROM todo WHERE [ccn_belongTo] = ?;', (username, ))
|
cursor.execute('SELECT [uuid] FROM todo WHERE [belong_to] = ?;', (username, ))
|
||||||
return tuple(map(lambda x: x[0], self.cursor.fetchall()))
|
return tuple(map(lambda x: x[0], cursor.fetchall()))
|
||||||
|
|
||||||
@SafeDatabaseOperation
|
@SafeDatabaseOperation
|
||||||
def todo_getDetail(self, token, uuid):
|
def todo_getDetail(self, token, uuid):
|
||||||
|
cursor = self._get_cursor()
|
||||||
username = self.tokenOper_get_username(token)
|
username = self.tokenOper_get_username(token)
|
||||||
self.cursor.execute('SELECT * FROM todo WHERE [ccn_belongTo] = ? AND [ccn_uuid] = ?;', (username, uuid))
|
cursor.execute('SELECT * FROM todo WHERE [belong_to] = ? AND [uuid] = ?;', (username, uuid))
|
||||||
return self.cursor.fetchone()
|
return cursor.fetchone()
|
||||||
|
|
||||||
@SafeDatabaseOperation
|
@SafeDatabaseOperation
|
||||||
def todo_add(self, token):
|
def todo_add(self, token):
|
||||||
|
cursor = self._get_cursor()
|
||||||
username = self.tokenOper_get_username(token)
|
username = self.tokenOper_get_username(token)
|
||||||
newuuid = utils.GenerateUUID()
|
newuuid = utils.GenerateUUID()
|
||||||
lastupdate = utils.GenerateUUID()
|
lastupdate = utils.GenerateUUID()
|
||||||
@@ -472,56 +560,60 @@ class CalendarDatabase:
|
|||||||
'',
|
'',
|
||||||
lastupdate,
|
lastupdate,
|
||||||
)
|
)
|
||||||
self.cursor.execute('INSERT INTO todo VALUES (?, ?, ?, ?);', returnedData)
|
cursor.execute('INSERT INTO todo VALUES (?, ?, ?, ?);', returnedData)
|
||||||
return returnedData
|
return returnedData
|
||||||
|
|
||||||
@SafeDatabaseOperation
|
@SafeDatabaseOperation
|
||||||
def todo_update(self, token, uuid, data, lastChange):
|
def todo_update(self, token, uuid, data, lastChange):
|
||||||
|
cursor = self._get_cursor()
|
||||||
# check valid token
|
# check valid token
|
||||||
self.tokenOper_check_valid(token)
|
self.tokenOper_check_valid(token)
|
||||||
|
|
||||||
# update
|
# update
|
||||||
newLastChange = utils.GenerateUUID()
|
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,
|
data,
|
||||||
newLastChange,
|
newLastChange,
|
||||||
uuid,
|
uuid,
|
||||||
lastChange
|
lastChange
|
||||||
))
|
))
|
||||||
if self.cursor.rowcount != 1:
|
if cursor.rowcount != 1:
|
||||||
raise Exception('Fail to update due to no matched rows or too much rows.')
|
raise DbException('Fail to update due to no matched rows or too much rows.')
|
||||||
return newLastChange
|
return newLastChange
|
||||||
|
|
||||||
@SafeDatabaseOperation
|
@SafeDatabaseOperation
|
||||||
def todo_delete(self, token, uuid, lastChange):
|
def todo_delete(self, token, uuid, lastChange):
|
||||||
|
cursor = self._get_cursor()
|
||||||
# check valid token
|
# check valid token
|
||||||
self.tokenOper_check_valid(token)
|
self.tokenOper_check_valid(token)
|
||||||
|
|
||||||
# delete
|
# delete
|
||||||
self.cursor.execute('DELETE FROM todo WHERE [ccn_uuid] = ? AND [ccn_lastChange] = ?;', (uuid, lastChange))
|
cursor.execute('DELETE FROM todo WHERE [uuid] = ? AND [last_change] = ?;', (uuid, lastChange))
|
||||||
if self.cursor.rowcount != 1:
|
if cursor.rowcount != 1:
|
||||||
raise Exception('Fail to delete due to no matched rows or too much rows.')
|
raise DbException('Fail to delete due to no matched rows or too much rows.')
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
# =============================== admin
|
# =============================== admin
|
||||||
@SafeDatabaseOperation
|
@SafeDatabaseOperation
|
||||||
def admin_get(self, token):
|
def admin_get(self, token):
|
||||||
|
cursor = self._get_cursor()
|
||||||
username = self.tokenOper_get_username(token)
|
username = self.tokenOper_get_username(token)
|
||||||
if not self.tokenOper_is_admin(username):
|
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;')
|
cursor.execute('SELECT [name], [is_admin] FROM user;')
|
||||||
return tuple(map(lambda x: (x[0], x[1] == 1), self.cursor.fetchall()))
|
return tuple(map(lambda x: (x[0], x[1] == 1), cursor.fetchall()))
|
||||||
|
|
||||||
@SafeDatabaseOperation
|
@SafeDatabaseOperation
|
||||||
def admin_add(self, token, newname):
|
def admin_add(self, token, newname):
|
||||||
|
cursor = self._get_cursor()
|
||||||
username = self.tokenOper_get_username(token)
|
username = self.tokenOper_get_username(token)
|
||||||
if not self.tokenOper_is_admin(username):
|
if not self.tokenOper_is_admin(username):
|
||||||
raise Exception('Permission denied.')
|
raise DbException('Permission denied.')
|
||||||
|
|
||||||
newpassword = utils.ComputePasswordHash(utils.GenerateUUID())
|
newpassword = utils.ComputePasswordHash(utils.GenerateUUID())
|
||||||
self.cursor.execute('INSERT INTO user VALUES (?, ?, ?, ?);', (
|
cursor.execute('INSERT INTO user VALUES (?, ?, ?, ?);', (
|
||||||
newname,
|
newname,
|
||||||
newpassword,
|
newpassword,
|
||||||
0,
|
0,
|
||||||
@@ -531,9 +623,10 @@ class CalendarDatabase:
|
|||||||
|
|
||||||
@SafeDatabaseOperation
|
@SafeDatabaseOperation
|
||||||
def admin_update(self, token, _username, **optArgs):
|
def admin_update(self, token, _username, **optArgs):
|
||||||
|
cursor = self._get_cursor()
|
||||||
username = self.tokenOper_get_username(token)
|
username = self.tokenOper_get_username(token)
|
||||||
if not self.tokenOper_is_admin(username):
|
if not self.tokenOper_is_admin(username):
|
||||||
raise Exception('Permission denied.')
|
raise DbException('Permission denied.')
|
||||||
|
|
||||||
# construct data
|
# construct data
|
||||||
sqlList = []
|
sqlList = []
|
||||||
@@ -542,45 +635,48 @@ class CalendarDatabase:
|
|||||||
# analyse opt arg
|
# analyse opt arg
|
||||||
cache = optArgs.get('password', None)
|
cache = optArgs.get('password', None)
|
||||||
if cache is not None:
|
if cache is not None:
|
||||||
sqlList.append('[ccn_password] = ?')
|
sqlList.append('[password] = ?')
|
||||||
argumentsList.append(utils.ComputePasswordHash(cache))
|
argumentsList.append(utils.ComputePasswordHash(cache))
|
||||||
cache = optArgs.get('isAdmin', None)
|
cache = optArgs.get('isAdmin', None)
|
||||||
if cache is not None:
|
if cache is not None:
|
||||||
sqlList.append('[ccn_isAdmin] = ?')
|
sqlList.append('[is_admin] = ?')
|
||||||
argumentsList.append(1 if cache else 0)
|
argumentsList.append(1 if cache else 0)
|
||||||
|
|
||||||
# execute
|
# execute
|
||||||
argumentsList.append(_username)
|
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))
|
tuple(argumentsList))
|
||||||
logging.debug(cache)
|
LOGGER.debug(cache)
|
||||||
logging.debug(tuple(argumentsList))
|
LOGGER.debug(tuple(argumentsList))
|
||||||
if self.cursor.rowcount != 1:
|
if cursor.rowcount != 1:
|
||||||
raise Exception('Fail to update due to no matched rows or too much rows.')
|
raise DbException('Fail to update due to no matched rows or too much rows.')
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@SafeDatabaseOperation
|
@SafeDatabaseOperation
|
||||||
def admin_delete(self, token, username):
|
def admin_delete(self, token, username):
|
||||||
|
cursor = self._get_cursor()
|
||||||
_username = self.tokenOper_get_username(token)
|
_username = self.tokenOper_get_username(token)
|
||||||
if not self.tokenOper_is_admin(_username):
|
if not self.tokenOper_is_admin(_username):
|
||||||
raise Exception('Permission denied.')
|
raise DbException('Permission denied.')
|
||||||
|
|
||||||
# delete
|
# delete
|
||||||
self.cursor.execute('DELETE FROM user WHERE [ccn_name] = ?;', (username, ))
|
cursor.execute('DELETE FROM user WHERE [name] = ?;', (username, ))
|
||||||
if self.cursor.rowcount != 1:
|
if cursor.rowcount != 1:
|
||||||
raise Exception('Fail to delete due to no matched rows or too much rows.')
|
raise DbException('Fail to delete due to no matched rows or too much rows.')
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# =============================== profile
|
# =============================== profile
|
||||||
@SafeDatabaseOperation
|
@SafeDatabaseOperation
|
||||||
def profile_isAdmin(self, token):
|
def profile_isAdmin(self, token):
|
||||||
|
cursor = self._get_cursor()
|
||||||
username = self.tokenOper_get_username(token)
|
username = self.tokenOper_get_username(token)
|
||||||
return self.tokenOper_is_admin(username)
|
return self.tokenOper_is_admin(username)
|
||||||
|
|
||||||
@SafeDatabaseOperation
|
@SafeDatabaseOperation
|
||||||
def profile_changePassword(self, token, newpassword):
|
def profile_changePassword(self, token, newpassword):
|
||||||
|
cursor = self._get_cursor()
|
||||||
username = self.tokenOper_get_username(token)
|
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),
|
utils.ComputePasswordHash(newpassword),
|
||||||
username
|
username
|
||||||
))
|
))
|
||||||
@@ -588,23 +684,25 @@ class CalendarDatabase:
|
|||||||
|
|
||||||
@SafeDatabaseOperation
|
@SafeDatabaseOperation
|
||||||
def profile_getToken(self, token):
|
def profile_getToken(self, token):
|
||||||
|
cursor = self._get_cursor()
|
||||||
username = self.tokenOper_get_username(token)
|
username = self.tokenOper_get_username(token)
|
||||||
|
|
||||||
self.cursor.execute('SELECT * FROM token WHERE [ccn_user] = ?;', (
|
cursor.execute('SELECT * FROM token WHERE [user] = ?;', (
|
||||||
username,
|
username,
|
||||||
))
|
))
|
||||||
return self.cursor.fetchall()
|
return cursor.fetchall()
|
||||||
|
|
||||||
@SafeDatabaseOperation
|
@SafeDatabaseOperation
|
||||||
def profile_deleteToken(self, token, deleteToken):
|
def profile_deleteToken(self, token, deleteToken):
|
||||||
|
cursor = self._get_cursor()
|
||||||
_username = self.tokenOper_get_username(token)
|
_username = self.tokenOper_get_username(token)
|
||||||
|
|
||||||
# delete
|
# delete
|
||||||
self.cursor.execute('DELETE FROM token WHERE [ccn_user] = ? AND [ccn_token] = ?;', (
|
cursor.execute('DELETE FROM token WHERE [user] = ? AND [token] = ?;', (
|
||||||
_username,
|
_username,
|
||||||
deleteToken
|
deleteToken
|
||||||
))
|
))
|
||||||
if self.cursor.rowcount != 1:
|
if cursor.rowcount != 1:
|
||||||
raise Exception('Fail to delete due to no matched rows or too much rows.')
|
raise DbException('Fail to delete due to no matched rows or too much rows.')
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
+408
-352
@@ -1,457 +1,513 @@
|
|||||||
from flask import Flask
|
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 request
|
||||||
# from flask import abort
|
from dataclasses import dataclass
|
||||||
from flask import redirect
|
from typing import Any, Callable, ParamSpec, TypeVar, Generic
|
||||||
|
|
||||||
# from functools import reduce
|
|
||||||
# import json
|
|
||||||
# import os
|
|
||||||
|
|
||||||
import config
|
import config
|
||||||
import database
|
import database
|
||||||
import utils
|
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__)
|
||||||
app = Flask(
|
|
||||||
__name__,
|
|
||||||
static_folder=_FRONTEND_PATH / "static",
|
|
||||||
template_folder=_FRONTEND_PATH / "templates",
|
|
||||||
)
|
|
||||||
calendar_db = database.CalendarDatabase()
|
calendar_db = database.CalendarDatabase()
|
||||||
|
|
||||||
# render_static_resources = None
|
# region: API Route
|
||||||
|
|
||||||
# =============================================database
|
# region: Common
|
||||||
|
|
||||||
# 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()
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================= static page route
|
@app.route("/common/salt", methods=["POST"])
|
||||||
|
|
||||||
@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'])
|
|
||||||
def api_common_saltHandle():
|
def api_common_saltHandle():
|
||||||
return SmartDbCaller(calendar_db.common_salt,
|
return SmartDbCaller(
|
||||||
(('username', str, False), ),
|
calendar_db.common_salt, (FormField("username", str, False),), None
|
||||||
None)
|
)
|
||||||
|
|
||||||
@app.route('/api/common/login', methods=['POST'])
|
|
||||||
|
@app.route("/common/login", methods=["POST"])
|
||||||
def api_common_loginHandle():
|
def api_common_loginHandle():
|
||||||
# construct client data first
|
clientInfo = FetchClientNetworkInfo()
|
||||||
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
|
|
||||||
|
|
||||||
return SmartDbCaller(calendar_db.common_login,
|
return SmartDbCaller(
|
||||||
(('username', str, False),
|
calendar_db.common_login,
|
||||||
('password', str, False),
|
(
|
||||||
('clientUa', str, False),
|
FormField("username", str, False),
|
||||||
('clientIp', str, False)),
|
FormField("password", str, False),
|
||||||
{
|
FormField("clientUa", str, False),
|
||||||
'clientUa': clientUa,
|
FormField("clientIp", str, False),
|
||||||
'clientIp': clientIp
|
),
|
||||||
})
|
{"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():
|
def api_common_webLoginHandle():
|
||||||
# construct client data first
|
clientInfo = FetchClientNetworkInfo()
|
||||||
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
|
|
||||||
|
|
||||||
return SmartDbCaller(calendar_db.common_webLogin,
|
return SmartDbCaller(
|
||||||
(('username', str, False),
|
calendar_db.common_webLogin,
|
||||||
('password', str, False),
|
(
|
||||||
('clientUa', str, False),
|
FormField("username", str, False),
|
||||||
('clientIp', str, False)),
|
FormField("password", str, False),
|
||||||
{
|
FormField("clientUa", str, False),
|
||||||
'clientUa': clientUa,
|
FormField("clientIp", str, False),
|
||||||
'clientIp': clientIp
|
),
|
||||||
})
|
{"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():
|
def api_common_logoutHandle():
|
||||||
return SmartDbCaller(calendar_db.common_logout,
|
return SmartDbCaller(
|
||||||
(('token', str, False), ),
|
calendar_db.common_logout, (FormField("token", str, False),), None
|
||||||
None)
|
)
|
||||||
|
|
||||||
@app.route('/api/common/tokenValid', methods=['POST'])
|
|
||||||
|
@app.route("/common/tokenValid", methods=["POST"])
|
||||||
def api_common_tokenValidHandle():
|
def api_common_tokenValidHandle():
|
||||||
return SmartDbCaller(calendar_db.common_tokenValid,
|
return SmartDbCaller(
|
||||||
(('token', str, False), ),
|
calendar_db.common_tokenValid, (FormField("token", str, False),), None
|
||||||
None)
|
)
|
||||||
|
|
||||||
# ================================ calendar
|
|
||||||
|
|
||||||
@app.route('/api/calendar/getFull', methods=['POST'])
|
# endregion
|
||||||
|
|
||||||
|
# region: Calendar
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/calendar/getFull", methods=["POST"])
|
||||||
def api_calendar_getFullHandle():
|
def api_calendar_getFullHandle():
|
||||||
return SmartDbCaller(calendar_db.calendar_getFull,
|
return SmartDbCaller(
|
||||||
(('token', str, False),
|
calendar_db.calendar_getFull,
|
||||||
('startDateTime', int, False),
|
(
|
||||||
('endDateTime', int, False)),
|
FormField("token", str, False),
|
||||||
None)
|
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():
|
def api_calendar_getListHandle():
|
||||||
return SmartDbCaller(calendar_db.calendar_getList,
|
return SmartDbCaller(
|
||||||
(('token', str, False),
|
calendar_db.calendar_getList,
|
||||||
('startDateTime', int, False),
|
(
|
||||||
('endDateTime', int, False)),
|
FormField("token", str, False),
|
||||||
None)
|
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():
|
def api_calendar_getDetailHandle():
|
||||||
return SmartDbCaller(calendar_db.calendar_getDetail,
|
return SmartDbCaller(
|
||||||
(('token', str, False),
|
calendar_db.calendar_getDetail,
|
||||||
('uuid', str, False)),
|
(FormField("token", str, False), FormField("uuid", str, False)),
|
||||||
None)
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
@app.route('/api/calendar/update', methods=['POST'])
|
|
||||||
|
@app.route("/calendar/update", methods=["POST"])
|
||||||
def api_calendar_updateHandle():
|
def api_calendar_updateHandle():
|
||||||
return SmartDbCaller(calendar_db.calendar_update,
|
return SmartDbCaller(
|
||||||
(('token', str, False),
|
calendar_db.calendar_update,
|
||||||
('uuid', str, False),
|
(
|
||||||
('belongTo', str, True),
|
FormField("token", str, False),
|
||||||
('title', str, True),
|
FormField("uuid", str, False),
|
||||||
('description', str, True),
|
FormField("belongTo", str, True),
|
||||||
('eventDateTimeStart', int, True),
|
FormField("title", str, True),
|
||||||
('eventDateTimeEnd', int, True),
|
FormField("description", str, True),
|
||||||
('loopRules', str, True),
|
FormField("eventDateTimeStart", int, True),
|
||||||
('timezoneOffset', int, True),
|
FormField("eventDateTimeEnd", int, True),
|
||||||
('lastChange', str, False)),
|
FormField("loopRules", str, True),
|
||||||
None)
|
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():
|
def api_calendar_addHandle():
|
||||||
return SmartDbCaller(calendar_db.calendar_add,
|
return SmartDbCaller(
|
||||||
(('token', str, False),
|
calendar_db.calendar_add,
|
||||||
('belongTo', str, False),
|
(
|
||||||
('title', str, False),
|
FormField("token", str, False),
|
||||||
('description', str, False),
|
FormField("belongTo", str, False),
|
||||||
('eventDateTimeStart', int, False),
|
FormField("title", str, False),
|
||||||
('eventDateTimeEnd', int, False),
|
FormField("description", str, False),
|
||||||
('loopRules', str, False),
|
FormField("eventDateTimeStart", int, False),
|
||||||
('timezoneOffset', int, False)),
|
FormField("eventDateTimeEnd", int, False),
|
||||||
None)
|
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():
|
def api_calendar_deleteHandle():
|
||||||
return SmartDbCaller(calendar_db.calendar_delete,
|
return SmartDbCaller(
|
||||||
(('token', str, False),
|
calendar_db.calendar_delete,
|
||||||
('uuid', str, False),
|
(
|
||||||
('lastChange', str, False)),
|
FormField("token", str, False),
|
||||||
None)
|
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():
|
def api_collection_getFullOwnHandle():
|
||||||
return SmartDbCaller(calendar_db.collection_getFullOwn,
|
return SmartDbCaller(
|
||||||
(('token', str, False), ),
|
calendar_db.collection_getFullOwn, (FormField("token", str, False),), None
|
||||||
None)
|
)
|
||||||
|
|
||||||
@app.route('/api/collection/getListOwn', methods=['POST'])
|
|
||||||
|
@app.route("/collection/getListOwn", methods=["POST"])
|
||||||
def api_collection_getListOwnHandle():
|
def api_collection_getListOwnHandle():
|
||||||
return SmartDbCaller(calendar_db.collection_getListOwn,
|
return SmartDbCaller(
|
||||||
(('token', str, False), ),
|
calendar_db.collection_getListOwn, (FormField("token", str, False),), None
|
||||||
None)
|
)
|
||||||
|
|
||||||
@app.route('/api/collection/getDetailOwn', methods=['POST'])
|
|
||||||
|
@app.route("/collection/getDetailOwn", methods=["POST"])
|
||||||
def api_collection_getDetailOwnHandle():
|
def api_collection_getDetailOwnHandle():
|
||||||
return SmartDbCaller(calendar_db.collection_getDetailOwn,
|
return SmartDbCaller(
|
||||||
(('token', str, False),
|
calendar_db.collection_getDetailOwn,
|
||||||
('uuid', str, False)),
|
(FormField("token", str, False), FormField("uuid", str, False)),
|
||||||
None)
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
@app.route('/api/collection/addOwn', methods=['POST'])
|
|
||||||
|
@app.route("/collection/addOwn", methods=["POST"])
|
||||||
def api_collection_addOwnHandle():
|
def api_collection_addOwnHandle():
|
||||||
return SmartDbCaller(calendar_db.collection_addOwn,
|
return SmartDbCaller(
|
||||||
(('token', str, False),
|
calendar_db.collection_addOwn,
|
||||||
('name', str, False)),
|
(FormField("token", str, False), FormField("name", str, False)),
|
||||||
None)
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
@app.route('/api/collection/updateOwn', methods=['POST'])
|
|
||||||
|
@app.route("/collection/updateOwn", methods=["POST"])
|
||||||
def api_collection_updateOwnHandle():
|
def api_collection_updateOwnHandle():
|
||||||
return SmartDbCaller(calendar_db.collection_updateOwn,
|
return SmartDbCaller(
|
||||||
(('token', str, False),
|
calendar_db.collection_updateOwn,
|
||||||
('uuid', str, False),
|
(
|
||||||
('name', str, False),
|
FormField("token", str, False),
|
||||||
('lastChange', str, False)),
|
FormField("uuid", str, False),
|
||||||
None)
|
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():
|
def api_collection_deleteOwnHandle():
|
||||||
return SmartDbCaller(calendar_db.collection_deleteOwn,
|
return SmartDbCaller(
|
||||||
(('token', str, False),
|
calendar_db.collection_deleteOwn,
|
||||||
('uuid', str, False),
|
(
|
||||||
('lastChange', str, False)),
|
FormField("token", str, False),
|
||||||
None)
|
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():
|
def api_collection_getSharingHandle():
|
||||||
return SmartDbCaller(calendar_db.collection_getSharing,
|
return SmartDbCaller(
|
||||||
(('token', str, False),
|
calendar_db.collection_getSharing,
|
||||||
('uuid', str, False)),
|
(FormField("token", str, False), FormField("uuid", str, False)),
|
||||||
None)
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
@app.route('/api/collection/deleteSharing', methods=['POST'])
|
|
||||||
|
@app.route("/collection/deleteSharing", methods=["POST"])
|
||||||
def api_collection_deleteSharingHandle():
|
def api_collection_deleteSharingHandle():
|
||||||
return SmartDbCaller(calendar_db.collection_deleteSharing,
|
return SmartDbCaller(
|
||||||
(('token', str, False),
|
calendar_db.collection_deleteSharing,
|
||||||
('uuid', str, False),
|
(
|
||||||
('target', str, False),
|
FormField("token", str, False),
|
||||||
('lastChange', str, False)),
|
FormField("uuid", str, False),
|
||||||
None)
|
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():
|
def api_collection_addSharingHandle():
|
||||||
return SmartDbCaller(calendar_db.collection_addSharing,
|
return SmartDbCaller(
|
||||||
(('token', str, False),
|
calendar_db.collection_addSharing,
|
||||||
('uuid', str, False),
|
(
|
||||||
('target', str, False),
|
FormField("token", str, False),
|
||||||
('lastChange', str, False)),
|
FormField("uuid", str, False),
|
||||||
None)
|
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():
|
def api_collection_getSharedHandle():
|
||||||
return SmartDbCaller(calendar_db.collection_getShared,
|
return SmartDbCaller(
|
||||||
(('token', str, False), ),
|
calendar_db.collection_getShared, (FormField("token", str, False),), None
|
||||||
None)
|
)
|
||||||
|
|
||||||
# ================================ todo
|
|
||||||
|
|
||||||
@app.route('/api/todo/getFull', methods=['POST'])
|
# endregion
|
||||||
|
|
||||||
|
# region: Todo
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/todo/getFull", methods=["POST"])
|
||||||
def api_todo_getFullHandle():
|
def api_todo_getFullHandle():
|
||||||
return SmartDbCaller(calendar_db.todo_getFull,
|
return SmartDbCaller(
|
||||||
(('token', str, False), ),
|
calendar_db.todo_getFull, (FormField("token", str, False),), None
|
||||||
None)
|
)
|
||||||
|
|
||||||
@app.route('/api/todo/getList', methods=['POST'])
|
|
||||||
|
@app.route("/todo/getList", methods=["POST"])
|
||||||
def api_todo_getListHandle():
|
def api_todo_getListHandle():
|
||||||
return SmartDbCaller(calendar_db.todo_getList,
|
return SmartDbCaller(
|
||||||
(('token', str, False), ),
|
calendar_db.todo_getList, (FormField("token", str, False),), None
|
||||||
None)
|
)
|
||||||
|
|
||||||
@app.route('/api/todo/getDetail', methods=['POST'])
|
|
||||||
|
@app.route("/todo/getDetail", methods=["POST"])
|
||||||
def api_todo_getDetailHandle():
|
def api_todo_getDetailHandle():
|
||||||
return SmartDbCaller(calendar_db.todo_getDetail,
|
return SmartDbCaller(
|
||||||
(('token', str, False),
|
calendar_db.todo_getDetail,
|
||||||
('uuid', str, False)),
|
(FormField("token", str, False), FormField("uuid", str, False)),
|
||||||
None)
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
@app.route('/api/todo/add', methods=['POST'])
|
|
||||||
|
@app.route("/todo/add", methods=["POST"])
|
||||||
def api_todo_addHandle():
|
def api_todo_addHandle():
|
||||||
return SmartDbCaller(calendar_db.todo_add,
|
return SmartDbCaller(calendar_db.todo_add, (FormField("token", str, False),), None)
|
||||||
(('token', str, False), ),
|
|
||||||
None)
|
|
||||||
|
|
||||||
@app.route('/api/todo/update', methods=['POST'])
|
|
||||||
|
@app.route("/todo/update", methods=["POST"])
|
||||||
def api_todo_updateHandle():
|
def api_todo_updateHandle():
|
||||||
return SmartDbCaller(calendar_db.todo_update,
|
return SmartDbCaller(
|
||||||
(('token', str, False),
|
calendar_db.todo_update,
|
||||||
('uuid', str, False),
|
(
|
||||||
('data', str, False),
|
FormField("token", str, False),
|
||||||
('lastChange', str, False)),
|
FormField("uuid", str, False),
|
||||||
None)
|
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():
|
def api_todo_deleteHandle():
|
||||||
return SmartDbCaller(calendar_db.todo_delete,
|
return SmartDbCaller(
|
||||||
(('token', str, False),
|
calendar_db.todo_delete,
|
||||||
('uuid', str, False),
|
(
|
||||||
('lastChange', str, False)),
|
FormField("token", str, False),
|
||||||
None)
|
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():
|
def api_admin_getHandle():
|
||||||
return SmartDbCaller(calendar_db.admin_get,
|
return SmartDbCaller(calendar_db.admin_get, (FormField("token", str, False),), None)
|
||||||
(('token', str, False), ),
|
|
||||||
None)
|
|
||||||
|
|
||||||
@app.route('/api/admin/add', methods=['POST'])
|
|
||||||
|
@app.route("/admin/add", methods=["POST"])
|
||||||
def api_admin_addHandle():
|
def api_admin_addHandle():
|
||||||
return SmartDbCaller(calendar_db.admin_add,
|
return SmartDbCaller(
|
||||||
(('token', str, False),
|
calendar_db.admin_add,
|
||||||
('username', str, False)),
|
(FormField("token", str, False), FormField("username", str, False)),
|
||||||
None)
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
@app.route('/api/admin/update', methods=['POST'])
|
|
||||||
|
@app.route("/admin/update", methods=["POST"])
|
||||||
def api_admin_updateHandle():
|
def api_admin_updateHandle():
|
||||||
return SmartDbCaller(calendar_db.admin_update,
|
return SmartDbCaller(
|
||||||
(('token', str, False),
|
calendar_db.admin_update,
|
||||||
('username', str, False),
|
(
|
||||||
('password', str, True),
|
FormField("token", str, False),
|
||||||
('isAdmin', utils.Str2Bool, True)),
|
FormField("username", str, False),
|
||||||
None)
|
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():
|
def api_admin_deleteHandle():
|
||||||
return SmartDbCaller(calendar_db.admin_delete,
|
return SmartDbCaller(
|
||||||
(('token', str, False),
|
calendar_db.admin_delete,
|
||||||
('username', str, False)),
|
(FormField("token", str, False), FormField("username", str, False)),
|
||||||
None)
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
# ================================ profile
|
|
||||||
|
|
||||||
@app.route('/api/profile/isAdmin', methods=['POST'])
|
# endregion
|
||||||
|
|
||||||
|
# region: Profile
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/profile/isAdmin", methods=["POST"])
|
||||||
def api_profile_isAdminHandle():
|
def api_profile_isAdminHandle():
|
||||||
return SmartDbCaller(calendar_db.profile_isAdmin,
|
return SmartDbCaller(
|
||||||
(('token', str, False), ),
|
calendar_db.profile_isAdmin, (FormField("token", str, False),), None
|
||||||
None)
|
)
|
||||||
|
|
||||||
@app.route('/api/profile/changePassword', methods=['POST'])
|
|
||||||
|
@app.route("/profile/changePassword", methods=["POST"])
|
||||||
def api_profile_changePasswordHandle():
|
def api_profile_changePasswordHandle():
|
||||||
return SmartDbCaller(calendar_db.profile_changePassword,
|
return SmartDbCaller(
|
||||||
(('token', str, False),
|
calendar_db.profile_changePassword,
|
||||||
('password', str, False)),
|
(FormField("token", str, False), FormField("password", str, False)),
|
||||||
None)
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
@app.route('/api/profile/getToken', methods=['POST'])
|
|
||||||
|
@app.route("/profile/getToken", methods=["POST"])
|
||||||
def api_profile_getTokenHandle():
|
def api_profile_getTokenHandle():
|
||||||
return SmartDbCaller(calendar_db.profile_getToken,
|
return SmartDbCaller(
|
||||||
(('token', str, False), ),
|
calendar_db.profile_getToken, (FormField("token", str, False),), None
|
||||||
None)
|
)
|
||||||
|
|
||||||
@app.route('/api/profile/deleteToken', methods=['POST'])
|
|
||||||
|
@app.route("/profile/deleteToken", methods=["POST"])
|
||||||
def api_profile_deleteTokenHandle():
|
def api_profile_deleteTokenHandle():
|
||||||
return SmartDbCaller(calendar_db.profile_deleteToken,
|
return SmartDbCaller(
|
||||||
(('token', str, False),
|
calendar_db.profile_deleteToken,
|
||||||
('deleteToken', str, False)),
|
(FormField("token", str, False), FormField("deleteToken", str, False)),
|
||||||
None)
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
# =============================================main run
|
|
||||||
|
|
||||||
'''
|
# endregion
|
||||||
def UpdateStaticResources():
|
|
||||||
global render_static_resources
|
|
||||||
if render_static_resources is not None:
|
|
||||||
return
|
|
||||||
|
|
||||||
render_static_resources = {
|
# endregion
|
||||||
'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'),
|
|
||||||
|
|
||||||
'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):
|
@dataclass(frozen=True)
|
||||||
result = (False, 'Invalid parameter', None)
|
class ClientNetworkInfo:
|
||||||
optCount = 0
|
user_agent: str
|
||||||
paramList = []
|
"""The user agent of client."""
|
||||||
optParamDict = {}
|
ip_addr: str
|
||||||
# for each item,
|
"""The IP address of client."""
|
||||||
# item[0] is field name.
|
|
||||||
# item[1] is type.
|
|
||||||
# item[2] is whether it is optional field
|
def FetchClientNetworkInfo() -> ClientNetworkInfo:
|
||||||
realForm = request.form.to_dict()
|
clientUa = request.user_agent.string
|
||||||
if extraDict is not None:
|
forwardIpList = request.headers.getlist("X-Forwarded-For")
|
||||||
realForm.update(extraDict)
|
if forwardIpList:
|
||||||
for item in paramTuple:
|
clientIp = forwardIpList[0]
|
||||||
cache = item[1](realForm.get(item[0], None))
|
|
||||||
if item[2]:
|
|
||||||
# optional param
|
|
||||||
if cache is not None:
|
|
||||||
optParamDict[item[0]] = cache
|
|
||||||
optCount += 1
|
|
||||||
else:
|
|
||||||
if cache is None:
|
|
||||||
break
|
|
||||||
paramList.append(cache)
|
|
||||||
else:
|
else:
|
||||||
# at least one opt param
|
directIp = request.remote_addr
|
||||||
if optCount == 0 or len(optParamDict) != 0:
|
if directIp is not None:
|
||||||
result = dbMethod(*paramList, **optParamDict)
|
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 value is not None:
|
||||||
|
opt_param_dict[field.name] = value
|
||||||
|
opt_param_counter += 1
|
||||||
|
else:
|
||||||
|
# required param
|
||||||
|
if value is None:
|
||||||
|
lost_required = True
|
||||||
|
else:
|
||||||
|
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)
|
return ConstructResponseBody(result)
|
||||||
|
|
||||||
def ConstructResponseBody(returnedTuple):
|
|
||||||
return {
|
def ConstructResponseBody(body: ResponseBody[Any]) -> dict[str, Any]:
|
||||||
'success': returnedTuple[0],
|
return {"success": body.success, "error": body.error, "data": body.data}
|
||||||
'error': returnedTuple[1],
|
|
||||||
'data': returnedTuple[2]
|
|
||||||
}
|
# endregion
|
||||||
|
|
||||||
|
|
||||||
def run():
|
def run():
|
||||||
calendar_db.open()
|
calendar_db.open()
|
||||||
app.run(port=config.get_config().web.port)
|
app.run(port=config.get_config().web.port)
|
||||||
calendar_db.close()
|
calendar_db.close()
|
||||||
|
|
||||||
+40
-40
@@ -1,67 +1,67 @@
|
|||||||
CREATE TABLE user(
|
CREATE TABLE user(
|
||||||
[ccn_name] TEXT NOT NULL,
|
[name] TEXT NOT NULL,
|
||||||
[ccn_password] TEXT NOT NULL,
|
[password] TEXT NOT NULL,
|
||||||
[ccn_isAdmin] TINYINT NOT NULL CHECK(ccn_isAdmin = 1 OR ccn_isAdmin = 0),
|
[is_admin] TINYINT NOT NULL CHECK(is_admin = 1 OR is_admin = 0),
|
||||||
[ccn_salt] INTEGER NOT NULL,
|
[salt] INTEGER NOT NULL,
|
||||||
|
|
||||||
PRIMARY KEY (ccn_name)
|
PRIMARY KEY (name)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE token(
|
CREATE TABLE token(
|
||||||
[ccn_user] TEXT NOT NULL,
|
[user] TEXT NOT NULL,
|
||||||
[ccn_token] TEXT UNIQUE NOT NULL,
|
[token] TEXT UNIQUE NOT NULL,
|
||||||
[ccn_tokenExpireOn] BIGINT NOT NULL,
|
[token_expire_on] BIGINT NOT NULL,
|
||||||
[ccn_ua] TEXT NOT NULL,
|
[ua] TEXT NOT NULL,
|
||||||
[ccn_ip] 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(
|
CREATE TABLE collection(
|
||||||
[ccn_uuid] TEXT NOT NULL,
|
[uuid] TEXT NOT NULL,
|
||||||
[ccn_name] TEXT NOT NULL,
|
[name] TEXT NOT NULL,
|
||||||
[ccn_user] TEXT NOT NULL,
|
[user] TEXT NOT NULL,
|
||||||
[ccn_lastChange] TEXT NOT NULL,
|
[last_change] TEXT NOT NULL,
|
||||||
|
|
||||||
PRIMARY KEY (ccn_uuid),
|
PRIMARY KEY (uuid),
|
||||||
FOREIGN KEY (ccn_user) REFERENCES user(ccn_name) ON DELETE CASCADE
|
FOREIGN KEY (user) REFERENCES user(name) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE share(
|
CREATE TABLE share(
|
||||||
[ccn_uuid] TEXT NOT NULL,
|
[uuid] TEXT NOT NULL,
|
||||||
[ccn_target] TEXT NOT NULL,
|
[target] TEXT NOT NULL,
|
||||||
|
|
||||||
FOREIGN KEY (ccn_uuid) REFERENCES collection(ccn_uuid) ON DELETE CASCADE
|
FOREIGN KEY (uuid) REFERENCES collection(uuid) ON DELETE CASCADE
|
||||||
FOREIGN KEY (ccn_target) REFERENCES user(ccn_name) ON DELETE CASCADE
|
FOREIGN KEY (target) REFERENCES user(name) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE calendar(
|
CREATE TABLE calendar(
|
||||||
[ccn_uuid] TEXT NOT NULL,
|
[uuid] TEXT NOT NULL,
|
||||||
[ccn_belongTo] TEXT NOT NULL,
|
[belong_to] TEXT NOT NULL,
|
||||||
|
|
||||||
[ccn_title] TEXT NOT NULL,
|
[title] TEXT NOT NULL,
|
||||||
[ccn_description] TEXT NOT NULL,
|
[description] TEXT NOT NULL,
|
||||||
[ccn_lastChange] TEXT NOT NULL,
|
[last_change] TEXT NOT NULL,
|
||||||
|
|
||||||
[ccn_eventDateTimeStart] BIGINT NOT NULL,
|
[event_date_time_start] BIGINT NOT NULL,
|
||||||
[ccn_eventDateTimeEnd] BIGINT NOT NULL,
|
[event_date_time_end] BIGINT NOT NULL,
|
||||||
[ccn_timezoneOffset] INT NOT NULL,
|
[timezone_offset] INT NOT NULL,
|
||||||
|
|
||||||
[ccn_loopRules] TEXT NOT NULL,
|
[loop_rules] TEXT NOT NULL,
|
||||||
[ccn_loopDateTimeStart] BIGINT NOT NULL,
|
[loop_date_time_start] BIGINT NOT NULL,
|
||||||
[ccn_loopDateTimeEnd] BIGINT NOT NULL,
|
[loop_date_time_end] BIGINT NOT NULL,
|
||||||
|
|
||||||
PRIMARY KEY (ccn_uuid),
|
PRIMARY KEY (uuid),
|
||||||
FOREIGN KEY (ccn_belongTo) REFERENCES collection(ccn_uuid) ON DELETE CASCADE
|
FOREIGN KEY (belong_to) REFERENCES collection(uuid) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE todo(
|
CREATE TABLE todo(
|
||||||
[ccn_uuid] TEXT NOT NULL,
|
[uuid] TEXT NOT NULL,
|
||||||
[ccn_belongTo] TEXT NOT NULL,
|
[belong_to] TEXT NOT NULL,
|
||||||
|
|
||||||
[ccn_data] TEXT NOT NULL,
|
[data] TEXT NOT NULL,
|
||||||
[ccn_lastChange] TEXT NOT NULL,
|
[last_change] TEXT NOT NULL,
|
||||||
|
|
||||||
PRIMARY KEY (ccn_uuid),
|
PRIMARY KEY (uuid),
|
||||||
FOREIGN KEY (ccn_belongTo) REFERENCES user(ccn_name) ON DELETE CASCADE
|
FOREIGN KEY (belong_to) REFERENCES user(name) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
+35
-23
@@ -3,50 +3,62 @@ import random
|
|||||||
import uuid
|
import uuid
|
||||||
import time
|
import time
|
||||||
import math
|
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)))
|
USERNAME_PATTERN: re.Pattern = re.compile("^[0-9A-Za-z]+$")
|
||||||
ValidPassword = set(map(lambda x:chr(x), range(33, 127, 1)))
|
PASSWORD_PATTERN: re.Pattern = re.compile("^[!-~]+$")
|
||||||
|
|
||||||
def IsValidUsername(strl):
|
|
||||||
return (len(set(strl) - ValidUsername) == 0)
|
|
||||||
|
|
||||||
def IsValidPassword(strl):
|
def IsValidUsername(strl: str) -> bool:
|
||||||
return (len(set(strl) - ValidPassword) == 0)
|
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 = hashlib.sha256()
|
||||||
s.update(password.encode('utf-8'))
|
s.update(password.encode("utf-8"))
|
||||||
return s.hexdigest()
|
return s.hexdigest()
|
||||||
|
|
||||||
def GenerateUUID():
|
|
||||||
|
def GenerateUUID() -> str:
|
||||||
return str(uuid.uuid1())
|
return str(uuid.uuid1())
|
||||||
|
|
||||||
def GenerateToken(username):
|
|
||||||
|
def GenerateToken(username: str) -> str:
|
||||||
s = hashlib.sha256()
|
s = hashlib.sha256()
|
||||||
s.update(username.encode('utf-8'))
|
s.update(username.encode("utf-8"))
|
||||||
s.update(GenerateUUID().encode('utf-8'))
|
s.update(GenerateUUID().encode("utf-8"))
|
||||||
return s.hexdigest()
|
return s.hexdigest()
|
||||||
|
|
||||||
def GenerateSalt():
|
|
||||||
|
def GenerateSalt() -> int:
|
||||||
return random.randint(0, 6172748)
|
return random.randint(0, 6172748)
|
||||||
|
|
||||||
def ComputePasswordHashWithSalt(passwordHashed, salt):
|
|
||||||
|
def ComputePasswordHashWithSalt(passwordHashed: str, salt: int) -> str:
|
||||||
s = hashlib.sha256()
|
s = hashlib.sha256()
|
||||||
s.update((passwordHashed + str(salt)).encode('utf-8'))
|
s.update((passwordHashed + str(salt)).encode("utf-8"))
|
||||||
return s.hexdigest()
|
return s.hexdigest()
|
||||||
|
|
||||||
def GetCurrentTimestamp():
|
|
||||||
|
def GetCurrentTimestamp() -> int:
|
||||||
return int(time.time())
|
return int(time.time())
|
||||||
|
|
||||||
def GetTokenExpireOn():
|
|
||||||
return GetCurrentTimestamp() + 60 * 60 * 24 * 2 # add 2 day from now
|
|
||||||
|
|
||||||
def Str2Bool(strl):
|
def GetTokenExpireOn() -> int:
|
||||||
return strl.lower() == 'true'
|
return GetCurrentTimestamp() + 60 * 60 * 24 * 2 # add 2 day from now
|
||||||
|
|
||||||
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)
|
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 |
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue"],
|
||||||
|
"env": {
|
||||||
|
"browser": true
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"correctness": "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
```
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -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'),
|
||||||
|
)
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+3584
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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');
|
||||||
@@ -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
|
||||||
@@ -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'],
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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'],
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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'
|
||||||
|
];
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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 "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<script setup lang="ts"></script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<h1>Congratulations</h1>
|
||||||
|
<p>This is admin.</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -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
Reference in New Issue
Block a user