Compare commits
27
Commits
v1.0
...
b3efb196ed
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3efb196ed | ||
|
|
26dce7ce80 | ||
|
|
0f674d0483 | ||
|
|
e6fc06c0f9 | ||
|
|
91317c9eb7 | ||
|
|
b1b42ab2fb | ||
|
|
18f73b3084 | ||
|
|
fcc022d070 | ||
|
|
fda2fe9ec9 | ||
|
|
ebe7ea5f56 | ||
|
|
c4b68400b3 | ||
|
|
d7df194a12 | ||
|
|
35fee0f473 | ||
|
|
e484ded5be | ||
|
|
2a280dcba0 | ||
|
|
6337ae432d | ||
|
|
826cbf18b1 | ||
|
|
167c83f7d4 | ||
|
|
078e61e993 | ||
|
|
bdee3b3efa | ||
|
|
37b08927a7 | ||
|
|
46f2d69800 | ||
|
|
24790d8e69 | ||
|
|
07205396c8 | ||
|
|
433c6cf2f8 | ||
|
|
37435eeb66 | ||
|
|
a0e3385670 |
+3
-16
@@ -1,16 +1,3 @@
|
||||
# ignore sqlite db
|
||||
*.db
|
||||
|
||||
# ignore my debug setting
|
||||
*.cfg
|
||||
|
||||
# ignore py cache
|
||||
src/__pycache__
|
||||
|
||||
# ignore any image first
|
||||
*.png
|
||||
*.jpg
|
||||
*.gif
|
||||
|
||||
# elimate vscode
|
||||
.vscode
|
||||
## ======== Personal ========
|
||||
# Ignore VSCode
|
||||
.vscode/
|
||||
|
||||
+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
|
||||
@@ -0,0 +1,19 @@
|
||||
## ======== Personal ========
|
||||
# Database file
|
||||
*.db
|
||||
|
||||
# Ignore setting file
|
||||
coconut-leaf.toml
|
||||
|
||||
## ======== Python ========
|
||||
# Python-generated files
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
3.11
|
||||
@@ -0,0 +1,84 @@
|
||||
import sys
|
||||
from argparse import ArgumentParser
|
||||
from typing import cast
|
||||
from pathlib import Path
|
||||
|
||||
import server
|
||||
import config
|
||||
import utils
|
||||
import database
|
||||
import logger
|
||||
from logger import LOGGER, LoggerLevel
|
||||
|
||||
|
||||
def GetUsernamePassword() -> tuple[str, str]:
|
||||
print("What is the first username of this calendar system?")
|
||||
cache = input()
|
||||
while not utils.IsValidUsername(cache):
|
||||
print("Sorry, invalid data. Please try again.")
|
||||
cache = input()
|
||||
username = cache
|
||||
|
||||
print("Input this user password:")
|
||||
cache = input()
|
||||
while not utils.IsValidPassword(cache):
|
||||
print("Sorry, invalid data. Please try again.")
|
||||
cache = input()
|
||||
password = cache
|
||||
|
||||
return (username, password)
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Set as INFO level in default first,
|
||||
# and we will change it once we load the configuration file.
|
||||
logger.set_level(LoggerLevel.INFO)
|
||||
|
||||
# Receive arguments
|
||||
parser = ArgumentParser(
|
||||
description="The server of light, self-host and multi-account calendar system."
|
||||
)
|
||||
parser.add_argument(
|
||||
"-c",
|
||||
"--config",
|
||||
required=True,
|
||||
type=Path,
|
||||
action="store",
|
||||
metavar="CONFIG_TOML",
|
||||
dest="config",
|
||||
help="The configuration file for coconut-leaf",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-i",
|
||||
"--init",
|
||||
action="store_true",
|
||||
dest="init",
|
||||
help="Set for initialize the calendar system",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Show splash
|
||||
LOGGER.info("Coconut-leaf")
|
||||
LOGGER.info("A light, self-host and multi-account calendar system")
|
||||
LOGGER.info("Project: https://github.com/yyc12345/coconut-leaf")
|
||||
LOGGER.info("===================")
|
||||
|
||||
# Load config file
|
||||
try:
|
||||
config.setup_config(cast(Path, args.config))
|
||||
except Exception as e:
|
||||
LOGGER.critical(f"Error loading config file: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Change logging level again according to whether enable debug mode
|
||||
logging_level = LoggerLevel.DEBUG if config.get_config().others.debug else LoggerLevel.INFO
|
||||
logger.set_level(logging_level)
|
||||
|
||||
# Initialize the calendar system if needed
|
||||
if cast(bool, args.init):
|
||||
gotten_data = GetUsernamePassword()
|
||||
calendar = database.CalendarDatabase()
|
||||
calendar.init(*gotten_data)
|
||||
calendar.close()
|
||||
|
||||
LOGGER.info("Staring server...")
|
||||
server.run()
|
||||
@@ -0,0 +1,22 @@
|
||||
[database]
|
||||
driver = "sqlite"
|
||||
|
||||
[database.config]
|
||||
path = "coconut-leaf.db"
|
||||
|
||||
# [database]
|
||||
# driver = "mysql"
|
||||
#
|
||||
# [database.config]
|
||||
# host = "localhost"
|
||||
# port = 3306
|
||||
# user = "root"
|
||||
# password = "password"
|
||||
# database = "coconut_leaf"
|
||||
|
||||
[web]
|
||||
port = 8848
|
||||
|
||||
[others]
|
||||
auto-token-clean-duration = 86400
|
||||
debug = true
|
||||
@@ -0,0 +1,130 @@
|
||||
import tomllib
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class DatabaseDriver(StrEnum):
|
||||
SQLITE = "sqlite"
|
||||
MYSQL = "mysql"
|
||||
|
||||
@staticmethod
|
||||
def from_raw(raw: dict):
|
||||
return DatabaseDriver(raw["driver"])
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SqliteDatabaseConfig:
|
||||
path: str
|
||||
"""Database path"""
|
||||
|
||||
@staticmethod
|
||||
def from_raw(raw: dict):
|
||||
return SqliteDatabaseConfig(raw["path"])
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MysqlDatabaseConfig:
|
||||
host: str
|
||||
"""Database host"""
|
||||
port: int
|
||||
"""Database port"""
|
||||
user: str
|
||||
"""Database user"""
|
||||
password: str
|
||||
"""Database password"""
|
||||
database: str
|
||||
"""Database name"""
|
||||
|
||||
@staticmethod
|
||||
def from_raw(raw: dict):
|
||||
return MysqlDatabaseConfig(
|
||||
raw["host"], raw["port"], raw["user"], raw["password"], raw["database"]
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DatabaseConfig:
|
||||
driver: DatabaseDriver
|
||||
"""Database driver"""
|
||||
config: SqliteDatabaseConfig | MysqlDatabaseConfig
|
||||
"""Database config"""
|
||||
|
||||
@staticmethod
|
||||
def from_raw(raw: dict):
|
||||
if raw["driver"] == DatabaseDriver.SQLITE:
|
||||
return DatabaseConfig(
|
||||
DatabaseDriver.SQLITE, SqliteDatabaseConfig.from_raw(raw["config"])
|
||||
)
|
||||
elif raw["driver"] == DatabaseDriver.MYSQL:
|
||||
return DatabaseConfig(
|
||||
DatabaseDriver.MYSQL, MysqlDatabaseConfig.from_raw(raw["config"])
|
||||
)
|
||||
else:
|
||||
raise ValueError("Invalid database driver")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WebConfig:
|
||||
port: int
|
||||
"""Web server port"""
|
||||
|
||||
@staticmethod
|
||||
def from_raw(raw: dict):
|
||||
return WebConfig(raw["port"])
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OthersConfig:
|
||||
debug: bool
|
||||
"""Whether enable debug mode"""
|
||||
auto_token_clean_duration: int
|
||||
"""Auto token clean duration"""
|
||||
|
||||
@staticmethod
|
||||
def from_raw(raw: dict):
|
||||
return OthersConfig(raw["debug"], raw["auto-token-clean-duration"])
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Config:
|
||||
database: DatabaseConfig
|
||||
web: WebConfig
|
||||
others: OthersConfig
|
||||
|
||||
@staticmethod
|
||||
def from_raw(raw: dict):
|
||||
return Config(
|
||||
database=DatabaseConfig.from_raw(raw["database"]),
|
||||
web=WebConfig.from_raw(raw["web"]),
|
||||
others=OthersConfig.from_raw(raw["others"]),
|
||||
)
|
||||
|
||||
|
||||
_CONFIG: Optional[Config] = None
|
||||
|
||||
|
||||
def setup_config(p: Path) -> None:
|
||||
"""
|
||||
Setup config by given path.
|
||||
|
||||
Raise exception if config file is invalid.
|
||||
"""
|
||||
with open(p, "rb") as f:
|
||||
raw = tomllib.load(f)
|
||||
|
||||
global _CONFIG
|
||||
_CONFIG = Config.from_raw(raw)
|
||||
|
||||
|
||||
def get_config() -> Config:
|
||||
"""
|
||||
Get config instance.
|
||||
|
||||
Raises RuntimeError if config is not loaded.
|
||||
"""
|
||||
if _CONFIG is None:
|
||||
raise RuntimeError("Config is not loaded. Call setup_config() first.")
|
||||
else:
|
||||
return _CONFIG
|
||||
@@ -0,0 +1,708 @@
|
||||
import sqlite3
|
||||
import threading
|
||||
from typing import cast
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, ParamSpec, TypeVar, Generic
|
||||
|
||||
import dt
|
||||
import utils
|
||||
import config
|
||||
from logger import LOGGER
|
||||
|
||||
|
||||
T = TypeVar('T')
|
||||
P = ParamSpec('P')
|
||||
R = TypeVar('R')
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResponseBody(Generic[T]):
|
||||
"""The generic response body for API return."""
|
||||
|
||||
success: bool
|
||||
"""True if this operation is successful, otherwise false."""
|
||||
error: str
|
||||
"""The error message provided when operation failed."""
|
||||
data: T | None
|
||||
"""The payload provided when operation successed."""
|
||||
|
||||
|
||||
class DbException(Exception):
|
||||
"""Error occurs when manipulating with database."""
|
||||
pass
|
||||
|
||||
|
||||
def SafeDatabaseOperation(inner: Callable[P, R]) -> Callable[P, ResponseBody[R]]:
|
||||
def wrapper(*args, **kwargs) -> ResponseBody[R]:
|
||||
# extract self from args
|
||||
self: 'CalendarDatabase' = args[0]
|
||||
# get config
|
||||
cfg = config.get_config()
|
||||
|
||||
with self.mutex:
|
||||
# try to fetching database and allocate database cursor
|
||||
try:
|
||||
db = self._get_db()
|
||||
self._allocate_cursor()
|
||||
except Exception as e:
|
||||
self._free_cursor()
|
||||
if cfg.others.debug:
|
||||
LOGGER.exception(e)
|
||||
return ResponseBody(False, str(e), None)
|
||||
|
||||
# do real data work
|
||||
try:
|
||||
currentTime = utils.GetCurrentTimestamp()
|
||||
if currentTime - self.latestClean > cfg.others.auto_token_clean_duration:
|
||||
self.latestClean = currentTime
|
||||
LOGGER.info('Cleaning outdated token...')
|
||||
self.tokenOper_clean()
|
||||
|
||||
result = ResponseBody(True, '', inner(*args, **kwargs))
|
||||
self._free_cursor()
|
||||
db.commit()
|
||||
return result
|
||||
except Exception as e:
|
||||
self._free_cursor()
|
||||
db.rollback()
|
||||
if cfg.others.debug:
|
||||
LOGGER.exception(e)
|
||||
return ResponseBody(False, str(e), None)
|
||||
|
||||
return wrapper
|
||||
|
||||
class CalendarDatabase:
|
||||
|
||||
db: sqlite3.Connection | None
|
||||
cursor: sqlite3.Cursor | None
|
||||
mutex: threading.Lock
|
||||
latestClean: int
|
||||
|
||||
def __init__(self):
|
||||
self.db = None
|
||||
self.cursor = None
|
||||
self.mutex = threading.Lock()
|
||||
self.latestClean = 0
|
||||
|
||||
def open(self):
|
||||
if (self.db is not None):
|
||||
raise DbException('Database is already opened')
|
||||
|
||||
cfg = config.get_config()
|
||||
match cfg.database.driver:
|
||||
case config.DatabaseDriver.SQLITE:
|
||||
self.db = sqlite3.connect(cast(config.SqliteDatabaseConfig, cfg.database.config).path, check_same_thread = False)
|
||||
self.db.execute('PRAGMA encoding = "UTF-8";')
|
||||
self.db.execute('PRAGMA foreign_keys = ON;')
|
||||
case config.DatabaseDriver.MYSQL:
|
||||
raise DbException('Not implemented database')
|
||||
case _:
|
||||
raise DbException('Unknow database type')
|
||||
|
||||
def init(self, username: str, password: str):
|
||||
if (self.db is not None):
|
||||
raise DbException('Database is already opened')
|
||||
|
||||
# establish tables
|
||||
cfg = config.get_config()
|
||||
backend_path = Path(__file__).resolve().parent
|
||||
backend_sql_path = backend_path / 'sql'
|
||||
match cfg.database.driver:
|
||||
case config.DatabaseDriver.SQLITE:
|
||||
sql_file = backend_sql_path / 'sqlite.sql'
|
||||
case config.DatabaseDriver.MYSQL:
|
||||
raise DbException('Not implemented database')
|
||||
case _:
|
||||
raise DbException('Unknow database type')
|
||||
|
||||
self.open()
|
||||
db = self._get_db()
|
||||
|
||||
self._allocate_cursor()
|
||||
cursor = self._get_cursor()
|
||||
|
||||
# execute script for creating tables
|
||||
with open(sql_file, 'r', encoding='utf-8') as fsql:
|
||||
cursor.executescript(fsql.read())
|
||||
# add default user in user table
|
||||
cursor.execute('INSERT INTO user VALUES (?, ?, ?, ?);', (
|
||||
username,
|
||||
utils.ComputePasswordHash(password),
|
||||
1,
|
||||
utils.GenerateSalt()
|
||||
))
|
||||
|
||||
self._free_cursor()
|
||||
|
||||
# commit to database
|
||||
db.commit()
|
||||
|
||||
def close(self):
|
||||
if (self.db is None):
|
||||
LOGGER.warning('Try to close null database.')
|
||||
else:
|
||||
self._free_cursor()
|
||||
self.db.close()
|
||||
self.db = None
|
||||
|
||||
def _get_db(self) -> sqlite3.Connection:
|
||||
if (self.db is None):
|
||||
raise DbException('There is no opened database')
|
||||
else:
|
||||
return self.db
|
||||
|
||||
def _allocate_cursor(self) -> None:
|
||||
if (self.cursor is not None):
|
||||
raise DbException('There is already opened database cursor')
|
||||
else:
|
||||
self.cursor = self._get_db().cursor()
|
||||
|
||||
def _get_cursor(self) -> sqlite3.Cursor:
|
||||
if (self.cursor is None):
|
||||
raise DbException('There is no opened database cursor')
|
||||
else:
|
||||
return self.cursor
|
||||
|
||||
def _free_cursor(self) -> None:
|
||||
if (self.cursor is None):
|
||||
LOGGER.warning('Try to free null databse cursor.')
|
||||
else:
|
||||
self.cursor.close()
|
||||
self.cursor = None
|
||||
|
||||
# ======================= token related internal operation
|
||||
def tokenOper_clean(self):
|
||||
# remove outdated token
|
||||
cursor = self._get_cursor()
|
||||
cursor.execute('DELETE FROM token WHERE [token_expire_on] <= ?',(utils.GetCurrentTimestamp(), ))
|
||||
|
||||
def tokenOper_postpone_expireOn(self, token):
|
||||
cursor = self._get_cursor()
|
||||
cursor.execute('UPDATE token SET [token_expire_on] = ? WHERE [token] = ?;', (
|
||||
utils.GetTokenExpireOn(),
|
||||
token
|
||||
))
|
||||
|
||||
def tokenOper_check_valid(self, token):
|
||||
self.tokenOper_get_username(token)
|
||||
|
||||
def tokenOper_is_admin(self, username):
|
||||
cursor = self._get_cursor()
|
||||
cursor.execute('SELECT [is_admin] FROM user WHERE [name] = ?;',(username, ))
|
||||
cache = cursor.fetchone()[0]
|
||||
return cache == 1
|
||||
|
||||
def tokenOper_get_username(self, token):
|
||||
cursor = self._get_cursor()
|
||||
cursor.execute('SELECT [user] FROM token WHERE [token] = ? AND [token_expire_on] > ?;',(
|
||||
token,
|
||||
utils.GetCurrentTimestamp()
|
||||
))
|
||||
result = cursor.fetchone()[0]
|
||||
# need postpone expire on time
|
||||
self.tokenOper_postpone_expireOn(token)
|
||||
return result
|
||||
|
||||
# =============================== # =============================== operation function
|
||||
# =============================== common
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def common_salt(self, username):
|
||||
cursor = self._get_cursor()
|
||||
salt = utils.GenerateSalt()
|
||||
cursor.execute('UPDATE user SET [salt] = ? WHERE [name] = ?;', (
|
||||
salt,
|
||||
username
|
||||
))
|
||||
return salt
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def common_login(self, username, password, clientUa, clientIp):
|
||||
cursor = self._get_cursor()
|
||||
cursor.execute('SELECT [password], [salt] FROM user WHERE [name] = ?;', (username, ))
|
||||
(gotten_salt, gotten_password) = cursor.fetchone()
|
||||
|
||||
if password == utils.ComputePasswordHashWithSalt(gotten_password, gotten_salt):
|
||||
token = utils.GenerateToken(username)
|
||||
cursor.execute('UPDATE user SET [salt] = ? WHERE [name] = ?;', (
|
||||
utils.GenerateSalt(), # regenerate a new slat to prevent re-login try
|
||||
username
|
||||
))
|
||||
cursor.execute('INSERT INTO token VALUES (?, ?, ?, ?, ?);', (
|
||||
username,
|
||||
token,
|
||||
utils.GetTokenExpireOn(), # add 2 day from now
|
||||
clientUa,
|
||||
clientIp,
|
||||
))
|
||||
return token
|
||||
else:
|
||||
# throw a exception to indicate fail to login
|
||||
raise DbException('Login authentication failed')
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def common_webLogin(self, username, password, clientUa, clientIp):
|
||||
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}')
|
||||
|
||||
cursor.execute('SELECT [name] FROM user WHERE [name] = ? AND [password] = ?;', (username, passwordHash))
|
||||
|
||||
if len(cursor.fetchall()) != 0:
|
||||
token = utils.GenerateToken(username)
|
||||
cursor.execute('INSERT INTO token VALUES (?, ?, ?, ?, ?);', (
|
||||
username,
|
||||
token,
|
||||
utils.GetTokenExpireOn(), # add 2 day from now
|
||||
clientUa,
|
||||
clientIp,
|
||||
))
|
||||
return token
|
||||
else:
|
||||
# throw a exception to indicate fail to login
|
||||
raise DbException('Login authentication failed')
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def common_logout(self, token):
|
||||
cursor = self._get_cursor()
|
||||
self.tokenOper_check_valid(token)
|
||||
cursor.execute('DELETE FROM token WHERE [token] = ?;', (token, ))
|
||||
return True
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def common_tokenValid(self, token):
|
||||
self.tokenOper_check_valid(token)
|
||||
return True
|
||||
|
||||
# =============================== calendar
|
||||
@SafeDatabaseOperation
|
||||
def calendar_getFull(self, token, startDateTime, endDateTime):
|
||||
cursor = self._get_cursor()
|
||||
username = self.tokenOper_get_username(token)
|
||||
cursor.execute('SELECT calendar.* FROM calendar INNER JOIN collection \
|
||||
ON collection.uuid = calendar.belong_to \
|
||||
WHERE (collection.user = ? AND calendar.loop_date_time_end >= ? AND calendar.loop_date_time_start - (calendar.event_date_time_end - calendar.event_date_time_start) <= ?);',
|
||||
(username, startDateTime, endDateTime))
|
||||
return cursor.fetchall()
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def calendar_getList(self, token, startDateTime, endDateTime):
|
||||
cursor = self._get_cursor()
|
||||
username = self.tokenOper_get_username(token)
|
||||
cursor.execute('SELECT calendar.uuid FROM calendar INNER JOIN collection \
|
||||
ON collection.uuid = calendar.belong_to \
|
||||
WHERE (collection.user = ? AND calendar.loop_date_time_end >= ? AND calendar.loop_date_time_start - (calendar.event_date_time_end - calendar.event_date_time_start) <= ?);',
|
||||
(username, startDateTime, endDateTime))
|
||||
return tuple(map(lambda x: x[0], cursor.fetchall()))
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def calendar_getDetail(self, token, uuid):
|
||||
cursor = self._get_cursor()
|
||||
self.tokenOper_check_valid(token)
|
||||
cursor.execute('SELECT * FROM calendar WHERE [uuid] = ?;', (uuid, ))
|
||||
return cursor.fetchone()
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def calendar_update(self, token, uuid, lastChange, **optArgs):
|
||||
cursor = self._get_cursor()
|
||||
self.tokenOper_check_valid(token)
|
||||
|
||||
# get prev data
|
||||
cursor.execute('SELECT * FROM calendar WHERE [uuid] = ? AND [last_change] = ?;', (uuid, lastChange))
|
||||
analyseData = list(cursor.fetchone())
|
||||
|
||||
# construct update data
|
||||
lastupdate = utils.GenerateUUID()
|
||||
sqlList = [
|
||||
'[last_change] = ?',
|
||||
]
|
||||
argumentsList = [
|
||||
lastupdate,
|
||||
]
|
||||
|
||||
# analyse opt arg
|
||||
reAnalyseLoop = False
|
||||
|
||||
cache = optArgs.get('belongTo', None)
|
||||
if cache is not None:
|
||||
sqlList.append('[belong_to] = ?')
|
||||
argumentsList.append(cache)
|
||||
cache = optArgs.get('title', None)
|
||||
if cache is not None:
|
||||
sqlList.append('[title] = ?')
|
||||
argumentsList.append(cache)
|
||||
cache = optArgs.get('description', None)
|
||||
if cache is not None:
|
||||
sqlList.append('[description] = ?')
|
||||
argumentsList.append(cache)
|
||||
cache = optArgs.get('eventDateTimeStart', None)
|
||||
if cache is not None:
|
||||
sqlList.append('[event_date_time_start] = ?')
|
||||
argumentsList.append(cache)
|
||||
reAnalyseLoop = True
|
||||
analyseData[5] = cache
|
||||
cache = optArgs.get('eventDateTimeEnd', None)
|
||||
if cache is not None:
|
||||
sqlList.append('[event_date_time_end] = ?')
|
||||
argumentsList.append(cache)
|
||||
cache = optArgs.get('loopRules', None)
|
||||
if cache is not None:
|
||||
sqlList.append('[loop_rules] = ?')
|
||||
argumentsList.append(cache)
|
||||
reAnalyseLoop = True
|
||||
analyseData[8] = cache
|
||||
cache = optArgs.get('timezoneOffset', None)
|
||||
if cache is not None:
|
||||
sqlList.append('[timezone_offset] = ?')
|
||||
argumentsList.append(cache)
|
||||
reAnalyseLoop = True
|
||||
analyseData[7] = cache
|
||||
|
||||
if reAnalyseLoop:
|
||||
# re-compute loop data and upload it into list
|
||||
sqlList.append('[loop_date_time_start] = ?')
|
||||
argumentsList.append(analyseData[5])
|
||||
sqlList.append('[loop_date_time_end] = ?')
|
||||
argumentsList.append(str(dt.ResolveLoopStr(
|
||||
analyseData[8],
|
||||
analyseData[5],
|
||||
analyseData[7]
|
||||
)))
|
||||
|
||||
# execute
|
||||
argumentsList.append(uuid)
|
||||
cursor.execute('UPDATE calendar SET {} WHERE [uuid] = ?;'.format(', '.join(sqlList)),
|
||||
tuple(argumentsList))
|
||||
if cursor.rowcount != 1:
|
||||
raise DbException('Fail to update due to no matched rows or too much rows.')
|
||||
return lastupdate
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def calendar_add(self, token, belongTo, title, description, eventDateTimeStart, eventDateTimeEnd, loopRules, timezoneOffset):
|
||||
cursor = self._get_cursor()
|
||||
self.tokenOper_check_valid(token)
|
||||
|
||||
newuuid = utils.GenerateUUID()
|
||||
lastupdate = utils.GenerateUUID()
|
||||
|
||||
# analyse loopRules and output following 2 fileds.
|
||||
loopDateTimeStart = eventDateTimeStart
|
||||
loopDateTimeEnd = dt.ResolveLoopStr(loopRules, eventDateTimeStart, timezoneOffset)
|
||||
|
||||
cursor.execute('INSERT INTO calendar VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);',
|
||||
(newuuid,
|
||||
belongTo,
|
||||
title,
|
||||
description,
|
||||
lastupdate,
|
||||
eventDateTimeStart,
|
||||
eventDateTimeEnd,
|
||||
timezoneOffset,
|
||||
loopRules,
|
||||
loopDateTimeStart,
|
||||
loopDateTimeEnd))
|
||||
return newuuid
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def calendar_delete(self, token, uuid, lastChange):
|
||||
cursor = self._get_cursor()
|
||||
self.tokenOper_check_valid(token)
|
||||
cursor.execute('DELETE FROM calendar WHERE [uuid] = ? AND [last_change] = ?;', (uuid, lastChange))
|
||||
if cursor.rowcount != 1:
|
||||
raise DbException('Fail to delete due to no matched rows or too much rows.')
|
||||
return True
|
||||
|
||||
# =============================== collection
|
||||
@SafeDatabaseOperation
|
||||
def collection_getFullOwn(self, token):
|
||||
cursor = self._get_cursor()
|
||||
username = self.tokenOper_get_username(token)
|
||||
cursor.execute('SELECT [uuid], [name], [last_change] FROM collection WHERE [user] = ?;', (username, ))
|
||||
return cursor.fetchall()
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def collection_getListOwn(self, token):
|
||||
cursor = self._get_cursor()
|
||||
username = self.tokenOper_get_username(token)
|
||||
cursor.execute('SELECT [uuid] FROM collection WHERE [user] = ?;', (username, ))
|
||||
return tuple(map(lambda x: x[0], cursor.fetchall()))
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def collection_getDetailOwn(self, token, uuid):
|
||||
cursor = self._get_cursor()
|
||||
username = self.tokenOper_get_username(token)
|
||||
cursor.execute('SELECT [uuid], [name], [last_change] FROM collection WHERE [user] = ? AND [uuid] = ?;', (username, uuid))
|
||||
return cursor.fetchone()
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def collection_addOwn(self, token, newname):
|
||||
cursor = self._get_cursor()
|
||||
username = self.tokenOper_get_username(token)
|
||||
newuuid = utils.GenerateUUID()
|
||||
lastupdate = utils.GenerateUUID()
|
||||
cursor.execute('INSERT INTO collection VALUES (?, ?, ?, ?);',
|
||||
(newuuid, newname, username, lastupdate))
|
||||
return newuuid
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def collection_updateOwn(self, token, uuid, newname, lastChange):
|
||||
cursor = self._get_cursor()
|
||||
self.tokenOper_check_valid(token)
|
||||
|
||||
lastupdate = utils.GenerateUUID()
|
||||
cursor.execute('UPDATE collection SET [name] = ?, [last_change] = ? WHERE [uuid] = ? AND [last_change] = ?;', (
|
||||
newname,
|
||||
lastupdate,
|
||||
uuid,
|
||||
lastChange
|
||||
))
|
||||
if cursor.rowcount != 1:
|
||||
raise DbException('Fail to update due to no matched rows or too much rows.')
|
||||
return lastupdate
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def collection_deleteOwn(self, token, uuid, lastChange):
|
||||
cursor = self._get_cursor()
|
||||
self.tokenOper_check_valid(token)
|
||||
|
||||
cursor.execute('DELETE FROM collection WHERE [uuid] = ? AND [last_change] = ?;', (
|
||||
uuid,
|
||||
lastChange
|
||||
))
|
||||
if cursor.rowcount != 1:
|
||||
raise DbException('Fail to delete due to no matched rows or too much rows.')
|
||||
return True
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def collection_getSharing(self, token, uuid):
|
||||
cursor = self._get_cursor()
|
||||
self.tokenOper_check_valid(token)
|
||||
cursor.execute('SELECT [target] FROM share WHERE [uuid] = ?;', (uuid, ))
|
||||
return tuple(map(lambda x: x[0], cursor.fetchall()))
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def collection_deleteSharing(self, token, uuid, target, lastChange):
|
||||
cursor = self._get_cursor()
|
||||
self.tokenOper_check_valid(token)
|
||||
|
||||
lastupdate = utils.GenerateUUID()
|
||||
cursor.execute('UPDATE collection SET [last_change] = ?, WHERE [uuid] = ? AND [last_change] = ?;', (lastupdate, uuid, lastChange))
|
||||
if cursor.rowcount != 1:
|
||||
raise DbException('Fail to delete due to no matched rows or too much rows.')
|
||||
|
||||
cursor.execute('DELETE FROM share WHERE [uuid] = ? AND [target] = ?;', (uuid, target))
|
||||
if cursor.rowcount != 1:
|
||||
raise DbException('Fail to delete due to no matched rows or too much rows.')
|
||||
|
||||
return lastupdate
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def collection_addSharing(self, token, uuid, target, lastChange):
|
||||
cursor = self._get_cursor()
|
||||
self.tokenOper_check_valid(token)
|
||||
|
||||
lastupdate = utils.GenerateUUID()
|
||||
cursor.execute('UPDATE collection SET [last_change] = ? WHERE [uuid] = ? AND [last_change] = ?;', (lastupdate, uuid, lastChange))
|
||||
if cursor.rowcount != 1:
|
||||
raise DbException('Fail to delete due to no matched rows or too much rows.')
|
||||
|
||||
cursor.execute('SELECT * FROM share WHERE [uuid] = ? AND [target] = ?;', (uuid, target))
|
||||
if len(cursor.fetchall()) != 0:
|
||||
raise DbException('Fail to insert duplicated item.')
|
||||
cursor.execute('INSERT INTO share VALUES (?, ?);', (uuid, target))
|
||||
|
||||
return lastupdate
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def collection_getShared(self, token):
|
||||
cursor = self._get_cursor()
|
||||
username = self.tokenOper_get_username(token)
|
||||
cursor.execute('SELECT collection.uuid, collection.name, collection.user \
|
||||
FROM share INNER JOIN collection \
|
||||
ON share.uuid = collection.uuid \
|
||||
WHERE share.target = ?;', (username, ))
|
||||
return cursor.fetchall()
|
||||
|
||||
# =============================== todo
|
||||
@SafeDatabaseOperation
|
||||
def todo_getFull(self, token):
|
||||
cursor = self._get_cursor()
|
||||
username = self.tokenOper_get_username(token)
|
||||
cursor.execute('SELECT * FROM todo WHERE [belong_to] = ?;', (username, ))
|
||||
return cursor.fetchall()
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def todo_getList(self, token):
|
||||
cursor = self._get_cursor()
|
||||
username = self.tokenOper_get_username(token)
|
||||
cursor.execute('SELECT [uuid] FROM todo WHERE [belong_to] = ?;', (username, ))
|
||||
return tuple(map(lambda x: x[0], cursor.fetchall()))
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def todo_getDetail(self, token, uuid):
|
||||
cursor = self._get_cursor()
|
||||
username = self.tokenOper_get_username(token)
|
||||
cursor.execute('SELECT * FROM todo WHERE [belong_to] = ? AND [uuid] = ?;', (username, uuid))
|
||||
return cursor.fetchone()
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def todo_add(self, token):
|
||||
cursor = self._get_cursor()
|
||||
username = self.tokenOper_get_username(token)
|
||||
newuuid = utils.GenerateUUID()
|
||||
lastupdate = utils.GenerateUUID()
|
||||
returnedData = (
|
||||
newuuid,
|
||||
username,
|
||||
'',
|
||||
lastupdate,
|
||||
)
|
||||
cursor.execute('INSERT INTO todo VALUES (?, ?, ?, ?);', returnedData)
|
||||
return returnedData
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def todo_update(self, token, uuid, data, lastChange):
|
||||
cursor = self._get_cursor()
|
||||
# check valid token
|
||||
self.tokenOper_check_valid(token)
|
||||
|
||||
# update
|
||||
newLastChange = utils.GenerateUUID()
|
||||
cursor.execute('UPDATE todo SET [data] = ?, [last_change] = ? WHERE [uuid] = ? AND [last_change] = ?;', (
|
||||
data,
|
||||
newLastChange,
|
||||
uuid,
|
||||
lastChange
|
||||
))
|
||||
if cursor.rowcount != 1:
|
||||
raise DbException('Fail to update due to no matched rows or too much rows.')
|
||||
return newLastChange
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def todo_delete(self, token, uuid, lastChange):
|
||||
cursor = self._get_cursor()
|
||||
# check valid token
|
||||
self.tokenOper_check_valid(token)
|
||||
|
||||
# delete
|
||||
cursor.execute('DELETE FROM todo WHERE [uuid] = ? AND [last_change] = ?;', (uuid, lastChange))
|
||||
if cursor.rowcount != 1:
|
||||
raise DbException('Fail to delete due to no matched rows or too much rows.')
|
||||
return True
|
||||
|
||||
|
||||
# =============================== admin
|
||||
@SafeDatabaseOperation
|
||||
def admin_get(self, token):
|
||||
cursor = self._get_cursor()
|
||||
username = self.tokenOper_get_username(token)
|
||||
if not self.tokenOper_is_admin(username):
|
||||
raise DbException('Permission denied.')
|
||||
|
||||
cursor.execute('SELECT [name], [is_admin] FROM user;')
|
||||
return tuple(map(lambda x: (x[0], x[1] == 1), cursor.fetchall()))
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def admin_add(self, token, newname):
|
||||
cursor = self._get_cursor()
|
||||
username = self.tokenOper_get_username(token)
|
||||
if not self.tokenOper_is_admin(username):
|
||||
raise DbException('Permission denied.')
|
||||
|
||||
newpassword = utils.ComputePasswordHash(utils.GenerateUUID())
|
||||
cursor.execute('INSERT INTO user VALUES (?, ?, ?, ?);', (
|
||||
newname,
|
||||
newpassword,
|
||||
0,
|
||||
utils.GenerateSalt()
|
||||
))
|
||||
return (newname, False)
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def admin_update(self, token, _username, **optArgs):
|
||||
cursor = self._get_cursor()
|
||||
username = self.tokenOper_get_username(token)
|
||||
if not self.tokenOper_is_admin(username):
|
||||
raise DbException('Permission denied.')
|
||||
|
||||
# construct data
|
||||
sqlList = []
|
||||
argumentsList = []
|
||||
|
||||
# analyse opt arg
|
||||
cache = optArgs.get('password', None)
|
||||
if cache is not None:
|
||||
sqlList.append('[password] = ?')
|
||||
argumentsList.append(utils.ComputePasswordHash(cache))
|
||||
cache = optArgs.get('isAdmin', None)
|
||||
if cache is not None:
|
||||
sqlList.append('[is_admin] = ?')
|
||||
argumentsList.append(1 if cache else 0)
|
||||
|
||||
# execute
|
||||
argumentsList.append(_username)
|
||||
cursor.execute('UPDATE user SET {} WHERE [name] = ?;'.format(', '.join(sqlList)),
|
||||
tuple(argumentsList))
|
||||
LOGGER.debug(cache)
|
||||
LOGGER.debug(tuple(argumentsList))
|
||||
if cursor.rowcount != 1:
|
||||
raise DbException('Fail to update due to no matched rows or too much rows.')
|
||||
return True
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def admin_delete(self, token, username):
|
||||
cursor = self._get_cursor()
|
||||
_username = self.tokenOper_get_username(token)
|
||||
if not self.tokenOper_is_admin(_username):
|
||||
raise DbException('Permission denied.')
|
||||
|
||||
# delete
|
||||
cursor.execute('DELETE FROM user WHERE [name] = ?;', (username, ))
|
||||
if cursor.rowcount != 1:
|
||||
raise DbException('Fail to delete due to no matched rows or too much rows.')
|
||||
return True
|
||||
|
||||
# =============================== profile
|
||||
@SafeDatabaseOperation
|
||||
def profile_isAdmin(self, token):
|
||||
cursor = self._get_cursor()
|
||||
username = self.tokenOper_get_username(token)
|
||||
return self.tokenOper_is_admin(username)
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def profile_changePassword(self, token, newpassword):
|
||||
cursor = self._get_cursor()
|
||||
username = self.tokenOper_get_username(token)
|
||||
cursor.execute('UPDATE user SET [password] = ? WHERE [name] = ?;', (
|
||||
utils.ComputePasswordHash(newpassword),
|
||||
username
|
||||
))
|
||||
return True
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def profile_getToken(self, token):
|
||||
cursor = self._get_cursor()
|
||||
username = self.tokenOper_get_username(token)
|
||||
|
||||
cursor.execute('SELECT * FROM token WHERE [user] = ?;', (
|
||||
username,
|
||||
))
|
||||
return cursor.fetchall()
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def profile_deleteToken(self, token, deleteToken):
|
||||
cursor = self._get_cursor()
|
||||
_username = self.tokenOper_get_username(token)
|
||||
|
||||
# delete
|
||||
cursor.execute('DELETE FROM token WHERE [user] = ? AND [token] = ?;', (
|
||||
_username,
|
||||
deleteToken
|
||||
))
|
||||
if cursor.rowcount != 1:
|
||||
raise DbException('Fail to delete due to no matched rows or too much rows.')
|
||||
return True
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import datetime
|
||||
import time
|
||||
import re
|
||||
import logging
|
||||
import typing
|
||||
from functools import reduce
|
||||
import utils
|
||||
|
||||
@@ -13,7 +14,9 @@ MAX_TIMESTAMP = int(MAX_DATETIME.timestamp() / 60)
|
||||
DAY1_SPAN = 60 * 24
|
||||
DAY7_SPAN = 7 * DAY1_SPAN
|
||||
|
||||
def ResolveLoopStr(strl, starttime, tzoffset):
|
||||
LoopHandle = typing.Callable[[re.Match, int, int, int], int]
|
||||
|
||||
def ResolveLoopStr(strl: str, starttime: int, tzoffset: int) -> int:
|
||||
# check no loop
|
||||
if strl == '':
|
||||
return starttime
|
||||
@@ -40,7 +43,7 @@ def ResolveLoopStr(strl, starttime, tzoffset):
|
||||
raise Exception('Invalid loopRules')
|
||||
|
||||
|
||||
def LoopHandle_Year(searchResult, starttime, times, tzoffset):
|
||||
def LoopHandle_Year(searchResult: re.Match, starttime: int, times: int, tzoffset: int) -> int:
|
||||
clientDate = datetime.datetime.fromtimestamp(starttime * 60, UTCTimezone(tzoffset))
|
||||
isStrict = searchResult.group(1) == 'S'
|
||||
yearSpan = int(searchResult.group(2))
|
||||
@@ -52,7 +55,7 @@ def LoopHandle_Year(searchResult, starttime, times, tzoffset):
|
||||
if clientMonth == 2 and clientDay == 29:
|
||||
if isStrict:
|
||||
realSpan = utils.LCM(yearSpan, 4)
|
||||
print(realSpan)
|
||||
logging.debug(realSpan)
|
||||
valCache = starttime
|
||||
while valCache < MAX_TIMESTAMP and times > 0:
|
||||
newYear += realSpan
|
||||
@@ -71,7 +74,7 @@ def LoopHandle_Year(searchResult, starttime, times, tzoffset):
|
||||
val = starttime + DAY1_SPAN * (DaysCount(newYear, newMonth, newDay) - DaysCount(clientYear, clientMonth, clientDay))
|
||||
return val if val < MAX_TIMESTAMP else MAX_TIMESTAMP
|
||||
|
||||
def LoopHandle_Month(searchResult, starttime, times, tzoffset):
|
||||
def LoopHandle_Month(searchResult: re.Match, starttime: int, times: int, tzoffset: int) -> int:
|
||||
isStrict = searchResult.group(1) == 'S'
|
||||
loopType = searchResult.group(2)
|
||||
monthSpan = int(searchResult.group(3))
|
||||
@@ -144,7 +147,7 @@ def LoopHandle_Month(searchResult, starttime, times, tzoffset):
|
||||
val = starttime + DAY1_SPAN * (DaysCount(newYear, newMonth, newDay) - DaysCount(clientYear, clientMonth, clientDay))
|
||||
return val if val < MAX_TIMESTAMP else MAX_TIMESTAMP
|
||||
|
||||
def LoopHandle_Week(searchResult, starttime, times, tzoffset):
|
||||
def LoopHandle_Week(searchResult: re.Match, starttime: int, times: int, tzoffset: int) -> int:
|
||||
weekOccupied = tuple(map(lambda x: x == 'T', searchResult.group(1)))
|
||||
weekEventCount = reduce(lambda x, y: x + (1 if y else 0), weekOccupied, 0)
|
||||
if weekEventCount == 0:
|
||||
@@ -170,25 +173,25 @@ def LoopHandle_Week(searchResult, starttime, times, tzoffset):
|
||||
val -= 1
|
||||
return val if val < MAX_TIMESTAMP else MAX_TIMESTAMP
|
||||
|
||||
def LoopHandle_Day(searchResult, starttime, times, tzoffset):
|
||||
def LoopHandle_Day(searchResult: re.Match, starttime: int, times: int, tzoffset: int) -> int:
|
||||
val = starttime + DAY1_SPAN * times * int(searchResult.group(1))
|
||||
val -= 1
|
||||
return val if val < MAX_TIMESTAMP else MAX_TIMESTAMP
|
||||
|
||||
precompiledLoopRules = (
|
||||
precompiledLoopRules: tuple[tuple[re.Pattern, LoopHandle], ...] = (
|
||||
(re.compile(r'^Y([SR]{1})([1-9]\d*)$'), LoopHandle_Year),
|
||||
(re.compile(r'^M([SR]{1})([ABCD]{1})([1-9]\d*)$'), LoopHandle_Month),
|
||||
(re.compile(r'^W([TF]{7})([1-9]\d*)$'), LoopHandle_Week),
|
||||
(re.compile(r'^D([1-9]\d*)$'), LoopHandle_Day)
|
||||
)
|
||||
|
||||
precompiledLoopStopRules = {
|
||||
precompiledLoopStopRules: dict[str, re.Pattern] = {
|
||||
'infinity': re.compile(r'^F$'),
|
||||
'datetime': re.compile(r'^D([1-9]\d*|0)$'),
|
||||
'times': re.compile(r'^T([1-9]\d*)$')
|
||||
}
|
||||
|
||||
def LeapYearCountEx(endYear, includeThis = False, baseYear = 1, includeBase = True):
|
||||
def LeapYearCountEx(endYear: int, includeThis: bool = False, baseYear: int = 1, includeBase: bool = True):
|
||||
if not includeThis:
|
||||
endYear -= 1
|
||||
if includeBase:
|
||||
@@ -204,10 +207,10 @@ def LeapYearCountEx(endYear, includeThis = False, baseYear = 1, includeBase = Tr
|
||||
|
||||
return (endly - basely)
|
||||
|
||||
def LeapYearCount(year):
|
||||
def LeapYearCount(year: int):
|
||||
return LeapYearCountEx(year, False, 1, True)
|
||||
|
||||
def IsLeapYear(year):
|
||||
def IsLeapYear(year: int):
|
||||
isLeap = False
|
||||
if year % 4 == 0:
|
||||
isLeap = True
|
||||
@@ -217,7 +220,7 @@ def IsLeapYear(year):
|
||||
isLeap = True
|
||||
return isLeap
|
||||
|
||||
def DaysCount(year, month, day):
|
||||
def DaysCount(year: int, month: int, day: int):
|
||||
ly = LeapYearCountEx(year, False, 1, True)
|
||||
days = 365 * (year - 1)
|
||||
days += ly
|
||||
@@ -231,7 +234,7 @@ def DaysCount(year, month, day):
|
||||
days += day - 1
|
||||
return days
|
||||
|
||||
def DayOfWeek(year, month, day):
|
||||
def DayOfWeek(year: int, month: int, day: int):
|
||||
# as we know, 1/1/1900 is Monday.
|
||||
# via this method, we can got 1/1/1 is Monday
|
||||
# compute day span
|
||||
@@ -240,7 +243,7 @@ def DayOfWeek(year, month, day):
|
||||
# return day of week (from 0 - 6, corresponding with python)
|
||||
return days % 7
|
||||
|
||||
def GetDayInMonth(year, month, day):
|
||||
def GetDayInMonth(year: int, month: int, day: int):
|
||||
days = MonthDayCount[month - 1] + (1 if (month == 2 and IsLeapYear(year)) else 0)
|
||||
firstDayOfWeek = DayOfWeek(year, month, 1)
|
||||
dayOfWeek = (firstDayOfWeek + day - 1) % 7
|
||||
@@ -253,7 +256,7 @@ def GetDayInMonth(year, month, day):
|
||||
|
||||
return (dayForwards, dayBackwards, weeksForward, dayOfWeek, weeksBackwards, dayOfWeek)
|
||||
|
||||
def GetMonthWeekStatistics(year, month):
|
||||
def GetMonthWeekStatistics(year: int, month: int):
|
||||
days = MonthDayCount[month - 1] + (1 if (month == 2 and IsLeapYear(year)) else 0)
|
||||
firstDayOfWeek = DayOfWeek(year, month, 1)
|
||||
lastDayOfWeek = (firstDayOfWeek + days - 1) % 7
|
||||
@@ -269,14 +272,18 @@ def GetMonthWeekStatistics(year, month):
|
||||
return tuple(result)
|
||||
|
||||
class UTCTimezone(datetime.tzinfo):
|
||||
def __init__(self, offset = 0):
|
||||
self._offset = offset
|
||||
|
||||
__offset: int
|
||||
|
||||
def __init__(self, offset: int = 0):
|
||||
self.__offset = offset
|
||||
|
||||
def utcoffset(self, dt):
|
||||
return datetime.timedelta(minutes=self._offset)
|
||||
return datetime.timedelta(minutes=self.__offset)
|
||||
|
||||
def tzname(self, dt):
|
||||
return 'UTC {}'.format(self._offset)
|
||||
return 'UTC {}'.format(self.__offset)
|
||||
|
||||
def dst(self, dt):
|
||||
return datetime.timedelta(0)
|
||||
return datetime.timedelta(0)
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,14 @@
|
||||
[project]
|
||||
name = "coleaf-backend"
|
||||
version = "1.1.0"
|
||||
description = "The backend of coconut-leaf."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"flask==2.2.3",
|
||||
]
|
||||
[tool.uv]
|
||||
constraint-dependencies = [
|
||||
"Werkzeug==2.2.2",
|
||||
"MarkupSafe==2.1.5"
|
||||
]
|
||||
@@ -0,0 +1,513 @@
|
||||
from flask import Flask
|
||||
from flask import request
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, ParamSpec, TypeVar, Generic
|
||||
|
||||
import config
|
||||
import database
|
||||
import utils
|
||||
from logger import LOGGER
|
||||
from database import ResponseBody
|
||||
|
||||
app = Flask(__name__)
|
||||
calendar_db = database.CalendarDatabase()
|
||||
|
||||
# region: API Route
|
||||
|
||||
# region: Common
|
||||
|
||||
|
||||
@app.route("/common/salt", methods=["POST"])
|
||||
def api_common_saltHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.common_salt, (FormField("username", str, False),), None
|
||||
)
|
||||
|
||||
|
||||
@app.route("/common/login", methods=["POST"])
|
||||
def api_common_loginHandle():
|
||||
clientInfo = FetchClientNetworkInfo()
|
||||
|
||||
return SmartDbCaller(
|
||||
calendar_db.common_login,
|
||||
(
|
||||
FormField("username", str, False),
|
||||
FormField("password", str, False),
|
||||
FormField("clientUa", str, False),
|
||||
FormField("clientIp", str, False),
|
||||
),
|
||||
{"clientUa": clientInfo.user_agent, "clientIp": clientInfo.ip_addr},
|
||||
)
|
||||
|
||||
|
||||
@app.route("/common/webLogin", methods=["POST"])
|
||||
def api_common_webLoginHandle():
|
||||
clientInfo = FetchClientNetworkInfo()
|
||||
|
||||
return SmartDbCaller(
|
||||
calendar_db.common_webLogin,
|
||||
(
|
||||
FormField("username", str, False),
|
||||
FormField("password", str, False),
|
||||
FormField("clientUa", str, False),
|
||||
FormField("clientIp", str, False),
|
||||
),
|
||||
{"clientUa": clientInfo.user_agent, "clientIp": clientInfo.ip_addr},
|
||||
)
|
||||
|
||||
|
||||
@app.route("/common/logout", methods=["POST"])
|
||||
def api_common_logoutHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.common_logout, (FormField("token", str, False),), None
|
||||
)
|
||||
|
||||
|
||||
@app.route("/common/tokenValid", methods=["POST"])
|
||||
def api_common_tokenValidHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.common_tokenValid, (FormField("token", str, False),), None
|
||||
)
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region: Calendar
|
||||
|
||||
|
||||
@app.route("/calendar/getFull", methods=["POST"])
|
||||
def api_calendar_getFullHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.calendar_getFull,
|
||||
(
|
||||
FormField("token", str, False),
|
||||
FormField("startDateTime", int, False),
|
||||
FormField("endDateTime", int, False),
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/calendar/getList", methods=["POST"])
|
||||
def api_calendar_getListHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.calendar_getList,
|
||||
(
|
||||
FormField("token", str, False),
|
||||
FormField("startDateTime", int, False),
|
||||
FormField("endDateTime", int, False),
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/calendar/getDetail", methods=["POST"])
|
||||
def api_calendar_getDetailHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.calendar_getDetail,
|
||||
(FormField("token", str, False), FormField("uuid", str, False)),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/calendar/update", methods=["POST"])
|
||||
def api_calendar_updateHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.calendar_update,
|
||||
(
|
||||
FormField("token", str, False),
|
||||
FormField("uuid", str, False),
|
||||
FormField("belongTo", str, True),
|
||||
FormField("title", str, True),
|
||||
FormField("description", str, True),
|
||||
FormField("eventDateTimeStart", int, True),
|
||||
FormField("eventDateTimeEnd", int, True),
|
||||
FormField("loopRules", str, True),
|
||||
FormField("timezoneOffset", int, True),
|
||||
FormField("lastChange", str, False),
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/calendar/add", methods=["POST"])
|
||||
def api_calendar_addHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.calendar_add,
|
||||
(
|
||||
FormField("token", str, False),
|
||||
FormField("belongTo", str, False),
|
||||
FormField("title", str, False),
|
||||
FormField("description", str, False),
|
||||
FormField("eventDateTimeStart", int, False),
|
||||
FormField("eventDateTimeEnd", int, False),
|
||||
FormField("loopRules", str, False),
|
||||
FormField("timezoneOffset", int, False),
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/calendar/delete", methods=["POST"])
|
||||
def api_calendar_deleteHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.calendar_delete,
|
||||
(
|
||||
FormField("token", str, False),
|
||||
FormField("uuid", str, False),
|
||||
FormField("lastChange", str, False),
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region: Collection
|
||||
|
||||
|
||||
@app.route("/collection/getFullOwn", methods=["POST"])
|
||||
def api_collection_getFullOwnHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.collection_getFullOwn, (FormField("token", str, False),), None
|
||||
)
|
||||
|
||||
|
||||
@app.route("/collection/getListOwn", methods=["POST"])
|
||||
def api_collection_getListOwnHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.collection_getListOwn, (FormField("token", str, False),), None
|
||||
)
|
||||
|
||||
|
||||
@app.route("/collection/getDetailOwn", methods=["POST"])
|
||||
def api_collection_getDetailOwnHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.collection_getDetailOwn,
|
||||
(FormField("token", str, False), FormField("uuid", str, False)),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/collection/addOwn", methods=["POST"])
|
||||
def api_collection_addOwnHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.collection_addOwn,
|
||||
(FormField("token", str, False), FormField("name", str, False)),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/collection/updateOwn", methods=["POST"])
|
||||
def api_collection_updateOwnHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.collection_updateOwn,
|
||||
(
|
||||
FormField("token", str, False),
|
||||
FormField("uuid", str, False),
|
||||
FormField("name", str, False),
|
||||
FormField("lastChange", str, False),
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/collection/deleteOwn", methods=["POST"])
|
||||
def api_collection_deleteOwnHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.collection_deleteOwn,
|
||||
(
|
||||
FormField("token", str, False),
|
||||
FormField("uuid", str, False),
|
||||
FormField("lastChange", str, False),
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/collection/getSharing", methods=["POST"])
|
||||
def api_collection_getSharingHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.collection_getSharing,
|
||||
(FormField("token", str, False), FormField("uuid", str, False)),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/collection/deleteSharing", methods=["POST"])
|
||||
def api_collection_deleteSharingHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.collection_deleteSharing,
|
||||
(
|
||||
FormField("token", str, False),
|
||||
FormField("uuid", str, False),
|
||||
FormField("target", str, False),
|
||||
FormField("lastChange", str, False),
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/collection/addSharing", methods=["POST"])
|
||||
def api_collection_addSharingHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.collection_addSharing,
|
||||
(
|
||||
FormField("token", str, False),
|
||||
FormField("uuid", str, False),
|
||||
FormField("target", str, False),
|
||||
FormField("lastChange", str, False),
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/collection/getShared", methods=["POST"])
|
||||
def api_collection_getSharedHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.collection_getShared, (FormField("token", str, False),), None
|
||||
)
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region: Todo
|
||||
|
||||
|
||||
@app.route("/todo/getFull", methods=["POST"])
|
||||
def api_todo_getFullHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.todo_getFull, (FormField("token", str, False),), None
|
||||
)
|
||||
|
||||
|
||||
@app.route("/todo/getList", methods=["POST"])
|
||||
def api_todo_getListHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.todo_getList, (FormField("token", str, False),), None
|
||||
)
|
||||
|
||||
|
||||
@app.route("/todo/getDetail", methods=["POST"])
|
||||
def api_todo_getDetailHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.todo_getDetail,
|
||||
(FormField("token", str, False), FormField("uuid", str, False)),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/todo/add", methods=["POST"])
|
||||
def api_todo_addHandle():
|
||||
return SmartDbCaller(calendar_db.todo_add, (FormField("token", str, False),), None)
|
||||
|
||||
|
||||
@app.route("/todo/update", methods=["POST"])
|
||||
def api_todo_updateHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.todo_update,
|
||||
(
|
||||
FormField("token", str, False),
|
||||
FormField("uuid", str, False),
|
||||
FormField("data", str, False),
|
||||
FormField("lastChange", str, False),
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/todo/delete", methods=["POST"])
|
||||
def api_todo_deleteHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.todo_delete,
|
||||
(
|
||||
FormField("token", str, False),
|
||||
FormField("uuid", str, False),
|
||||
FormField("lastChange", str, False),
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region: Admin
|
||||
|
||||
|
||||
@app.route("/admin/get", methods=["POST"])
|
||||
def api_admin_getHandle():
|
||||
return SmartDbCaller(calendar_db.admin_get, (FormField("token", str, False),), None)
|
||||
|
||||
|
||||
@app.route("/admin/add", methods=["POST"])
|
||||
def api_admin_addHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.admin_add,
|
||||
(FormField("token", str, False), FormField("username", str, False)),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/admin/update", methods=["POST"])
|
||||
def api_admin_updateHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.admin_update,
|
||||
(
|
||||
FormField("token", str, False),
|
||||
FormField("username", str, False),
|
||||
FormField("password", str, True),
|
||||
FormField("isAdmin", utils.Str2Bool, True),
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/admin/delete", methods=["POST"])
|
||||
def api_admin_deleteHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.admin_delete,
|
||||
(FormField("token", str, False), FormField("username", str, False)),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region: Profile
|
||||
|
||||
|
||||
@app.route("/profile/isAdmin", methods=["POST"])
|
||||
def api_profile_isAdminHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.profile_isAdmin, (FormField("token", str, False),), None
|
||||
)
|
||||
|
||||
|
||||
@app.route("/profile/changePassword", methods=["POST"])
|
||||
def api_profile_changePasswordHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.profile_changePassword,
|
||||
(FormField("token", str, False), FormField("password", str, False)),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/profile/getToken", methods=["POST"])
|
||||
def api_profile_getTokenHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.profile_getToken, (FormField("token", str, False),), None
|
||||
)
|
||||
|
||||
|
||||
@app.route("/profile/deleteToken", methods=["POST"])
|
||||
def api_profile_deleteTokenHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.profile_deleteToken,
|
||||
(FormField("token", str, False), FormField("deleteToken", str, False)),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# endregion
|
||||
|
||||
# region: Utilities
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClientNetworkInfo:
|
||||
user_agent: str
|
||||
"""The user agent of client."""
|
||||
ip_addr: str
|
||||
"""The IP address of client."""
|
||||
|
||||
|
||||
def FetchClientNetworkInfo() -> ClientNetworkInfo:
|
||||
clientUa = request.user_agent.string
|
||||
forwardIpList = request.headers.getlist("X-Forwarded-For")
|
||||
if forwardIpList:
|
||||
clientIp = forwardIpList[0]
|
||||
else:
|
||||
directIp = request.remote_addr
|
||||
if directIp is not None:
|
||||
clientIp = directIp
|
||||
else:
|
||||
clientIp = "0.0.0.0"
|
||||
|
||||
return ClientNetworkInfo(clientUa, clientIp)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FormField:
|
||||
name: str
|
||||
"""The name of form field."""
|
||||
ty: Callable[[str], Any]
|
||||
"""The type of form field."""
|
||||
is_optional: bool
|
||||
"""True if this form field is optional, otherwise false."""
|
||||
|
||||
|
||||
def SmartDbCaller(
|
||||
db_method: Callable[..., ResponseBody[Any]],
|
||||
fields: tuple[FormField, ...],
|
||||
padding_form: dict[str, str] | None,
|
||||
) -> dict[str, Any]:
|
||||
opt_param_counter = 0
|
||||
lost_required: bool = False
|
||||
param_list: list[Any] = []
|
||||
opt_param_dict: dict[str, Any] = {}
|
||||
|
||||
# fetch user passed form
|
||||
user_form: dict[str, str] = request.form.to_dict()
|
||||
LOGGER.debug(f"User Form: {user_form}")
|
||||
# overwrite user form by our padding form
|
||||
if padding_form is not None:
|
||||
user_form.update(padding_form)
|
||||
LOGGER.debug(f"Padded User Form: {user_form}")
|
||||
|
||||
# check fields one by one
|
||||
for field in fields:
|
||||
value = user_form.get(field.name, None)
|
||||
if value is not None:
|
||||
value = field.ty(value)
|
||||
|
||||
if field.is_optional:
|
||||
# optional param
|
||||
if 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)
|
||||
|
||||
|
||||
def ConstructResponseBody(body: ResponseBody[Any]) -> dict[str, Any]:
|
||||
return {"success": body.success, "error": body.error, "data": body.data}
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
def run():
|
||||
calendar_db.open()
|
||||
app.run(port=config.get_config().web.port)
|
||||
calendar_db.close()
|
||||
@@ -0,0 +1,67 @@
|
||||
CREATE TABLE user(
|
||||
[name] TEXT NOT NULL,
|
||||
[password] TEXT NOT NULL,
|
||||
[is_admin] TINYINT NOT NULL CHECK(is_admin = 1 OR is_admin = 0),
|
||||
[salt] INTEGER NOT NULL,
|
||||
|
||||
PRIMARY KEY (name)
|
||||
);
|
||||
|
||||
CREATE TABLE token(
|
||||
[user] TEXT NOT NULL,
|
||||
[token] TEXT UNIQUE NOT NULL,
|
||||
[token_expire_on] BIGINT NOT NULL,
|
||||
[ua] TEXT NOT NULL,
|
||||
[ip] TEXT NOT NULL,
|
||||
|
||||
FOREIGN KEY (user) REFERENCES user(name) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE collection(
|
||||
[uuid] TEXT NOT NULL,
|
||||
[name] TEXT NOT NULL,
|
||||
[user] TEXT NOT NULL,
|
||||
[last_change] TEXT NOT NULL,
|
||||
|
||||
PRIMARY KEY (uuid),
|
||||
FOREIGN KEY (user) REFERENCES user(name) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE share(
|
||||
[uuid] TEXT NOT NULL,
|
||||
[target] TEXT NOT NULL,
|
||||
|
||||
FOREIGN KEY (uuid) REFERENCES collection(uuid) ON DELETE CASCADE
|
||||
FOREIGN KEY (target) REFERENCES user(name) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE calendar(
|
||||
[uuid] TEXT NOT NULL,
|
||||
[belong_to] TEXT NOT NULL,
|
||||
|
||||
[title] TEXT NOT NULL,
|
||||
[description] TEXT NOT NULL,
|
||||
[last_change] TEXT NOT NULL,
|
||||
|
||||
[event_date_time_start] BIGINT NOT NULL,
|
||||
[event_date_time_end] BIGINT NOT NULL,
|
||||
[timezone_offset] INT NOT NULL,
|
||||
|
||||
[loop_rules] TEXT NOT NULL,
|
||||
[loop_date_time_start] BIGINT NOT NULL,
|
||||
[loop_date_time_end] BIGINT NOT NULL,
|
||||
|
||||
PRIMARY KEY (uuid),
|
||||
FOREIGN KEY (belong_to) REFERENCES collection(uuid) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE todo(
|
||||
[uuid] TEXT NOT NULL,
|
||||
[belong_to] TEXT NOT NULL,
|
||||
|
||||
[data] TEXT NOT NULL,
|
||||
[last_change] TEXT NOT NULL,
|
||||
|
||||
PRIMARY KEY (uuid),
|
||||
FOREIGN KEY (belong_to) REFERENCES user(name) ON DELETE CASCADE
|
||||
);
|
||||
@@ -0,0 +1,64 @@
|
||||
import hashlib
|
||||
import random
|
||||
import uuid
|
||||
import time
|
||||
import math
|
||||
import re
|
||||
|
||||
USERNAME_PATTERN: re.Pattern = re.compile("^[0-9A-Za-z]+$")
|
||||
PASSWORD_PATTERN: re.Pattern = re.compile("^[!-~]+$")
|
||||
|
||||
|
||||
def IsValidUsername(strl: str) -> bool:
|
||||
return USERNAME_PATTERN.match(strl) is not None
|
||||
|
||||
|
||||
def IsValidPassword(strl: str) -> bool:
|
||||
return PASSWORD_PATTERN.match(strl) is not None
|
||||
|
||||
|
||||
def ComputePasswordHash(password: str) -> str:
|
||||
s = hashlib.sha256()
|
||||
s.update(password.encode("utf-8"))
|
||||
return s.hexdigest()
|
||||
|
||||
|
||||
def GenerateUUID() -> str:
|
||||
return str(uuid.uuid1())
|
||||
|
||||
|
||||
def GenerateToken(username: str) -> str:
|
||||
s = hashlib.sha256()
|
||||
s.update(username.encode("utf-8"))
|
||||
s.update(GenerateUUID().encode("utf-8"))
|
||||
return s.hexdigest()
|
||||
|
||||
|
||||
def GenerateSalt() -> int:
|
||||
return random.randint(0, 6172748)
|
||||
|
||||
|
||||
def ComputePasswordHashWithSalt(passwordHashed: str, salt: int) -> str:
|
||||
s = hashlib.sha256()
|
||||
s.update((passwordHashed + str(salt)).encode("utf-8"))
|
||||
return s.hexdigest()
|
||||
|
||||
|
||||
def GetCurrentTimestamp() -> int:
|
||||
return int(time.time())
|
||||
|
||||
|
||||
def GetTokenExpireOn() -> int:
|
||||
return GetCurrentTimestamp() + 60 * 60 * 24 * 2 # add 2 day from now
|
||||
|
||||
|
||||
def Str2Bool(strl: str) -> bool:
|
||||
return strl.lower() == "true"
|
||||
|
||||
|
||||
def GCD(a: int, b: int) -> int:
|
||||
return math.gcd(a, b)
|
||||
|
||||
|
||||
def LCM(a: int, b: int) -> int:
|
||||
return (a * b) // GCD(a, b)
|
||||
Generated
+117
@@ -0,0 +1,117 @@
|
||||
version = 1
|
||||
revision = 2
|
||||
requires-python = ">=3.11"
|
||||
|
||||
[manifest]
|
||||
constraints = [
|
||||
{ name = "markupsafe", specifier = "==2.1.5" },
|
||||
{ name = "werkzeug", specifier = "==2.2.2" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.3.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coleaf-backend"
|
||||
version = "1.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "flask" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "flask", specifier = "==2.2.3" }]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flask"
|
||||
version = "2.2.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "itsdangerous" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "werkzeug" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e8/5c/ff9047989bd995b1098d14b03013f160225db2282925b517bb4a967752ee/Flask-2.2.3.tar.gz", hash = "sha256:7eb373984bf1c770023fce9db164ed0c3353cd0b53f130f4693da0ca756a2e6d", size = 697599, upload-time = "2023-02-15T22:43:57.265Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/95/9c/a3542594ce4973786236a1b7b702b8ca81dbf40ea270f0f96284f0c27348/Flask-2.2.3-py3-none-any.whl", hash = "sha256:c0bec9477df1cb867e5a67c9e1ab758de9cb4a3e52dd70681f59fa40a62b3f2d", size = 101839, upload-time = "2023-02-15T22:43:55.501Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itsdangerous"
|
||||
version = "2.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jinja2"
|
||||
version = "3.1.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markupsafe"
|
||||
version = "2.1.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/87/5b/aae44c6655f3801e81aa3eef09dbbf012431987ba564d7231722f68df02d/MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b", size = 19384, upload-time = "2024-02-02T16:31:22.863Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/11/e7/291e55127bb2ae67c64d66cef01432b5933859dfb7d6949daa721b89d0b3/MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f", size = 18219, upload-time = "2024-02-02T16:30:19.988Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/cb/aed7a284c00dfa7c0682d14df85ad4955a350a21d2e3b06d8240497359bf/MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2", size = 14098, upload-time = "2024-02-02T16:30:21.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/cf/35fe557e53709e93feb65575c93927942087e9b97213eabc3fe9d5b25a55/MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced", size = 29014, upload-time = "2024-02-02T16:30:22.926Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/18/c30da5e7a0e7f4603abfc6780574131221d9148f323752c2755d48abad30/MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5", size = 28220, upload-time = "2024-02-02T16:30:24.76Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/40/2e73e7d532d030b1e41180807a80d564eda53babaf04d65e15c1cf897e40/MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c", size = 27756, upload-time = "2024-02-02T16:30:25.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/46/5dca760547e8c59c5311b332f70605d24c99d1303dd9a6e1fc3ed0d73561/MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f", size = 33988, upload-time = "2024-02-02T16:30:26.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/c5/27febe918ac36397919cd4a67d5579cbbfa8da027fa1238af6285bb368ea/MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a", size = 32718, upload-time = "2024-02-02T16:30:28.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/81/56e567126a2c2bc2684d6391332e357589a96a76cb9f8e5052d85cb0ead8/MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f", size = 33317, upload-time = "2024-02-02T16:30:29.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/0b/23f4b2470accb53285c613a3ab9ec19dc944eaf53592cb6d9e2af8aa24cc/MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906", size = 16670, upload-time = "2024-02-02T16:30:30.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/a2/c78a06a9ec6d04b3445a949615c4c7ed86a0b2eb68e44e7541b9d57067cc/MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617", size = 17224, upload-time = "2024-02-02T16:30:32.09Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/bd/583bf3e4c8d6a321938c13f49d44024dbe5ed63e0a7ba127e454a66da974/MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1", size = 18215, upload-time = "2024-02-02T16:30:33.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/d6/e7cd795fc710292c3af3a06d80868ce4b02bfbbf370b7cee11d282815a2a/MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4", size = 14069, upload-time = "2024-02-02T16:30:34.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/b5/5d8ec796e2a08fc814a2c7d2584b55f889a55cf17dd1a90f2beb70744e5c/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee", size = 29452, upload-time = "2024-02-02T16:30:35.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/0d/2454f072fae3b5a137c119abf15465d1771319dfe9e4acbb31722a0fff91/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5", size = 28462, upload-time = "2024-02-02T16:30:36.166Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/75/fd6cb2e68780f72d47e6671840ca517bda5ef663d30ada7616b0462ad1e3/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b", size = 27869, upload-time = "2024-02-02T16:30:37.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/81/147c477391c2750e8fc7705829f7351cf1cd3be64406edcf900dc633feb2/MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a", size = 33906, upload-time = "2024-02-02T16:30:39.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/ff/9a52b71839d7a256b563e85d11050e307121000dcebc97df120176b3ad93/MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f", size = 32296, upload-time = "2024-02-02T16:30:40.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/07/2dc76aa51b481eb96a4c3198894f38b480490e834479611a4053fbf08623/MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169", size = 33038, upload-time = "2024-02-02T16:30:42.243Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/0c/620c1fb3661858c0e37eb3cbffd8c6f732a67cd97296f725789679801b31/MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad", size = 16572, upload-time = "2024-02-02T16:30:43.326Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/14/c3554d512d5f9100a95e737502f4a2323a1959f6d0d01e0d0997b35f7b10/MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb", size = 17127, upload-time = "2024-02-02T16:30:44.418Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "werkzeug"
|
||||
version = "2.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f8/c1/1c8e539f040acd80f844c69a5ef8e2fccdf8b442dabb969e497b55d544e1/Werkzeug-2.2.2.tar.gz", hash = "sha256:7ea2d48322cc7c0f8b3a215ed73eabd7b5d75d0b50e31ab006286ccff9e00b8f", size = 844378, upload-time = "2022-08-08T21:44:15.376Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/27/be6ddbcf60115305205de79c29004a0c6bc53cec814f733467b1bb89386d/Werkzeug-2.2.2-py3-none-any.whl", hash = "sha256:f979ab81f58d7318e064e99c4506445d60135ac5cd2e177a2de0089bfd4c9bd5", size = 232700, upload-time = "2022-08-08T21:44:13.251Z" },
|
||||
]
|
||||
@@ -0,0 +1,32 @@
|
||||
# If you prefer the allow list template instead of the deny list, see community template:
|
||||
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
||||
#
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Code coverage profiles and other test artifacts
|
||||
*.out
|
||||
coverage.*
|
||||
*.coverprofile
|
||||
profile.cov
|
||||
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
# vendor/
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
go.work.sum
|
||||
|
||||
# env file
|
||||
.env
|
||||
|
||||
# Editor/IDE
|
||||
# .idea/
|
||||
# .vscode/
|
||||
@@ -0,0 +1,45 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// CLIArgs 保存从命令行解析得到的参数,对应旧后端 coconut-leaf.py 中 argparse 解析的结果。
|
||||
type CLIArgs struct {
|
||||
// Config 是 coconut-leaf 的配置文件路径(即旧后端的 CONFIG_TOML)。
|
||||
Config string
|
||||
// Init 指示是否在启动前初始化日历系统。
|
||||
Init bool
|
||||
}
|
||||
|
||||
// ParseCLI 使用标准库 flag 包解析命令行参数并返回 *CLIArgs。
|
||||
// 当必需的 --config 参数缺失时,打印用法并以退出码 2 退出(与 argparse 的退出码一致)。
|
||||
func ParseCLI() *CLIArgs {
|
||||
args := &CLIArgs{}
|
||||
|
||||
// 自定义用法输出,复刻旧后端 ArgumentParser 的 description 横幅。
|
||||
flag.Usage = func() {
|
||||
out := flag.CommandLine.Output()
|
||||
fmt.Fprintln(out, "The server of light, self-host and multi-account calendar system.")
|
||||
fmt.Fprintln(out)
|
||||
fmt.Fprintf(out, "Usage of %s:\n", os.Args[0])
|
||||
flag.PrintDefaults()
|
||||
}
|
||||
|
||||
// --config 对应旧后端的 -c/--config(必需),--init 对应旧后端的 -i/--init。
|
||||
flag.StringVar(&args.Config, "config", "", "The configuration file `CONFIG_TOML` for coconut-leaf")
|
||||
flag.BoolVar(&args.Init, "init", false, "Set for initialize the calendar system")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
// 标准库 flag 不支持 required,故解析后手动校验。
|
||||
if args.Config == "" {
|
||||
fmt.Fprintln(os.Stderr, "error: the required flag --config is not provided")
|
||||
flag.Usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
// DatabaseDriver 表示配置文件中指定的数据库驱动类型,对应旧后端的 DatabaseDriver(StrEnum)。
|
||||
// 用 const 配合 iota 模拟数值枚举,字符串形式由 databaseDriverFromString 转换。
|
||||
type DatabaseDriver int
|
||||
|
||||
const (
|
||||
// DatabaseDriverSqlite 表示使用 SQLite 数据库。
|
||||
DatabaseDriverSqlite DatabaseDriver = iota
|
||||
// DatabaseDriverMysql 表示使用 MySQL 数据库。
|
||||
DatabaseDriverMysql
|
||||
)
|
||||
|
||||
// databaseDriverFromString 将配置文件中的 driver 字符串转换为 DatabaseDriver,
|
||||
// 无法识别的字符串将返回错误。
|
||||
func databaseDriverFromString(s string) (DatabaseDriver, error) {
|
||||
switch s {
|
||||
case "sqlite":
|
||||
return DatabaseDriverSqlite, nil
|
||||
case "mysql":
|
||||
return DatabaseDriverMysql, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("invalid database driver: %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
// SqliteDatabaseConfig 是 SQLite 数据库的连接配置,对应旧后端 SqliteDatabaseConfig。
|
||||
type SqliteDatabaseConfig struct {
|
||||
// Path 是数据库文件的路径。
|
||||
Path string `toml:"path"`
|
||||
}
|
||||
|
||||
// MysqlDatabaseConfig 是 MySQL 数据库的连接配置,对应旧后端 MysqlDatabaseConfig。
|
||||
type MysqlDatabaseConfig struct {
|
||||
// Host 是数据库主机地址。
|
||||
Host string `toml:"host"`
|
||||
// Port 是数据库端口。
|
||||
Port int `toml:"port"`
|
||||
// User 是数据库用户名。
|
||||
User string `toml:"user"`
|
||||
// Password 是数据库密码。
|
||||
Password string `toml:"password"`
|
||||
// Database 是数据库名称。
|
||||
Database string `toml:"database"`
|
||||
}
|
||||
|
||||
// DatabaseConfig 描述数据库驱动及其对应的具体配置,对应旧后端 DatabaseConfig。
|
||||
// 其中 Driver 起判别作用,决定 Sqlite 或 Mysql 中的哪一个被填充。
|
||||
type DatabaseConfig struct {
|
||||
// Driver 是当前选择的数据库驱动。
|
||||
Driver DatabaseDriver
|
||||
// Sqlite 在 Driver 为 DatabaseDriverSqlite 时非空。
|
||||
Sqlite *SqliteDatabaseConfig
|
||||
// Mysql 在 Driver 为 DatabaseDriverMysql 时非空。
|
||||
Mysql *MysqlDatabaseConfig
|
||||
}
|
||||
|
||||
// WebConfig 是 Web 服务器的配置,对应旧后端 WebConfig。
|
||||
type WebConfig struct {
|
||||
// Port 是 Web 服务器监听端口。
|
||||
Port int `toml:"port"`
|
||||
}
|
||||
|
||||
// OthersConfig 是其余杂项配置,对应旧后端 OthersConfig。
|
||||
type OthersConfig struct {
|
||||
// Debug 指示是否启用调试模式。
|
||||
Debug bool `toml:"debug"`
|
||||
// AutoTokenCleanDuration 是自动清理过期 token 的时间间隔(秒)。
|
||||
AutoTokenCleanDuration int `toml:"auto-token-clean-duration"`
|
||||
}
|
||||
|
||||
// Config 是从配置文件解析得到的完整配置,对应旧后端 Config。
|
||||
type Config struct {
|
||||
// Database 是数据库相关配置。
|
||||
Database DatabaseConfig
|
||||
// Web 是 Web 服务器相关配置。
|
||||
Web WebConfig
|
||||
// Others 是其余杂项配置。
|
||||
Others OthersConfig
|
||||
}
|
||||
|
||||
// rawDatabaseConfig 是仅用于 TOML 解析的内部结构。
|
||||
// 其 Config 字段以 toml.Primitive 暂存,以便依据 Driver 做两段式分发解码。
|
||||
type rawDatabaseConfig struct {
|
||||
Driver string `toml:"driver"`
|
||||
Config toml.Primitive `toml:"config"`
|
||||
}
|
||||
|
||||
// rawConfig 是仅用于 TOML 解析的内部结构,配合两段式解码使用。
|
||||
type rawConfig struct {
|
||||
Database rawDatabaseConfig `toml:"database"`
|
||||
Web WebConfig `toml:"web"`
|
||||
Others OthersConfig `toml:"others"`
|
||||
}
|
||||
|
||||
// LoadConfig 从给定路径读取并解析 TOML 配置文件,返回解析后的配置或错误。
|
||||
// 它合并了旧后端的 setup_config 与 get_config:第一阶段用 toml.DecodeFile 暂存 database.config,
|
||||
// 第二阶段依据 driver 用 PrimitiveDecode 分发解码到 SqliteDatabaseConfig 或 MysqlDatabaseConfig。
|
||||
func LoadConfig(path string) (*Config, error) {
|
||||
var raw rawConfig
|
||||
meta, err := toml.DecodeFile(path, &raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
driver, err := databaseDriverFromString(raw.Database.Driver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
Web: raw.Web,
|
||||
Others: raw.Others,
|
||||
Database: DatabaseConfig{
|
||||
Driver: driver,
|
||||
},
|
||||
}
|
||||
|
||||
switch driver {
|
||||
case DatabaseDriverSqlite:
|
||||
var s SqliteDatabaseConfig
|
||||
if err := meta.PrimitiveDecode(raw.Database.Config, &s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Database.Sqlite = &s
|
||||
case DatabaseDriverMysql:
|
||||
var m MysqlDatabaseConfig
|
||||
if err := meta.PrimitiveDecode(raw.Database.Config, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Database.Mysql = &m
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
module github.com/yyc12345/coconut-leaf/backend
|
||||
|
||||
go 1.26.2
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.6.0
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
)
|
||||
@@ -0,0 +1,91 @@
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,32 @@
|
||||
// main.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func main() {
|
||||
args := ParseCLI()
|
||||
|
||||
cfg, err := LoadConfig(args.Config)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
_ = cfg
|
||||
|
||||
// 创建默认的 Gin 引擎(包含 Logger 和 Recovery 中间件)
|
||||
r := gin.Default()
|
||||
|
||||
// 定义路由
|
||||
r.GET("/", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Hello, Gin!",
|
||||
})
|
||||
})
|
||||
|
||||
//启动服务器(默认端口8088)
|
||||
r.Run(":8080")
|
||||
}
|
||||
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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user