29 Commits
Author SHA1 Message Date
yyc12345 93812b818d refactor: remove legacy frontend 2026-07-21 20:41:12 +08:00
yyc12345 741af85d86 doc: update README and ROADMAP 2026-07-21 20:40:43 +08:00
yyc12345 5429166334 feat: update frontend icon 2026-07-21 20:34:56 +08:00
yyc12345 79b667b644 doc: update frontend doc 2026-07-21 20:30:03 +08:00
yyc12345 443e4e4d5c refactor: add window title support 2026-07-21 20:18:20 +08:00
yyc12345 e16a40148a refactor: support i18n in frontend 2026-07-21 19:34:19 +08:00
yyc12345 1a9eb9299b fix: fix lost token validation 2026-07-21 14:54:46 +08:00
yyc12345 36ce59c4ab refactor: migrate frontend event page 2026-07-21 14:34:23 +08:00
yyc12345 02546c12f1 refactor: migrate frontend calendar page 2026-07-21 13:06:27 +08:00
yyc12345 c105d878fe refactor: migrate frontend datetimepicker 2026-07-20 21:59:57 +08:00
yyc12345 8e6ce7f6c4 refactor: migrate frontend admin page 2026-07-19 22:55:24 +08:00
yyc12345 108d1bac5c fix: fix build warning and linter error 2026-07-19 16:25:46 +08:00
yyc12345 0402f2d2b7 refactor: migrate frontend todo page 2026-07-19 15:57:50 +08:00
yyc12345 4c02b47620 doc: update backend doc 2026-07-17 21:20:39 +08:00
yyc12345 4893c8ddb0 revert: revoke all ai-generated content 2026-07-17 21:06:54 +08:00
yyc12345 fe0314015c feat: commit ai-produced frontend first 2026-07-17 21:02:27 +08:00
yyc12345 06001b29f8 refactor: remove legacy backend 2026-07-17 16:45:38 +08:00
yyc12345 bb70f3432d doc: add readme and config toml example for new backend 2026-07-17 16:44:57 +08:00
yyc12345 cabe17884a feat: update modern nginx conf for backend and frontend 2026-07-17 15:16:13 +08:00
yyc12345 9e0facdf83 feat: add nginx conf for legacy backend and frontend 2026-07-17 15:08:11 +08:00
yyc12345 8941f625c8 fix: fix logger style err and gin warning 2026-07-17 12:12:14 +08:00
yyc12345 44e1415194 refactor: migrate backend main function 2026-07-17 10:53:29 +08:00
yyc12345 3c0a763572 refactor: migrate backend server 2026-07-17 10:53:08 +08:00
yyc12345 20bf3a0c34 refactor: update backend database return type 2026-07-16 20:51:18 +08:00
yyc12345 14350f84c5 refactor: migrate backend database 2026-07-16 20:13:49 +08:00
yyc12345 7e335c51c6 refactor: migrate backend dt 2026-07-15 20:33:14 +08:00
yyc12345 9f7fd6e5e7 refactor: migrate logger 2026-07-15 15:33:05 +08:00
yyc12345 1cc6b7b1ba refactor: use new struct in backend 2026-07-15 11:08:45 +08:00
yyc12345 a8de650a5a refactor: migrate utils in backend 2026-07-15 10:53:24 +08:00
119 changed files with 5972 additions and 9362 deletions
+10 -3
View File
@@ -4,9 +4,16 @@ A self-host, multi-account calendar system.
## Warning
This project still work in progress. Because this project need a massive refactor now.
If you want to check out the first version which can fufill basic usage, please switch to `v1-maintain` branch. In `main` branch, I am refactoring v1 and it will be updated to v2 in future.
The first version of this project have too much C-style JavaScript. It is too complicated to maintain and cannot add any other new features. Therefore, it needs to be fully refactored using ES6 and some modern JavaScript tools. It will come soon.
This project is **NOT** suit for any cases in production environment.
It is just a toybox where I learn modern frontend and backend.
## Version Infos
If you want to check out the first version which can fufill basic usage, please switch to `v1-maintain` branch.
It is fully written in Python Flask backend and jQuery frontend.
In `master` branch, I am developing with Go backend and Vue frontend.
And I also learn them from it.
## Features & shortcomings
+8 -8
View File
@@ -1,10 +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重写后端。
- [x] 前后端分离,将前端静态文件和后端Python分装到两个文件夹中。同时辅助类文件夹改换位置。
- [x] 后端使用Astral UV重构,使得项目可以跑起来。
- [x] 定为1.1版本。1.0版本也要打tag然后提交。然后分叉v1-maintain分支。后续在master上开发v2。
- [x] 后端数据库字段重命名。
- [ ] 前后端通信API命名格式修改。
- [x] 使用Vue重写前端
- [ ] 使用Tailwind重写前端CSS
- [x] 使用Go重写后端。
@@ -1,9 +1,12 @@
# Driver: "sqlite" or "mysql"
[database]
driver = "sqlite"
# Path to the SQLite database file
[database.config]
path = "coconut-leaf.db"
# MySQL connection parameters (placeholder, not yet implemented)
# [database]
# driver = "mysql"
#
@@ -14,9 +17,12 @@ path = "coconut-leaf.db"
# password = "password"
# database = "coconut_leaf"
# HTTP listening port
[web]
port = 8848
[others]
# Interval (in seconds) for automatic cleanup of expired tokens
auto-token-clean-duration = 86400
# Debug mode; must be set to false in production
debug = true
+98
View File
@@ -0,0 +1,98 @@
# Setup this path to coconut-leaf legacy frontend.
set $frontend_root /var/www/coconut-leaf/frontend-legacy;
root $frontend_root;
index home.html;
# ============================
# Route 1: /web -> HTML templates with extensionless fallback
# ============================
location /web {
# Use alias to exactly mapping
alias $frontend_root/templates;
# Optimize for static file
expires 7d;
add_header Cache-Control "public, max-age=604800";
gzip_static on;
# Try file with .html suffix
try_files $uri.html $uri =404;
}
# /web/eventAdd -> templates/event.html, replace {{uuidPath}} with blank
location = /web/eventAdd {
alias $frontend_root/templates/event.html;
default_type text/html;
sub_filter '{{uuidPath}}' '';
sub_filter_once on;
}
# /web/eventUpdate/<path:uuidPath> -> templates/event.html, replace {{uuidPath}} with uuidPath
location ~ ^/web/eventUpdate/(?<uuidPath>.+)$ {
alias $frontend_root/templates/event.html;
default_type text/html;
sub_filter '{{uuidPath}}' $uuidPath;
sub_filter_once on;
}
# ============================
# Route 2: /static -> static files
# ============================
location /static {
# Use alias to exactly mapping
alias $frontend_root/static;
# Optimize for static file
expires 7d;
add_header Cache-Control "public, max-age=604800";
gzip_static on;
# Try file with .html suffix
try_files $uri $uri =404;
}
# ============================
# Route 3: /api -> reverse proxy to backend
# ============================
location /api {
# Rewrite to remove "api" prefix
rewrite ^/api(/.*)$ $1 break;
# Reverse proxy to localhost backend
proxy_pass http://127.0.0.1:8848;
# Keep original request headers
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;
# Optional: WebSocket support
# proxy_http_version 1.1;
# proxy_set_header Upgrade $http_upgrade;
# proxy_set_header Connection "upgrade";
# Time configuration
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# Buffer setup (changed when meeting large file uploading)
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
}
# ============================
# Root path redirect: Root -> /web/home
# ============================
location = / {
return 302 /web/home;
}
# Deny hidden files at root level
location ~ /\. {
deny all;
return 404;
}
+23 -20
View File
@@ -1,60 +1,63 @@
# Setup this path to coconut-leaf frontend.
set $frontend_root /var/www/coconut-leaf/frontend/dist;
# ============================
# 路由 1: /web -> 静态文件
# Route 1: /web -> Vite-built SPA
# ============================
location /web {
# 使用 alias 精确映射
# 请求 /web/index.html -> /var/www/static/index.html
alias /var/www/static;
alias $frontend_root;
index index.html;
# 静态文件优化
# Optimize for static file
expires 7d;
add_header Cache-Control "public, max-age=604800";
# 尝试返回文件,不存在则返回404(避免落入其他location
try_files $uri $uri/ =404;
# 可选:启用 gzip 压缩
gzip_static on;
# SPA fallback: for non-file routes, serve index.html
try_files $uri $uri/ /web/index.html;
}
# ============================
# 路由 2: /api -> Go 程序 (8848端口)
# Route 3: /api -> reverse proxy to backend
# ============================
location /api {
# 反向代理到本地 Go 服务
# Rewrite to remove "api" prefix
rewrite ^/api(/.*)$ $1 break;
# Reverse proxy to localhost backend
proxy_pass http://127.0.0.1:8848;
# 重要:保留原始请求头
# Keep original request headers
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 程序需要)
# Optional: WebSocket support
# proxy_http_version 1.1;
# proxy_set_header Upgrade $http_upgrade;
# proxy_set_header Connection "upgrade";
# 超时设置(根据业务调整)
# Time configuration
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# 缓冲设置(可选,大文件上传时注意调整)
# Buffer setup (changed when meeting large file uploading)
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
}
# ============================
# 可选:根路径处理
# Root path redirect: Root -> /web/home
# ============================
location = / {
# 重定向到 /web
return 302 /web/;
return 302 /web/home;
}
# 禁止访问隐藏文件
# Deny hidden files at root level
location ~ /\. {
deny all;
return 404;
-19
View File
@@ -1,19 +0,0 @@
## ======== 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
-1
View File
@@ -1 +0,0 @@
3.11
View File
-84
View File
@@ -1,84 +0,0 @@
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()
-130
View File
@@ -1,130 +0,0 @@
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
-708
View File
@@ -1,708 +0,0 @@
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
-289
View File
@@ -1,289 +0,0 @@
import datetime
import re
import logging
import typing
from functools import reduce
import utils
MonthDayCount = (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
MIN_DATETIME = datetime.datetime(1950, 1, 1, 0, 0, 0, 0, tzinfo=datetime.timezone.utc)
MAX_DATETIME = datetime.datetime(2200, 1, 1, 0, 0, 0, 0, tzinfo=datetime.timezone.utc)
MIN_TIMESTAMP = int(MIN_DATETIME.timestamp() / 60)
MAX_TIMESTAMP = int(MAX_DATETIME.timestamp() / 60)
DAY1_SPAN = 60 * 24
DAY7_SPAN = 7 * DAY1_SPAN
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
# try compute from loopStop
(loopRules, loopStopRules) = strl.split('-')
cache = precompiledLoopStopRules['infinity'].search(loopStopRules)
if cache is not None:
return MAX_TIMESTAMP
cache = precompiledLoopStopRules['datetime'].search(loopStopRules)
if cache is not None:
return int(cache.group(1)) # group 1 is datetime
cache = precompiledLoopStopRules['times'].search(loopStopRules)
if cache is not None:
loopTimes = int(cache.group(1)) # for follwing calc
else:
raise Exception('Invalid loopStopRules') # invalid rules
for rules in precompiledLoopRules:
cache = rules[0].search(loopRules)
if cache is not None:
return rules[1](cache, starttime, loopTimes, tzoffset)
else:
raise Exception('Invalid loopRules')
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))
times -= 1
newYear = clientYear = clientDate.year
newMonth = clientMonth = clientDate.month
newDay = clientDay = clientDate.day
if clientMonth == 2 and clientDay == 29:
if isStrict:
realSpan = utils.LCM(yearSpan, 4)
logging.debug(realSpan)
valCache = starttime
while valCache < MAX_TIMESTAMP and times > 0:
newYear += realSpan
if not IsLeapYear(newYear):
continue
valCache = starttime + DAY1_SPAN * (DaysCount(newYear, newMonth, newDay) - DaysCount(clientYear, clientMonth, clientDay))
times -= 1
else:
newYear += times * yearSpan
if not IsLeapYear(newYear):
newDay = 28 # migrate to 28
else:
# if times == 1, no extra datetime need to be added
newYear += times * yearSpan
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: re.Match, starttime: int, times: int, tzoffset: int) -> int:
isStrict = searchResult.group(1) == 'S'
loopType = searchResult.group(2)
monthSpan = int(searchResult.group(3))
# we should get original data in each method
times -= 1
clientDate = datetime.datetime.fromtimestamp(starttime * 60, UTCTimezone(tzoffset))
newYear = clientYear = clientDate.year
newMonth = clientMonth = clientDate.month
newDay = clientDay = clientDate.day
# data struct
# dayStatistics =
# (dayForwards || dayBackwards || weeksForward, dayOfWeek || weeksBackwards, dayOfWeek)
# ( A || B || C || D )
dayStatistics = GetDayInMonth(clientYear, clientMonth, clientDay)
if isStrict:
if loopType == 'A':
while times > 0:
newMonth += monthSpan
if newMonth > 12:
newYear += int((newMonth - 1) / 12)
newMonth = ((newMonth - 1) % 12) + 1
if newYear > MAX_DATETIME.year:
break
maxDays = MonthDayCount[newMonth - 1] + (1 if newMonth == 2 and IsLeapYear(newYear) else 0)
if dayStatistics[0] <= maxDays:
times -= 1
elif loopType == 'B':
while times > 0:
newMonth += monthSpan
if newMonth > 12:
newYear += int((newMonth - 1) / 12)
newMonth = ((newMonth - 1) % 12) + 1
if newYear > MAX_DATETIME.year:
break
maxDays = MonthDayCount[newMonth - 1] + (1 if newMonth == 2 and IsLeapYear(newYear) else 0)
if dayStatistics[1] <= maxDays:
times -= 1
elif loopType == 'C':
while times > 0:
newMonth += monthSpan
if newMonth > 12:
newYear += int((newMonth - 1) / 12)
newMonth = ((newMonth - 1) % 12) + 1
if newYear > MAX_DATETIME.year:
break
monthStatistics = GetMonthWeekStatistics(newYear, newMonth)
if dayStatistics[2] <= monthStatistics[dayStatistics[3]]:
times -= 1
elif loopType == 'D':
while times > 0:
newMonth += monthSpan
if newMonth > 12:
newYear += int((newMonth - 1) / 12)
newMonth = ((newMonth - 1) % 12) + 1
if newYear > MAX_DATETIME.year:
break
monthStatistics = GetMonthWeekStatistics(newYear, newMonth)
if dayStatistics[4] <= monthStatistics[dayStatistics[5]]:
times -= 1
else:
newMonth += times * monthSpan
newYear += int((newMonth - 1) / 12)
newMonth = ((newMonth - 1) % 12) + 1
# all method need calc newDay and it should be the last day of current selected month
# so calc it in there
newDay = MonthDayCount[newMonth - 1] + (1 if newMonth == 2 and IsLeapYear(newYear) else 0)
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: 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:
raise Exception('Invalid week format')
weekSpan = int(searchResult.group(2))
nowDayOfWeek = datetime.datetime.fromtimestamp(starttime * 60, UTCTimezone(tzoffset)).weekday()
if not weekOccupied[nowDayOfWeek]:
times-=1 # if first event is not suit for week loop rules, minus one more event to suit it.
fullWeek = int(times / weekEventCount)
remainEvent = times % weekEventCount
val = starttime + DAY7_SPAN * fullWeek * weekSpan
if val > MAX_TIMESTAMP:
return MAX_TIMESTAMP # return now, to reduce calc usage
while remainEvent != 0:
val += DAY1_SPAN
if weekOccupied[nowDayOfWeek % 7]:
remainEvent -= 1
nowDayOfWeek += 1
val -= 1
return val if val < MAX_TIMESTAMP else MAX_TIMESTAMP
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: 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: 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: int, includeThis: bool = False, baseYear: int = 1, includeBase: bool = True):
if not includeThis:
endYear -= 1
if includeBase:
baseYear -= 1
endly = int(endYear / 4)
endly -= int(endYear / 100)
endly += int(endYear / 400)
basely = int(baseYear / 4)
basely -= int(baseYear / 100)
basely += int(baseYear / 400)
return (endly - basely)
def LeapYearCount(year: int):
return LeapYearCountEx(year, False, 1, True)
def IsLeapYear(year: int):
isLeap = False
if year % 4 == 0:
isLeap = True
if year % 100 == 0:
isLeap = False
if year % 400 == 0:
isLeap = True
return isLeap
def DaysCount(year: int, month: int, day: int):
ly = LeapYearCountEx(year, False, 1, True)
days = 365 * (year - 1)
days += ly
for index in range(1, month, 1):
days += MonthDayCount[index - 1]
if (month > 2) and IsLeapYear(year):
days += 1
days += day - 1
return days
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
days=DaysCount(year, month, day)
# return day of week (from 0 - 6, corresponding with python)
return days % 7
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
dayForwards = day
dayBackwards = days - day + 1
weeksForward = int((dayForwards - 1) / 7) + 1
weeksBackwards = int((dayBackwards - 1) / 7) + 1
return (dayForwards, dayBackwards, weeksForward, dayOfWeek, weeksBackwards, dayOfWeek)
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
result = [4, 4, 4, 4, 4, 4, 4]
remain = days % 7
week = firstDayOfWeek
while remain > 0:
result[week % 7] += 1
week += 1
remain -= 1
return tuple(result)
class UTCTimezone(datetime.tzinfo):
__offset: int
def __init__(self, offset: int = 0):
self.__offset = offset
def utcoffset(self, dt):
return datetime.timedelta(minutes=self.__offset)
def tzname(self, dt):
return 'UTC {}'.format(self.__offset)
def dst(self, dt):
return datetime.timedelta(0)
-42
View File
@@ -1,42 +0,0 @@
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)
-14
View File
@@ -1,14 +0,0 @@
[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"
]
-513
View File
@@ -1,513 +0,0 @@
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()
View File
-67
View File
@@ -1,67 +0,0 @@
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
);
-64
View File
@@ -1,64 +0,0 @@
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)
-117
View File
@@ -1,117 +0,0 @@
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" },
]
+69
View File
@@ -0,0 +1,69 @@
# coconut-leaf Backend
The backend service of coconut-leaf,
implemented with [Go](https://go.dev/) + [Gin](https://gin-gonic.com/),
migrated from the old Python Flask backend.
The data layer executes SQL directly (no ORM).
> [!NOTE]
> Currently only SQLite is fully implemented.
> MySQL interfaces are placeholders (all endpoints return "not implemented" errors).
## Requirements
- **Go >= 1.26**
- **C compiler (cgo)**: The SQLite driver [`mattn/go-sqlite3`](https://github.com/mattn/go-sqlite3) requires CGO feature.
- Windows: MSYS2's gcc is recommended.
- Linux / macOS: System-provided gcc / clang works.
- Ensure `CGO_ENABLED=1` before building (usually enabled by default when a C compiler is available).
## Build
In the `backend/` directory:
```sh
go build -o coconut-leaf
```
> [!NOTE]
> The first build compiles SQLite C source code; 13 minutes is normal.
>
> On Windows, executables built with MSYS2 depend on MSYS2 runtime libraries
> (I use MSYS2 UCRT64, so it may include `libgcc_s_*`, `libwinpthread-1.dll`, etc.).
> Either add the DLL directory to PATH or copy those DLLs next to the executable.
## Configuration
Uses TOML format, specified via `-config` parameter.
See the full template at [coconut-leaf.template.toml](../assets/coconut-leaf.template.toml) in the `assets` directory.
## Command-line Arguments
| Argument | Description |
| --- | --- |
| `-config <PATH>` | **Required**, path to the TOML configuration file |
| `-init` | Initialize the system: create tables and the first admin user |
| `-username <NAME>` | Used with `-init`, the initial admin username |
| `-password <PASS>` | Used with `-init`, the initial admin password |
`-username` / `-password` are required only when `-init` is specified and are validated
(username: `[0-9A-Za-z]+`, password: all visible ASCII characters, i.e., `[!-~]+`).
## Initialization and Runtime
### First Deployment
First deployment includes creating tables and the admin user, then running as usual:
```sh
./coconut-leaf --config coconut-leaf.toml --init --username admin --password "your-password"
```
### Normal Runtime
```sh
./coconut-leaf --config coconut-leaf.toml
```
After starting, it listens on `web.port`.
Debug logs are written to stderr in the format `[<time>] [<level>] <message> [key=value ...]`.
-45
View File
@@ -1,45 +0,0 @@
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
}
+55
View File
@@ -0,0 +1,55 @@
package cli
import (
"flag"
"fmt"
"os"
)
// CLIArgs holds the arguments parsed from the command line, mirroring the
// argparse result in the legacy coconut-leaf.py.
type CLIArgs struct {
// Config is the path to the configuration file (the legacy CONFIG_TOML).
Config string
// Init indicates whether to initialize the calendar system before running.
Init bool
// Username is the first admin user's name; required together with -init.
Username string
// Password is the first admin user's password; required together with -init.
Password string
}
// Parse parses the command-line arguments via the standard flag package and
// returns a *CLIArgs. When the required -config flag is missing, it prints the
// usage and exits with status 2 (matching argparse's exit code).
func Parse() *CLIArgs {
args := &CLIArgs{}
// Custom usage output mirroring the legacy ArgumentParser description banner.
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 maps to the legacy -c/--config (required), -init to -i/--init.
// -username/-password replace the legacy interactive GetUsernamePassword
// prompt and are required together with --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.StringVar(&args.Username, "username", "", "The first admin user's name (required with -init)")
flag.StringVar(&args.Password, "password", "", "The first admin user's password (required with -init)")
flag.Parse()
// The standard flag package has no built-in "required", so validate manually.
if args.Config == "" {
fmt.Fprintln(os.Stderr, "error: the required flag -config is not provided")
flag.Usage()
os.Exit(2)
}
return args
}
-141
View File
@@ -1,141 +0,0 @@
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
}
+151
View File
@@ -0,0 +1,151 @@
package config
import (
"fmt"
"github.com/BurntSushi/toml"
)
// DatabaseDriver represents the database driver specified in the config file,
// mirroring the legacy DatabaseDriver(StrEnum). It is a numeric enum simulated
// with const + iota; its string form is produced by databaseDriverFromString.
type DatabaseDriver int
const (
// DatabaseDriverSqlite means the SQLite database is used.
DatabaseDriverSqlite DatabaseDriver = iota
// DatabaseDriverMysql means the MySQL database is used.
DatabaseDriverMysql
)
// databaseDriverFromString converts a driver string from the config file into a
// DatabaseDriver. An unrecognized string returns an error.
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 is the connection config for SQLite, mirroring the
// legacy SqliteDatabaseConfig.
type SqliteDatabaseConfig struct {
// Path is the database file path.
Path string `toml:"path"`
}
// MysqlDatabaseConfig is the connection config for MySQL, mirroring the legacy
// MysqlDatabaseConfig.
type MysqlDatabaseConfig struct {
// Host is the database host.
Host string `toml:"host"`
// Port is the database port.
Port int `toml:"port"`
// User is the database user.
User string `toml:"user"`
// Password is the database password.
Password string `toml:"password"`
// Database is the database name.
Database string `toml:"database"`
}
// DatabaseConfig describes the database driver and its concrete config, mirroring
// the legacy DatabaseConfig. Driver is the discriminator deciding whether Sqlite
// or Mysql is populated.
type DatabaseConfig struct {
// Driver is the selected database driver.
Driver DatabaseDriver
// Sqlite is non-nil when Driver is DatabaseDriverSqlite.
Sqlite *SqliteDatabaseConfig
// Mysql is non-nil when Driver is DatabaseDriverMysql.
Mysql *MysqlDatabaseConfig
}
// WebConfig is the web server config, mirroring the legacy WebConfig.
type WebConfig struct {
// Port is the web server listening port.
Port int `toml:"port"`
}
// OthersConfig holds miscellaneous options, mirroring the legacy OthersConfig.
type OthersConfig struct {
// Debug indicates whether debug mode is enabled.
Debug bool `toml:"debug"`
// AutoTokenCleanDuration is the interval (in seconds) for auto-cleaning
// outdated tokens.
AutoTokenCleanDuration int `toml:"auto-token-clean-duration"`
}
// Config is the full configuration parsed from the config file, mirroring the
// legacy Config.
type Config struct {
// Database is the database-related config.
Database DatabaseConfig
// Web is the web-server-related config.
Web WebConfig
// Others is the miscellaneous config.
Others OthersConfig
}
// rawDatabaseConfig is an internal struct used only for TOML decoding. Its
// Config field is stored as a toml.Primitive so it can be dispatched and decoded
// in two phases based on Driver.
type rawDatabaseConfig struct {
Driver string `toml:"driver"`
Config toml.Primitive `toml:"config"`
}
// rawConfig is an internal struct used only for TOML decoding, in tandem with
// the two-phase decode.
type rawConfig struct {
Database rawDatabaseConfig `toml:"database"`
Web WebConfig `toml:"web"`
Others OthersConfig `toml:"others"`
}
// Load reads and parses the TOML config file at the given path, returning the
// parsed config or an error. It merges the legacy setup_config and get_config:
// the first phase uses toml.DecodeFile to defer database.config, and the second
// phase dispatches PrimitiveDecode into SqliteDatabaseConfig or
// MysqlDatabaseConfig based on the driver.
func Load(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
}
+86
View File
@@ -0,0 +1,86 @@
package database
import (
"context"
"log/slog"
"github.com/yyc12345/coconut-leaf/backend/config"
)
// CalendarUpdateOptions carries the optional fields of CalendarUpdate. A nil
// pointer means "not provided", mirroring the legacy **optArgs semantics: a
// field is applied only when its pointer is non-nil.
type CalendarUpdateOptions struct {
BelongTo *string
Title *string
Description *string
EventDateTimeStart *int64
EventDateTimeEnd *int64
LoopRules *string
TimezoneOffset *int64
}
// AdminUpdateOptions carries the optional fields of AdminUpdate. A nil pointer
// means "not provided".
type AdminUpdateOptions struct {
Password *string
IsAdmin *bool
}
// Deps bundles the shared dependencies a database implementation needs: the
// loaded configuration and a logger. It is passed to database constructors and
// stored inside each implementation so they can read config and emit logs.
type Deps struct {
Cfg *config.Config
Logger *slog.Logger
}
// Database describes every database operation the Gin server may use. It is the
// Go equivalent of the legacy CalendarDatabase public surface: each method takes
// a context.Context for cancellation and returns (any, error). The server layer
// is responsible for wrapping the result into an HTTP response body.
type Database interface {
Init(username, password string) error
Close() error
CommonSalt(ctx context.Context, username string) (any, error)
CommonLogin(ctx context.Context, username, password, clientUa, clientIp string) (any, error)
CommonWebLogin(ctx context.Context, username, password, clientUa, clientIp string) (any, error)
CommonLogout(ctx context.Context, token string) (any, error)
CommonTokenValid(ctx context.Context, token string) (any, error)
CalendarGetFull(ctx context.Context, token string, startDateTime, endDateTime int64) (any, error)
CalendarGetList(ctx context.Context, token string, startDateTime, endDateTime int64) (any, error)
CalendarGetDetail(ctx context.Context, token, uuid string) (any, error)
CalendarUpdate(ctx context.Context, token, uuid, lastChange string, opts CalendarUpdateOptions) (any, error)
CalendarAdd(ctx context.Context, token, belongTo, title, description string, eventDateTimeStart, eventDateTimeEnd int64, loopRules string, timezoneOffset int64) (any, error)
CalendarDelete(ctx context.Context, token, uuid, lastChange string) (any, error)
CollectionGetFullOwn(ctx context.Context, token string) (any, error)
CollectionGetListOwn(ctx context.Context, token string) (any, error)
CollectionGetDetailOwn(ctx context.Context, token, uuid string) (any, error)
CollectionAddOwn(ctx context.Context, token, name string) (any, error)
CollectionUpdateOwn(ctx context.Context, token, uuid, name, lastChange string) (any, error)
CollectionDeleteOwn(ctx context.Context, token, uuid, lastChange string) (any, error)
CollectionGetSharing(ctx context.Context, token, uuid string) (any, error)
CollectionDeleteSharing(ctx context.Context, token, uuid, target, lastChange string) (any, error)
CollectionAddSharing(ctx context.Context, token, uuid, target, lastChange string) (any, error)
CollectionGetShared(ctx context.Context, token string) (any, error)
TodoGetFull(ctx context.Context, token string) (any, error)
TodoGetList(ctx context.Context, token string) (any, error)
TodoGetDetail(ctx context.Context, token, uuid string) (any, error)
TodoAdd(ctx context.Context, token string) (any, error)
TodoUpdate(ctx context.Context, token, uuid, data, lastChange string) (any, error)
TodoDelete(ctx context.Context, token, uuid, lastChange string) (any, error)
AdminGet(ctx context.Context, token string) (any, error)
AdminAdd(ctx context.Context, token, username string) (any, error)
AdminUpdate(ctx context.Context, token, username string, opts AdminUpdateOptions) (any, error)
AdminDelete(ctx context.Context, token, username string) (any, error)
ProfileIsAdmin(ctx context.Context, token string) (any, error)
ProfileChangePassword(ctx context.Context, token, password string) (any, error)
ProfileGetToken(ctx context.Context, token string) (any, error)
ProfileDeleteToken(ctx context.Context, token, deleteToken string) (any, error)
}
+141
View File
@@ -0,0 +1,141 @@
package database
import (
"context"
"errors"
)
var errNotImplemented = errors.New("mysql database: not implemented")
// stubDatabase provides a not-implemented default for every Database method.
// MysqlDatabase embeds it so the interface is satisfied without hand-writing
// each method; individual methods can be overridden on MysqlDatabase later.
type stubDatabase struct{}
func (stubDatabase) Init(username, password string) error { return errNotImplemented }
func (stubDatabase) Close() error { return errNotImplemented }
func (stubDatabase) CommonSalt(ctx context.Context, username string) (any, error) {
return nil, errNotImplemented
}
func (stubDatabase) CommonLogin(ctx context.Context, username, password, clientUa, clientIp string) (any, error) {
return nil, errNotImplemented
}
func (stubDatabase) CommonWebLogin(ctx context.Context, username, password, clientUa, clientIp string) (any, error) {
return nil, errNotImplemented
}
func (stubDatabase) CommonLogout(ctx context.Context, token string) (any, error) {
return nil, errNotImplemented
}
func (stubDatabase) CommonTokenValid(ctx context.Context, token string) (any, error) {
return nil, errNotImplemented
}
func (stubDatabase) CalendarGetFull(ctx context.Context, token string, startDateTime, endDateTime int64) (any, error) {
return nil, errNotImplemented
}
func (stubDatabase) CalendarGetList(ctx context.Context, token string, startDateTime, endDateTime int64) (any, error) {
return nil, errNotImplemented
}
func (stubDatabase) CalendarGetDetail(ctx context.Context, token, uuid string) (any, error) {
return nil, errNotImplemented
}
func (stubDatabase) CalendarUpdate(ctx context.Context, token, uuid, lastChange string, opts CalendarUpdateOptions) (any, error) {
return nil, errNotImplemented
}
func (stubDatabase) CalendarAdd(ctx context.Context, token, belongTo, title, description string, eventDateTimeStart, eventDateTimeEnd int64, loopRules string, timezoneOffset int64) (any, error) {
return nil, errNotImplemented
}
func (stubDatabase) CalendarDelete(ctx context.Context, token, uuid, lastChange string) (any, error) {
return nil, errNotImplemented
}
func (stubDatabase) CollectionGetFullOwn(ctx context.Context, token string) (any, error) {
return nil, errNotImplemented
}
func (stubDatabase) CollectionGetListOwn(ctx context.Context, token string) (any, error) {
return nil, errNotImplemented
}
func (stubDatabase) CollectionGetDetailOwn(ctx context.Context, token, uuid string) (any, error) {
return nil, errNotImplemented
}
func (stubDatabase) CollectionAddOwn(ctx context.Context, token, name string) (any, error) {
return nil, errNotImplemented
}
func (stubDatabase) CollectionUpdateOwn(ctx context.Context, token, uuid, name, lastChange string) (any, error) {
return nil, errNotImplemented
}
func (stubDatabase) CollectionDeleteOwn(ctx context.Context, token, uuid, lastChange string) (any, error) {
return nil, errNotImplemented
}
func (stubDatabase) CollectionGetSharing(ctx context.Context, token, uuid string) (any, error) {
return nil, errNotImplemented
}
func (stubDatabase) CollectionDeleteSharing(ctx context.Context, token, uuid, target, lastChange string) (any, error) {
return nil, errNotImplemented
}
func (stubDatabase) CollectionAddSharing(ctx context.Context, token, uuid, target, lastChange string) (any, error) {
return nil, errNotImplemented
}
func (stubDatabase) CollectionGetShared(ctx context.Context, token string) (any, error) {
return nil, errNotImplemented
}
func (stubDatabase) TodoGetFull(ctx context.Context, token string) (any, error) {
return nil, errNotImplemented
}
func (stubDatabase) TodoGetList(ctx context.Context, token string) (any, error) {
return nil, errNotImplemented
}
func (stubDatabase) TodoGetDetail(ctx context.Context, token, uuid string) (any, error) {
return nil, errNotImplemented
}
func (stubDatabase) TodoAdd(ctx context.Context, token string) (any, error) {
return nil, errNotImplemented
}
func (stubDatabase) TodoUpdate(ctx context.Context, token, uuid, data, lastChange string) (any, error) {
return nil, errNotImplemented
}
func (stubDatabase) TodoDelete(ctx context.Context, token, uuid, lastChange string) (any, error) {
return nil, errNotImplemented
}
func (stubDatabase) AdminGet(ctx context.Context, token string) (any, error) {
return nil, errNotImplemented
}
func (stubDatabase) AdminAdd(ctx context.Context, token, username string) (any, error) {
return nil, errNotImplemented
}
func (stubDatabase) AdminUpdate(ctx context.Context, token, username string, opts AdminUpdateOptions) (any, error) {
return nil, errNotImplemented
}
func (stubDatabase) AdminDelete(ctx context.Context, token, username string) (any, error) {
return nil, errNotImplemented
}
func (stubDatabase) ProfileIsAdmin(ctx context.Context, token string) (any, error) {
return nil, errNotImplemented
}
func (stubDatabase) ProfileChangePassword(ctx context.Context, token, password string) (any, error) {
return nil, errNotImplemented
}
func (stubDatabase) ProfileGetToken(ctx context.Context, token string) (any, error) {
return nil, errNotImplemented
}
func (stubDatabase) ProfileDeleteToken(ctx context.Context, token, deleteToken string) (any, error) {
return nil, errNotImplemented
}
// MysqlDatabase implements Database as a fully not-implemented stub by
// embedding stubDatabase. Real MySQL support can be filled in later by
// overriding individual methods.
type MysqlDatabase struct {
stubDatabase
deps Deps
}
// NewMysqlDatabase returns a not-implemented MysqlDatabase. The deps are stored
// for symmetry with NewSqlite3Database but not yet used.
func NewMysqlDatabase(deps Deps) (*MysqlDatabase, error) {
return &MysqlDatabase{deps: deps}, nil
}
File diff suppressed because it is too large Load Diff
+444
View File
@@ -0,0 +1,444 @@
package dt
import (
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"time"
"github.com/yyc12345/coconut-leaf/backend/utils"
)
// All time values in this file use minute-granularity timestamps (UNIX seconds
// / 60), mirroring the legacy dt.py. tzoffset is also in minutes.
var monthDayCount = [12]int64{31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}
var (
minDatetime = time.Date(1950, time.January, 1, 0, 0, 0, 0, time.UTC)
maxDatetime = time.Date(2200, time.January, 1, 0, 0, 0, 0, time.UTC)
minTimestamp = minDatetime.Unix() / 60
maxTimestamp = maxDatetime.Unix() / 60
maxDatetimeYear = int64(maxDatetime.Year())
)
const (
day1Span = int64(60 * 24)
day7Span = int64(7) * day1Span
)
var (
loopYearRule = regexp.MustCompile(`^Y([SR]{1})([1-9][0-9]*)$`)
loopMonthRule = regexp.MustCompile(`^M([SR]{1})([ABCD])([1-9][0-9]*)$`)
loopWeekRule = regexp.MustCompile(`^W([TF]{7})([1-9][0-9]*)$`)
loopDayRule = regexp.MustCompile(`^D([1-9][0-9]*)$`)
loopStopInfinity = regexp.MustCompile(`^F$`)
loopStopDatetime = regexp.MustCompile(`^D([1-9][0-9]*|0)$`)
loopStopTimes = regexp.MustCompile(`^T([1-9][0-9]*)$`)
)
type loopHandler func(sub []string, starttime, loopTimes, tzoffset int64) (int64, error)
var loopRules = []struct {
re *regexp.Regexp
handler loopHandler
}{
{loopYearRule, loopHandleYear},
{loopMonthRule, loopHandleMonth},
{loopWeekRule, loopHandleWeek},
{loopDayRule, loopHandleDay},
}
// ResolveLoopStr parses a loop-rule string of the form "[rules]-[stop]" and
// returns the loop end timestamp (minute granularity), given the start time and
// timezone offset (both in minutes). Mirrors the legacy dt.ResolveLoopStr.
func ResolveLoopStr(strl string, starttime, tzoffset int64) (int64, error) {
// check no loop
if strl == "" {
return starttime, nil
}
parts := strings.Split(strl, "-")
if len(parts) != 2 {
return 0, errors.New("invalid loop rule: expected exactly one '-' separator")
}
rulesStr, stopStr := parts[0], parts[1]
// try compute from loopStop
if loopStopInfinity.MatchString(stopStr) {
return maxTimestamp, nil
}
if m := loopStopDatetime.FindStringSubmatch(stopStr); m != nil {
ts, err := strconv.ParseInt(m[1], 10, 64)
if err != nil {
return 0, fmt.Errorf("invalid loop datetime stop: %w", err)
}
return ts, nil
}
var loopTimes int64
if m := loopStopTimes.FindStringSubmatch(stopStr); m != nil {
t, err := strconv.ParseInt(m[1], 10, 64)
if err != nil {
return 0, fmt.Errorf("invalid loop times stop: %w", err)
}
loopTimes = t
} else {
return 0, errors.New("invalid loop stop rules")
}
for _, rule := range loopRules {
if m := rule.re.FindStringSubmatch(rulesStr); m != nil {
return rule.handler(m, starttime, loopTimes, tzoffset)
}
}
return 0, errors.New("invalid loop rules")
}
// clientDateComponents extracts the (year, month, day) wall-clock components of
// starttime (minute timestamp) under the fixed offset tzoffset (minutes). This
// mirrors python's datetime.fromtimestamp(starttime*60, UTCTimezone(tzoffset)).
// Note time.FixedZone takes seconds, hence tzoffset*60.
func clientDateComponents(starttime int64, tzoffset int64) (int64, int64, int64) {
loc := time.FixedZone("offset", int(tzoffset*60))
t := time.Unix(starttime*60, 0).In(loc)
return int64(t.Year()), int64(t.Month()), int64(t.Day())
}
func loopHandleYear(sub []string, starttime, times, tzoffset int64) (int64, error) {
clientYear, clientMonth, clientDay := clientDateComponents(starttime, tzoffset)
isStrict := sub[1] == "S"
yearSpan, err := strconv.ParseInt(sub[2], 10, 64)
if err != nil {
return 0, fmt.Errorf("invalid year span: %w", err)
}
times--
newYear, newMonth, newDay := clientYear, clientMonth, clientDay
if clientMonth == 2 && clientDay == 29 {
if isStrict {
realSpan := utils.LCM(yearSpan, 4)
valCache := starttime
for valCache < maxTimestamp && times > 0 {
newYear += realSpan
if !isLeapYear(newYear) {
continue
}
valCache = starttime + day1Span*(daysCount(newYear, newMonth, newDay)-daysCount(clientYear, clientMonth, clientDay))
times--
}
} else {
newYear += times * yearSpan
if !isLeapYear(newYear) {
newDay = 28
}
}
} else {
// if times == 1, no extra datetime need to be added
newYear += times * yearSpan
}
val := starttime + day1Span*(daysCount(newYear, newMonth, newDay)-daysCount(clientYear, clientMonth, clientDay))
if val < maxTimestamp {
return val, nil
} else {
return maxTimestamp, nil
}
}
func loopHandleMonth(sub []string, starttime, times, tzoffset int64) (int64, error) {
isStrict := sub[1] == "S"
loopType := sub[2]
monthSpan, err := strconv.ParseInt(sub[3], 10, 64)
if err != nil {
return 0, fmt.Errorf("invalid month span: %w", err)
}
// we should get original data in each method
times--
clientYear, clientMonth, clientDay := clientDateComponents(starttime, tzoffset)
newYear, newMonth, newDay := clientYear, clientMonth, clientDay
// data struct
// ds =
// (dayForwards || dayBackwards || weeksForward, dayOfWeek || weeksBackwards, dayOfWeek)
// ( A || B || C || D )
ds := getDayInMonth(clientYear, clientMonth, clientDay)
advanceMonth := func() {
newMonth += monthSpan
if newMonth > 12 {
newYear += (newMonth - 1) / 12
newMonth = ((newMonth - 1) % 12) + 1
}
}
if isStrict {
switch loopType {
case "A":
for times > 0 {
advanceMonth()
if newYear > maxDatetimeYear {
break
}
maxDays := monthDayCount[newMonth-1]
if newMonth == 2 && isLeapYear(newYear) {
maxDays++
}
if ds.daysForward <= maxDays {
times--
}
}
case "B":
for times > 0 {
advanceMonth()
if newYear > maxDatetimeYear {
break
}
maxDays := monthDayCount[newMonth-1]
if newMonth == 2 && isLeapYear(newYear) {
maxDays++
}
if ds.daysBackward <= maxDays {
times--
}
}
case "C":
for times > 0 {
advanceMonth()
if newYear > maxDatetimeYear {
break
}
ms := getMonthWeekStatistics(newYear, newMonth)
if ds.weeksForward <= ms[ds.weeksForwardDayOfWeek] {
times--
}
}
case "D":
for times > 0 {
advanceMonth()
if newYear > maxDatetimeYear {
break
}
ms := getMonthWeekStatistics(newYear, newMonth)
if ds.weeksBackward <= ms[ds.weeksBackwardDayOfWeek] {
times--
}
}
}
} else {
newMonth += times * monthSpan
newYear += (newMonth - 1) / 12
newMonth = ((newMonth - 1) % 12) + 1
}
// all method need calc newDay and it should be the last day of current selected month
// so calc it in there
newDay = monthDayCount[newMonth-1]
if newMonth == 2 && isLeapYear(newYear) {
newDay++
}
val := starttime + day1Span*(daysCount(newYear, newMonth, newDay)-daysCount(clientYear, clientMonth, clientDay))
if val < maxTimestamp {
return val, nil
} else {
return maxTimestamp, nil
}
}
func loopHandleWeek(sub []string, starttime, times, tzoffset int64) (int64, error) {
weekStr := sub[1]
var weekOccupied [7]bool
var weekEventCount int64
for i := range 7 {
weekOccupied[i] = weekStr[i] == 'T'
if weekOccupied[i] {
weekEventCount++
}
}
if weekEventCount == 0 {
return 0, errors.New("invalid week format")
}
weekSpan, err := strconv.ParseInt(sub[2], 10, 64)
if err != nil {
return 0, fmt.Errorf("invalid week span: %w", err)
}
// Use the ported dayOfWeek (Monday=0) instead of Go's time.Weekday (Sunday=0)
// so the week rule's Monday-first indexing stays consistent.
cy, cm, cd := clientDateComponents(starttime, tzoffset)
nowDayOfWeek := dayOfWeek(cy, cm, cd)
if !weekOccupied[nowDayOfWeek] {
// if first event is not suit for week loop rules, minus one more event to suit it.
times--
}
fullWeek := times / weekEventCount
remainEvent := times % weekEventCount
val := starttime + day7Span*fullWeek*weekSpan
if val > maxTimestamp {
// return now, to reduce calc usage
return maxTimestamp, nil
}
for remainEvent != 0 {
val += day1Span
if weekOccupied[nowDayOfWeek%7] {
remainEvent--
}
nowDayOfWeek++
}
val--
if val < maxTimestamp {
return val, nil
} else {
return maxTimestamp, nil
}
}
func loopHandleDay(sub []string, starttime, times, tzoffset int64) (int64, error) {
span, err := strconv.ParseInt(sub[1], 10, 64)
if err != nil {
return 0, fmt.Errorf("invalid day span: %w", err)
}
val := starttime + day1Span*times*span - 1
if val < maxTimestamp {
return val, nil
}
return maxTimestamp, nil
}
// leapYearCountEx counts leap years following the proleptic Gregorian
// 4/100/400 rule. NOTE: the legacy python had a typo in the baseYear branch
// (subtracted /400 instead of /100, which cancelled the +/400); it is corrected
// here. The fix has no observable effect on current call sites, since baseYear
// is always 1 and floor(1/n)=0 for n in {4,100,400}.
func leapYearCountEx(endYear int64, includeThis bool, baseYear int64, includeBase bool) int64 {
if !includeThis {
endYear--
}
if includeBase {
baseYear--
}
endly := endYear / 4
endly -= endYear / 100
endly += endYear / 400
basely := baseYear / 4
basely -= baseYear / 100
basely += baseYear / 400
return endly - basely
}
func leapYearCount(year int64) int64 {
return leapYearCountEx(year, false, 1, true)
}
func isLeapYear(year int64) bool {
isLeap := false
if year%4 == 0 {
isLeap = true
}
if year%100 == 0 {
isLeap = false
}
if year%400 == 0 {
isLeap = true
}
return isLeap
}
func daysCount(year, month, day int64) int64 {
ly := leapYearCountEx(year, false, 1, true)
days := int64(365) * (year - 1)
days += ly
for index := int64(1); index < month; index++ {
days += monthDayCount[index-1]
}
if month > 2 && isLeapYear(year) {
days++
}
days += day - 1
return days
}
// dayOfWeek returns the day of week with Monday=0 .. Sunday=6 (independent of
// Go's time.Weekday convention), derived from the portable daysCount.
func dayOfWeek(year, month, day int64) int64 {
// As we know, Jan 1, 1900 is Monday.
// According to this, we can speculate Jan 1, 0001 also is Monday.
return daysCount(year, month, day) % 7
}
// dayInMonthInfo holds positional statistics for a day within its month. The
// day-of-week values use Monday=0 indexing.
type dayInMonthInfo struct {
// daysForward is the day count to this day, counting from month head to tail.
daysForward int64
// daysBackward is the day count to this day, counting from month tail to head.
daysBackward int64
// weeksForward is the count of the week this day is located in, counting from
// month head to tail.
weeksForward int64
// weeksForwardDayOfWeek is the day-of-week index paired with weeksForward.
weeksForwardDayOfWeek int64
// weeksBackward is the count of the week this day is located in, counting from
// month tail to head.
weeksBackward int64
// weeksBackwardDayOfWeek is the day-of-week index paired with weeksBackward.
weeksBackwardDayOfWeek int64
}
// getDayInMonth returns the positional statistics of the given date within its
// month.
func getDayInMonth(year, month, day int64) dayInMonthInfo {
days := monthDayCount[month-1]
if month == 2 && isLeapYear(year) {
days++
}
firstDayOfWeek := dayOfWeek(year, month, 1)
dow := (firstDayOfWeek + day - 1) % 7
daysForward := day
daysBackward := days - day + 1
weeksForward := (daysForward-1)/7 + 1
weeksBackward := (daysBackward-1)/7 + 1
return dayInMonthInfo{
daysForward: daysForward,
daysBackward: daysBackward,
weeksForward: weeksForward,
weeksForwardDayOfWeek: dow,
weeksBackward: weeksBackward,
weeksBackwardDayOfWeek: dow,
}
}
// getMonthWeekStatistics returns, for each weekday Monday=0..Sunday=6, how many
// times that weekday occurs in the given month.
func getMonthWeekStatistics(year, month int64) [7]int64 {
days := monthDayCount[month-1]
if month == 2 && isLeapYear(year) {
days++
}
firstDayOfWeek := dayOfWeek(year, month, 1)
// lastDayOfWeek := (firstDayOfWeek + days - 1) % 7
result := [7]int64{4, 4, 4, 4, 4, 4, 4}
remain := days % 7
week := firstDayOfWeek
for remain > 0 {
result[week%7]++
week++
remain--
}
return result
}
+21 -19
View File
@@ -8,33 +8,35 @@ require (
)
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/bytedance/gopkg v0.1.4 // indirect
github.com/bytedance/sonic v1.15.2 // indirect
github.com/bytedance/sonic/loader v0.5.1 // indirect
github.com/cloudwego/base64x v0.1.7 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gin-contrib/sse v1.1.1 // 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/go-playground/validator/v10 v10.30.3 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-sqlite3 v1.14.48 // 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/pelletier/go-toml/v2 v2.4.3 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/quic-go/quic-go v0.60.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
go.mongodb.org/mongo-driver/v2 v2.8.0 // indirect
golang.org/x/arch v0.29.0 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
+42
View File
@@ -2,19 +2,31 @@ 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/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
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 v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo=
github.com/bytedance/sonic v1.15.2/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA=
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/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
github.com/bytedance/sonic/loader v0.5.1/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/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI=
github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg=
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/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/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-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
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=
@@ -25,21 +37,33 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
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/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8=
github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc=
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-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/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/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
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/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
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/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-sqlite3 v1.14.48 h1:7XHIgl0a8HwOaiK4E47ozLkST78rR9+OtNGx27D/TFs=
github.com/mattn/go-sqlite3 v1.14.48/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
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=
@@ -47,12 +71,16 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
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/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
github.com/pelletier/go-toml/v2 v2.4.3/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/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0=
github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
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=
@@ -70,21 +98,35 @@ 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.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8=
go.mongodb.org/mongo-driver/v2 v2.8.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/arch v0.29.0 h1:8sSET5wB0+exBm0FGmOtdHMqjlRdV2DRD3/IV6OZgho=
golang.org/x/arch v0.29.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
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/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
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/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/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=
+79
View File
@@ -0,0 +1,79 @@
package logger
import (
"context"
"io"
"log/slog"
"os"
)
// LoggerLevel expresses the desired logging verbosity. Unlike a full log-level
// enum, it only distinguishes between a verbose development mode and a quieter
// production mode; it is converted to an slog.Level by New.
type LoggerLevel int
const (
// Development enables debug-level output, suitable for local development.
Development LoggerLevel = iota
// Production limits output to info level and above.
Production
)
// toSlogLevel converts a LoggerLevel into the corresponding slog.Level,
// defaulting to info for any unrecognized value.
func toSlogLevel(level LoggerLevel) slog.Level {
switch level {
case Development:
return slog.LevelDebug
case Production:
return slog.LevelInfo
default:
return slog.LevelInfo
}
}
// New creates a *slog.Logger that writes to os.Stderr in the bracketed format
// "[<time>] [<LEVEL>] <message> [key=value ...]". Its verbosity is driven by the
// given LoggerLevel. This replaces the legacy module-level singleton LOGGER;
// callers own the returned logger and thread it through to sub-modules.
func New(level LoggerLevel) *slog.Logger {
return slog.New(&bracketHandler{w: os.Stderr, level: toSlogLevel(level)})
}
// bracketHandler is a custom slog.Handler emitting the bracketed format
// "[<time>] [<LEVEL>] <message>" followed by any attributes as "key=value",
// matching the legacy "[LEVEL] message" style with an added timestamp.
type bracketHandler struct {
w io.Writer
level slog.Level
}
func (h *bracketHandler) Enabled(_ context.Context, l slog.Level) bool {
return l >= h.level
}
func (h *bracketHandler) Handle(_ context.Context, r slog.Record) error {
var b []byte
b = append(b, '[')
b = r.Time.AppendFormat(b, "2006-01-02T15:04:05.000-07:00")
b = append(b, "] ["...)
b = append(b, r.Level.String()...)
b = append(b, "] "...)
b = append(b, r.Message...)
r.Attrs(func(a slog.Attr) bool {
b = append(b, ' ')
b = append(b, a.Key...)
b = append(b, '=')
b = append(b, a.Value.String()...)
return true
})
b = append(b, '\n')
_, err := h.w.Write(b)
return err
}
// WithAttrs and WithGroup are no-ops: this project does not derive sub-loggers
// via Logger.With, so they simply return the receiver.
func (h *bracketHandler) WithAttrs(_ []slog.Attr) slog.Handler { return h }
func (h *bracketHandler) WithGroup(_ string) slog.Handler { return h }
+64 -17
View File
@@ -2,31 +2,78 @@
package main
import (
"log"
"net/http"
"fmt"
"os"
"github.com/gin-gonic/gin"
"github.com/yyc12345/coconut-leaf/backend/cli"
"github.com/yyc12345/coconut-leaf/backend/config"
"github.com/yyc12345/coconut-leaf/backend/database"
"github.com/yyc12345/coconut-leaf/backend/logger"
"github.com/yyc12345/coconut-leaf/backend/server"
"github.com/yyc12345/coconut-leaf/backend/utils"
)
func main() {
args := ParseCLI()
args := cli.Parse()
cfg, err := LoadConfig(args.Config)
cfg, err := config.Load(args.Config)
if err != nil {
log.Fatal(err)
fmt.Fprintln(os.Stderr, "error loading config file:", err)
os.Exit(1)
}
_ = cfg
// 创建默认的 Gin 引擎(包含 Logger 和 Recovery 中间件)
r := gin.Default()
// Splash is printed directly; the logger is not built yet.
fmt.Println("Coconut-leaf")
fmt.Println("A light, self-host and multi-account calendar system")
fmt.Println("Project: https://github.com/yyc12345/coconut-leaf")
fmt.Println("===================")
// 定义路由
r.GET("/", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"message": "Hello, Gin!",
})
})
// Build the logger from the loaded config.
loggerLevel := logger.Production
if cfg.Others.Debug {
loggerLevel = logger.Development
}
log := logger.New(loggerLevel)
//启动服务器(默认端口8088
r.Run(":8080")
// Create the database backend selected by the config.
deps := database.Deps{Cfg: cfg, Logger: log}
var db database.Database
switch cfg.Database.Driver {
case config.DatabaseDriverSqlite:
db, err = database.NewSqlite3Database(deps)
case config.DatabaseDriverMysql:
db, err = database.NewMysqlDatabase(deps)
default:
log.Error("unknown database driver", "driver", cfg.Database.Driver)
os.Exit(1)
}
if err != nil {
log.Error("failed to open database", "error", err)
os.Exit(1)
}
// Initialize the schema and first admin user when requested.
if args.Init {
if !utils.IsValidUsername(args.Username) {
log.Error("invalid init username")
os.Exit(1)
}
if !utils.IsValidPassword(args.Password) {
log.Error("invalid init password")
os.Exit(1)
}
if err := db.Init(args.Username, args.Password); err != nil {
log.Error("failed to initialize database", "error", err)
os.Exit(1)
}
}
// Serve.
defer db.Close()
log.Info("Starting server...")
handler := &server.Handler{DB: db, Logger: log, Cfg: cfg}
if err := server.Run(handler); err != nil {
log.Error("server stopped with error", "error", err)
os.Exit(1)
}
}
+692
View File
@@ -0,0 +1,692 @@
package server
import (
"fmt"
"log/slog"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/yyc12345/coconut-leaf/backend/config"
"github.com/yyc12345/coconut-leaf/backend/database"
"github.com/yyc12345/coconut-leaf/backend/utils"
)
// ResponseBody is the JSON envelope returned by every API endpoint, mirroring
// the legacy ConstructResponseBody: {success, error, data}.
type ResponseBody struct {
Success bool `json:"success"`
Error string `json:"error"`
Data any `json:"data"`
}
// Handler holds the shared dependencies injected into every Gin handler: the
// database, the application logger and the loaded config.
type Handler struct {
DB database.Database
Logger *slog.Logger
Cfg *config.Config
}
// region: Utilities
// respond wraps a (data, error) pair into a ResponseBody. The HTTP status is
// always 200; failures are encoded in the body, mirroring the legacy API.
func respond(c *gin.Context, data any, err error) {
if err != nil {
c.JSON(http.StatusOK, ResponseBody{Success: false, Error: err.Error()})
return
}
c.JSON(http.StatusOK, ResponseBody{Success: true, Data: data})
}
// respondInvalidParam replies with the legacy "Invalid parameter" body.
func respondInvalidParam(c *gin.Context) {
c.JSON(http.StatusOK, ResponseBody{Success: false, Error: "Invalid parameter"})
}
// fetchForm flattens the POST form into a map and logs it at debug level,
// mirroring the legacy SmartDbCaller "User Form" log.
func fetchForm(c *gin.Context, logger *slog.Logger) map[string]string {
_ = c.Request.ParseForm()
form := make(map[string]string, len(c.Request.PostForm))
for k, v := range c.Request.PostForm {
if len(v) > 0 {
form[k] = v[0]
}
}
logger.Debug("User Form", "form", form)
return form
}
// fetchClientInfo returns the client user agent and IP, mirroring the legacy
// FetchClientNetworkInfo. The real IP (X-Forwarded-For behind Nginx) comes from
// gin's c.ClientIP().
func fetchClientInfo(c *gin.Context) (ua, ip string) {
ua = c.Request.UserAgent()
ip = c.ClientIP()
if ip == "" {
ip = "0.0.0.0"
}
return ua, ip
}
// endregion
// Run sets up the Gin engine with all routes bound to the handler and starts
// listening on the configured web port.
func Run(handler *Handler) error {
if handler.Cfg.Others.Debug {
gin.SetMode(gin.DebugMode)
} else {
gin.SetMode(gin.ReleaseMode)
}
r := gin.Default()
err := r.SetTrustedProxies([]string{
"127.0.0.1", // IPv4 本地回环
"::1", // IPv6 本地回环
})
if err != nil {
return err
}
registerRoutes(r, handler)
return r.Run(fmt.Sprintf(":%d", handler.Cfg.Web.Port))
}
func registerRoutes(r *gin.Engine, h *Handler) {
// common
r.POST("/common/salt", h.CommonSalt)
r.POST("/common/login", h.CommonLogin)
r.POST("/common/webLogin", h.CommonWebLogin)
r.POST("/common/logout", h.CommonLogout)
r.POST("/common/tokenValid", h.CommonTokenValid)
// calendar
r.POST("/calendar/getFull", h.CalendarGetFull)
r.POST("/calendar/getList", h.CalendarGetList)
r.POST("/calendar/getDetail", h.CalendarGetDetail)
r.POST("/calendar/update", h.CalendarUpdate)
r.POST("/calendar/add", h.CalendarAdd)
r.POST("/calendar/delete", h.CalendarDelete)
// collection
r.POST("/collection/getFullOwn", h.CollectionGetFullOwn)
r.POST("/collection/getListOwn", h.CollectionGetListOwn)
r.POST("/collection/getDetailOwn", h.CollectionGetDetailOwn)
r.POST("/collection/addOwn", h.CollectionAddOwn)
r.POST("/collection/updateOwn", h.CollectionUpdateOwn)
r.POST("/collection/deleteOwn", h.CollectionDeleteOwn)
r.POST("/collection/getSharing", h.CollectionGetSharing)
r.POST("/collection/deleteSharing", h.CollectionDeleteSharing)
r.POST("/collection/addSharing", h.CollectionAddSharing)
r.POST("/collection/getShared", h.CollectionGetShared)
// todo
r.POST("/todo/getFull", h.TodoGetFull)
r.POST("/todo/getList", h.TodoGetList)
r.POST("/todo/getDetail", h.TodoGetDetail)
r.POST("/todo/add", h.TodoAdd)
r.POST("/todo/update", h.TodoUpdate)
r.POST("/todo/delete", h.TodoDelete)
// admin
r.POST("/admin/get", h.AdminGet)
r.POST("/admin/add", h.AdminAdd)
r.POST("/admin/update", h.AdminUpdate)
r.POST("/admin/delete", h.AdminDelete)
// profile
r.POST("/profile/isAdmin", h.ProfileIsAdmin)
r.POST("/profile/changePassword", h.ProfileChangePassword)
r.POST("/profile/getToken", h.ProfileGetToken)
r.POST("/profile/deleteToken", h.ProfileDeleteToken)
}
// region: API Route
// region: Common
func (h *Handler) CommonSalt(c *gin.Context) {
form := fetchForm(c, h.Logger)
username, ok := form["username"]
if !ok {
respondInvalidParam(c)
return
}
data, err := h.DB.CommonSalt(c.Request.Context(), username)
respond(c, data, err)
}
func (h *Handler) CommonLogin(c *gin.Context) {
form := fetchForm(c, h.Logger)
username, okU := form["username"]
password, okP := form["password"]
if !okU || !okP {
respondInvalidParam(c)
return
}
clientUa, clientIp := fetchClientInfo(c)
data, err := h.DB.CommonLogin(c.Request.Context(), username, password, clientUa, clientIp)
respond(c, data, err)
}
func (h *Handler) CommonWebLogin(c *gin.Context) {
form := fetchForm(c, h.Logger)
username, okU := form["username"]
password, okP := form["password"]
if !okU || !okP {
respondInvalidParam(c)
return
}
clientUa, clientIp := fetchClientInfo(c)
data, err := h.DB.CommonWebLogin(c.Request.Context(), username, password, clientUa, clientIp)
respond(c, data, err)
}
func (h *Handler) CommonLogout(c *gin.Context) {
form := fetchForm(c, h.Logger)
token, ok := form["token"]
if !ok {
respondInvalidParam(c)
return
}
data, err := h.DB.CommonLogout(c.Request.Context(), token)
respond(c, data, err)
}
func (h *Handler) CommonTokenValid(c *gin.Context) {
form := fetchForm(c, h.Logger)
token, ok := form["token"]
if !ok {
respondInvalidParam(c)
return
}
data, err := h.DB.CommonTokenValid(c.Request.Context(), token)
respond(c, data, err)
}
// endregion
// region: Calendar
func (h *Handler) CalendarGetFull(c *gin.Context) {
form := fetchForm(c, h.Logger)
token, okT := form["token"]
sds, okS := form["startDateTime"]
eds, okE := form["endDateTime"]
if !okT || !okS || !okE {
respondInvalidParam(c)
return
}
startDateTime, err1 := strconv.ParseInt(sds, 10, 64)
endDateTime, err2 := strconv.ParseInt(eds, 10, 64)
if err1 != nil || err2 != nil {
respondInvalidParam(c)
return
}
data, err := h.DB.CalendarGetFull(c.Request.Context(), token, startDateTime, endDateTime)
respond(c, data, err)
}
func (h *Handler) CalendarGetList(c *gin.Context) {
form := fetchForm(c, h.Logger)
token, okT := form["token"]
sds, okS := form["startDateTime"]
eds, okE := form["endDateTime"]
if !okT || !okS || !okE {
respondInvalidParam(c)
return
}
startDateTime, err1 := strconv.ParseInt(sds, 10, 64)
endDateTime, err2 := strconv.ParseInt(eds, 10, 64)
if err1 != nil || err2 != nil {
respondInvalidParam(c)
return
}
data, err := h.DB.CalendarGetList(c.Request.Context(), token, startDateTime, endDateTime)
respond(c, data, err)
}
func (h *Handler) CalendarGetDetail(c *gin.Context) {
form := fetchForm(c, h.Logger)
token, okT := form["token"]
uuid, okU := form["uuid"]
if !okT || !okU {
respondInvalidParam(c)
return
}
data, err := h.DB.CalendarGetDetail(c.Request.Context(), token, uuid)
respond(c, data, err)
}
func (h *Handler) CalendarUpdate(c *gin.Context) {
form := fetchForm(c, h.Logger)
token, okT := form["token"]
uuid, okU := form["uuid"]
lastChange, okL := form["lastChange"]
if !okT || !okU || !okL {
respondInvalidParam(c)
return
}
var opts database.CalendarUpdateOptions
provided := 0
if v, ok := form["belongTo"]; ok {
opts.BelongTo = &v
provided++
}
if v, ok := form["title"]; ok {
opts.Title = &v
provided++
}
if v, ok := form["description"]; ok {
opts.Description = &v
provided++
}
if v, ok := form["eventDateTimeStart"]; ok {
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
respondInvalidParam(c)
return
}
opts.EventDateTimeStart = &n
provided++
}
if v, ok := form["eventDateTimeEnd"]; ok {
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
respondInvalidParam(c)
return
}
opts.EventDateTimeEnd = &n
provided++
}
if v, ok := form["loopRules"]; ok {
opts.LoopRules = &v
provided++
}
if v, ok := form["timezoneOffset"]; ok {
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
respondInvalidParam(c)
return
}
opts.TimezoneOffset = &n
provided++
}
if provided == 0 {
respondInvalidParam(c)
return
}
data, err := h.DB.CalendarUpdate(c.Request.Context(), token, uuid, lastChange, opts)
respond(c, data, err)
}
func (h *Handler) CalendarAdd(c *gin.Context) {
form := fetchForm(c, h.Logger)
token, okT := form["token"]
belongTo, okB := form["belongTo"]
title, okTi := form["title"]
description, okD := form["description"]
loopRules, okL := form["loopRules"]
edtsStr, okES := form["eventDateTimeStart"]
edteStr, okEE := form["eventDateTimeEnd"]
tzoStr, okTZ := form["timezoneOffset"]
if !okT || !okB || !okTi || !okD || !okL || !okES || !okEE || !okTZ {
respondInvalidParam(c)
return
}
edts, err1 := strconv.ParseInt(edtsStr, 10, 64)
edte, err2 := strconv.ParseInt(edteStr, 10, 64)
tzo, err3 := strconv.ParseInt(tzoStr, 10, 64)
if err1 != nil || err2 != nil || err3 != nil {
respondInvalidParam(c)
return
}
data, err := h.DB.CalendarAdd(c.Request.Context(), token, belongTo, title, description, edts, edte, loopRules, tzo)
respond(c, data, err)
}
func (h *Handler) CalendarDelete(c *gin.Context) {
form := fetchForm(c, h.Logger)
token, okT := form["token"]
uuid, okU := form["uuid"]
lastChange, okL := form["lastChange"]
if !okT || !okU || !okL {
respondInvalidParam(c)
return
}
data, err := h.DB.CalendarDelete(c.Request.Context(), token, uuid, lastChange)
respond(c, data, err)
}
// endregion
// region: Collection
func (h *Handler) CollectionGetFullOwn(c *gin.Context) {
form := fetchForm(c, h.Logger)
token, ok := form["token"]
if !ok {
respondInvalidParam(c)
return
}
data, err := h.DB.CollectionGetFullOwn(c.Request.Context(), token)
respond(c, data, err)
}
func (h *Handler) CollectionGetListOwn(c *gin.Context) {
form := fetchForm(c, h.Logger)
token, ok := form["token"]
if !ok {
respondInvalidParam(c)
return
}
data, err := h.DB.CollectionGetListOwn(c.Request.Context(), token)
respond(c, data, err)
}
func (h *Handler) CollectionGetDetailOwn(c *gin.Context) {
form := fetchForm(c, h.Logger)
token, okT := form["token"]
uuid, okU := form["uuid"]
if !okT || !okU {
respondInvalidParam(c)
return
}
data, err := h.DB.CollectionGetDetailOwn(c.Request.Context(), token, uuid)
respond(c, data, err)
}
func (h *Handler) CollectionAddOwn(c *gin.Context) {
form := fetchForm(c, h.Logger)
token, okT := form["token"]
name, okN := form["name"]
if !okT || !okN {
respondInvalidParam(c)
return
}
data, err := h.DB.CollectionAddOwn(c.Request.Context(), token, name)
respond(c, data, err)
}
func (h *Handler) CollectionUpdateOwn(c *gin.Context) {
form := fetchForm(c, h.Logger)
token, okT := form["token"]
uuid, okU := form["uuid"]
name, okN := form["name"]
lastChange, okL := form["lastChange"]
if !okT || !okU || !okN || !okL {
respondInvalidParam(c)
return
}
data, err := h.DB.CollectionUpdateOwn(c.Request.Context(), token, uuid, name, lastChange)
respond(c, data, err)
}
func (h *Handler) CollectionDeleteOwn(c *gin.Context) {
form := fetchForm(c, h.Logger)
token, okT := form["token"]
uuid, okU := form["uuid"]
lastChange, okL := form["lastChange"]
if !okT || !okU || !okL {
respondInvalidParam(c)
return
}
data, err := h.DB.CollectionDeleteOwn(c.Request.Context(), token, uuid, lastChange)
respond(c, data, err)
}
func (h *Handler) CollectionGetSharing(c *gin.Context) {
form := fetchForm(c, h.Logger)
token, okT := form["token"]
uuid, okU := form["uuid"]
if !okT || !okU {
respondInvalidParam(c)
return
}
data, err := h.DB.CollectionGetSharing(c.Request.Context(), token, uuid)
respond(c, data, err)
}
func (h *Handler) CollectionDeleteSharing(c *gin.Context) {
form := fetchForm(c, h.Logger)
token, okT := form["token"]
uuid, okU := form["uuid"]
target, okG := form["target"]
lastChange, okL := form["lastChange"]
if !okT || !okU || !okG || !okL {
respondInvalidParam(c)
return
}
data, err := h.DB.CollectionDeleteSharing(c.Request.Context(), token, uuid, target, lastChange)
respond(c, data, err)
}
func (h *Handler) CollectionAddSharing(c *gin.Context) {
form := fetchForm(c, h.Logger)
token, okT := form["token"]
uuid, okU := form["uuid"]
target, okG := form["target"]
lastChange, okL := form["lastChange"]
if !okT || !okU || !okG || !okL {
respondInvalidParam(c)
return
}
data, err := h.DB.CollectionAddSharing(c.Request.Context(), token, uuid, target, lastChange)
respond(c, data, err)
}
func (h *Handler) CollectionGetShared(c *gin.Context) {
form := fetchForm(c, h.Logger)
token, ok := form["token"]
if !ok {
respondInvalidParam(c)
return
}
data, err := h.DB.CollectionGetShared(c.Request.Context(), token)
respond(c, data, err)
}
// endregion
// region: Todo
func (h *Handler) TodoGetFull(c *gin.Context) {
form := fetchForm(c, h.Logger)
token, ok := form["token"]
if !ok {
respondInvalidParam(c)
return
}
data, err := h.DB.TodoGetFull(c.Request.Context(), token)
respond(c, data, err)
}
func (h *Handler) TodoGetList(c *gin.Context) {
form := fetchForm(c, h.Logger)
token, ok := form["token"]
if !ok {
respondInvalidParam(c)
return
}
data, err := h.DB.TodoGetList(c.Request.Context(), token)
respond(c, data, err)
}
func (h *Handler) TodoGetDetail(c *gin.Context) {
form := fetchForm(c, h.Logger)
token, okT := form["token"]
uuid, okU := form["uuid"]
if !okT || !okU {
respondInvalidParam(c)
return
}
data, err := h.DB.TodoGetDetail(c.Request.Context(), token, uuid)
respond(c, data, err)
}
func (h *Handler) TodoAdd(c *gin.Context) {
form := fetchForm(c, h.Logger)
token, ok := form["token"]
if !ok {
respondInvalidParam(c)
return
}
data, err := h.DB.TodoAdd(c.Request.Context(), token)
respond(c, data, err)
}
func (h *Handler) TodoUpdate(c *gin.Context) {
form := fetchForm(c, h.Logger)
token, okT := form["token"]
uuid, okU := form["uuid"]
dataField, okD := form["data"]
lastChange, okL := form["lastChange"]
if !okT || !okU || !okD || !okL {
respondInvalidParam(c)
return
}
data, err := h.DB.TodoUpdate(c.Request.Context(), token, uuid, dataField, lastChange)
respond(c, data, err)
}
func (h *Handler) TodoDelete(c *gin.Context) {
form := fetchForm(c, h.Logger)
token, okT := form["token"]
uuid, okU := form["uuid"]
lastChange, okL := form["lastChange"]
if !okT || !okU || !okL {
respondInvalidParam(c)
return
}
data, err := h.DB.TodoDelete(c.Request.Context(), token, uuid, lastChange)
respond(c, data, err)
}
// endregion
// region: Admin
func (h *Handler) AdminGet(c *gin.Context) {
form := fetchForm(c, h.Logger)
token, ok := form["token"]
if !ok {
respondInvalidParam(c)
return
}
data, err := h.DB.AdminGet(c.Request.Context(), token)
respond(c, data, err)
}
func (h *Handler) AdminAdd(c *gin.Context) {
form := fetchForm(c, h.Logger)
token, okT := form["token"]
username, okU := form["username"]
if !okT || !okU {
respondInvalidParam(c)
return
}
data, err := h.DB.AdminAdd(c.Request.Context(), token, username)
respond(c, data, err)
}
func (h *Handler) AdminUpdate(c *gin.Context) {
form := fetchForm(c, h.Logger)
token, okT := form["token"]
username, okU := form["username"]
if !okT || !okU {
respondInvalidParam(c)
return
}
var opts database.AdminUpdateOptions
provided := 0
if v, ok := form["password"]; ok {
opts.Password = &v
provided++
}
if v, ok := form["isAdmin"]; ok {
b := utils.Str2Bool(v)
opts.IsAdmin = &b
provided++
}
if provided == 0 {
respondInvalidParam(c)
return
}
data, err := h.DB.AdminUpdate(c.Request.Context(), token, username, opts)
respond(c, data, err)
}
func (h *Handler) AdminDelete(c *gin.Context) {
form := fetchForm(c, h.Logger)
token, okT := form["token"]
username, okU := form["username"]
if !okT || !okU {
respondInvalidParam(c)
return
}
data, err := h.DB.AdminDelete(c.Request.Context(), token, username)
respond(c, data, err)
}
// endregion
// region: Profile
func (h *Handler) ProfileIsAdmin(c *gin.Context) {
form := fetchForm(c, h.Logger)
token, ok := form["token"]
if !ok {
respondInvalidParam(c)
return
}
data, err := h.DB.ProfileIsAdmin(c.Request.Context(), token)
respond(c, data, err)
}
func (h *Handler) ProfileChangePassword(c *gin.Context) {
form := fetchForm(c, h.Logger)
token, okT := form["token"]
password, okP := form["password"]
if !okT || !okP {
respondInvalidParam(c)
return
}
data, err := h.DB.ProfileChangePassword(c.Request.Context(), token, password)
respond(c, data, err)
}
func (h *Handler) ProfileGetToken(c *gin.Context) {
form := fetchForm(c, h.Logger)
token, ok := form["token"]
if !ok {
respondInvalidParam(c)
return
}
data, err := h.DB.ProfileGetToken(c.Request.Context(), token)
respond(c, data, err)
}
func (h *Handler) ProfileDeleteToken(c *gin.Context) {
form := fetchForm(c, h.Logger)
token, okT := form["token"]
deleteToken, okD := form["deleteToken"]
if !okT || !okD {
respondInvalidParam(c)
return
}
data, err := h.DB.ProfileDeleteToken(c.Request.Context(), token, deleteToken)
respond(c, data, err)
}
// endregion
// endregion
+93
View File
@@ -0,0 +1,93 @@
package utils
import (
"crypto/sha256"
"encoding/hex"
"math/rand"
"regexp"
"strconv"
"strings"
"time"
"github.com/google/uuid"
)
var (
usernamePattern = regexp.MustCompile(`^[0-9A-Za-z]+$`)
passwordPattern = regexp.MustCompile(`^[!-~]+$`)
)
// IsValidUsername reports whether s is a valid username.
func IsValidUsername(s string) bool {
return usernamePattern.MatchString(s)
}
// IsValidPassword reports whether s is a valid password.
func IsValidPassword(s string) bool {
return passwordPattern.MatchString(s)
}
// ComputePasswordHash returns the lowercased SHA-256 hex digest of password.
func ComputePasswordHash(password string) string {
sum := sha256.Sum256([]byte(password))
return hex.EncodeToString(sum[:])
}
// GenerateUUID returns a new UUID string, preferring version 1 (mirroring
// python's uuid.uuid1()), falling back to a random version-4 UUID on error.
func GenerateUUID() string {
u, err := uuid.NewUUID()
if err != nil {
u = uuid.New()
}
return u.String()
}
// GenerateToken returns a SHA-256 hex digest of the username concatenated with
// a fresh UUID.
func GenerateToken(username string) string {
sum := sha256.Sum256([]byte(username + GenerateUUID()))
return hex.EncodeToString(sum[:])
}
// GenerateSalt returns a random salt in the inclusive range [0, 6172748).
func GenerateSalt() int {
return rand.Intn(6172748)
}
// ComputePasswordHashWithSalt returns the lowercased SHA-256 hex digest of
// passwordHashed concatenated with the decimal string form of salt.
func ComputePasswordHashWithSalt(passwordHashed string, salt int) string {
sum := sha256.Sum256([]byte(passwordHashed + strconv.Itoa(salt)))
return hex.EncodeToString(sum[:])
}
// GetCurrentTimestamp returns the current UNIX timestamp in seconds.
func GetCurrentTimestamp() int {
return int(time.Now().Unix())
}
// GetTokenExpireOn returns the timestamp 2 days from now, used as the token
// expiry.
func GetTokenExpireOn() int {
return GetCurrentTimestamp() + 60*60*24*2
}
// Str2Bool reports whether s, compared case-insensitively, equals "true".
func Str2Bool(s string) bool {
return strings.ToLower(s) == "true"
}
// GCD returns the greatest common divisor of a and b via the Euclidean
// algorithm.
func GCD(a, b int64) int64 {
for b != 0 {
a, b = b, a%b
}
return a
}
// LCM returns the least common multiple of a and b.
func LCM(a, b int64) int64 {
return (a * b) / GCD(a, b)
}
-62
View File
@@ -1,62 +0,0 @@
div.user-item {
display: flex;
flex-flow: row;
align-items: flex-start;
padding: 1.25rem;
margin-bottom: 1.25rem;
}
div.user-item-words {
display: flex;
flex-flow: column;
align-items: flex-start;
flex-grow: 1;
flex-basis: 0;
word-break: break-all;
}
div.user-item-icon {
margin-left: 0.75rem;
}
div.token-item {
display: flex;
flex-flow: row;
align-items: flex-start;
padding: 1.25rem;
margin-bottom: 1.25rem;
}
div.token-item-words {
display: flex;
flex-flow: column;
align-items: flex-start;
flex-grow: 1;
flex-basis: 0;
word-break: break-all;
}
div.token-item-icon {
margin-left: 0.75rem;
}
div.control-list {
display: flex;
flex-flow: row;
flex-wrap: wrap;
}
div.control-list > * {
margin-right: 0.75rem;
margin-bottom: 0.75rem;
}
-159
View File
@@ -1,159 +0,0 @@
#ccn-calendar-calendarBody > div:nth-child(n+2) > div {
border-top: 0 solid black;
border-left: 0 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;
}
#ccn-calendar-calendarBody > div:nth-child(n+2) > div:nth-child(1) {
border-left: 1px solid black;
}
#ccn-calendar-calendarBody > div:nth-child(2) > div {
border-top: 1px solid black;
}
#ccn-calendar-calendarBody > div > div {
flex-grow: 1;
flex-basis: 0;
flex-shrink: 0;
overflow: hidden;
}
#ccn-calendar-calendarBody > div > div[isCurrentMonth=false] {
background: #d0d0d0;
}
#ccn-calendar-calendarBody > div {
display: flex;
flex-flow: row;
}
div.calendarItem-eventBox {
border: 1px solid black;
border-radius: 2px;
margin-top: 0.2rem;
height: 0.75rem;
width: 100%;
}
div.calendarItem-eventBox[enableDisplay=true] {
visibility: visible;
}
div.calendarItem-eventBox[enableDisplay=false] {
visibility: hidden;
}
div.schedule-day {
display: flex;
flex-flow: column;
}
div.schedule-day-words {
margin-top: 0.75rem;
margin-bottom: 0.75rem;
}
div.schedule-event-list {
display: flex;
flex-flow: column;
}
div.schedule-event-outter {
display: flex;
flex-flow: row;
align-items: flex-start;
margin-bottom: 1.25rem;
}
div.schedule-event-inner {
display: flex;
flex-flow: row;
align-items: flex-start;
padding: 1.25rem;
}
div.schedule-event-words {
display: flex;
flex-flow: column;
align-items: flex-start;
flex-grow: 1;
flex-basis: 0;
word-break: break-all;
}
div.schedule-event-icon {
margin-left: 0.75rem;
}
div.schedule-event-color {
width: 0.75rem;
height: 100%;
}
#ccn-calendar-scheduleList div.schedule-day:nth-child(n+2) {
border-top: 1px solid rgba(219,219,219,.5);
}
div.collection-item {
display: flex;
flex-flow: row;
align-items: flex-start;
padding: 1.25rem;
margin-bottom: 1.25rem;
}
div.collection-item-words {
flex-grow: 1;
flex-basis: 0;
word-break: break-all;
}
div.collection-item-icon {
margin-left: 0.75rem;
}
div.control-list {
display: flex;
flex-flow: row;
flex-wrap: wrap;
}
div.control-list > * {
margin-right: 0.75rem;
margin-bottom: 0.75rem;
margin-left: 0 !important;
}
-34
View File
@@ -1,34 +0,0 @@
div.collection-item {
display: flex;
flex-flow: row;
align-items: flex-start;
padding: 1.25rem;
margin-bottom: 1.25rem;
}
div.collection-item-words {
flex-grow: 1;
flex-basis: 0;
word-break: break-all;
}
div.collection-item-icon {
margin-left: 0.75rem;
}
div.control-list {
display: flex;
flex-flow: row;
flex-wrap: wrap;
}
div.control-list > * {
margin-right: 0.75rem;
margin-bottom: 0.75rem;
margin-left: 0 !important;
}
@@ -1,117 +0,0 @@
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=true] {
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;
}
-17
View File
@@ -1,17 +0,0 @@
div.control-list {
display: flex;
flex-flow: row;
flex-wrap: wrap;
}
div.control-list > * {
margin-right: 0.75rem;
margin-bottom: 0.75rem;
margin-left: 0 !important;
}
#ccn-event-eventFormBody > section {
border-top: 1px solid rgba(219,219,219,.5);
}
-32
View File
@@ -1,32 +0,0 @@
div.todo-item {
display: flex;
flex-flow: row;
align-items: flex-start;
padding: 1.25rem;
margin-bottom: 1.25rem;
}
div.todo-item-words {
flex-grow: 1;
flex-basis: 0;
word-break: break-all;
}
div.todo-item-icon {
margin-left: 0.75rem;
}
div.control-list {
display: flex;
flex-flow: row;
flex-wrap: wrap;
}
div.control-list > * {
margin-right: 0.75rem;
margin-bottom: 0.75rem;
}
@@ -1,140 +0,0 @@
ccn-i18n-pageName-home=coconut-leaf - A light, self-host calendar system.
ccn-i18n-pageName-collection=coconut-leaf - Collection
ccn-i18n-pageName-calendar=coconut-leaf - Calendar
ccn-i18n-pageName-event=coconut-leaf - Event
ccn-i18n-pageName-todo=coconut-leaf - Todo
ccn-i18n-pageName-admin=coconut-leaf - Admin
ccn-i18n-pageName-login=coconut-leaf - Login
ccn-i18n-header-nav-home=Home
ccn-i18n-header-nav-collection=Collection
ccn-i18n-header-nav-calendar=Calendar
ccn-i18n-header-nav-todo=Todo
ccn-i18n-header-nav-admin=Admin
ccn-i18n-header-user-login=Login
ccn-i18n-header-user-logout=Logout
ccn-i18n-header-language=Language
ccn-i18n-universal-text-year=Year
ccn-i18n-universal-text-month=Month
ccn-i18n-universal-text-day=Day
ccn-i18n-universal-text-hour=Hour
ccn-i18n-universal-text-minute=Minute
ccn-i18n-universal-week-1=Monday
ccn-i18n-universal-week-2=Tuesday
ccn-i18n-universal-week-3=Wednesday
ccn-i18n-universal-week-4=Thursday
ccn-i18n-universal-week-5=Friday
ccn-i18n-universal-week-6=Saturday
ccn-i18n-universal-week-7=Sunday
ccn-i18n-universal-month-1=January
ccn-i18n-universal-month-2=February
ccn-i18n-universal-month-3=March
ccn-i18n-universal-month-4=April
ccn-i18n-universal-month-5=May
ccn-i18n-universal-month-6=June
ccn-i18n-universal-month-7=July
ccn-i18n-universal-month-8=August
ccn-i18n-universal-month-9=September
ccn-i18n-universal-month-10=October
ccn-i18n-universal-month-11=November
ccn-i18n-universal-month-12=December
ccn-i18n-messagebox-confirm=OK
ccn-i18n-messagebox-title=Notification
ccn-i18n-datetimepicker-confirm=OK
ccn-i18n-datetimepicker-cancel=Cancel
ccn-i18n-js-fail-login=Fail to login. Please check your username or password.
ccn-i18n-js-fail-logout=Fail to logout due to unknow reason. Consider refreshing page to solve problem.
ccn-i18n-js-fail-get=A get operation failed. It may caused by server internal error or your limited permission. Refreshing page may fix system problem. Before refreshing page, please backup all your unsaved data.
ccn-i18n-js-fail-add=An add operation failed. It may caused by wrong arguments. Refreshing page may fix system problem. Before refreshing page, please backup all your unsaved data.
ccn-i18n-js-fail-update=An update operation failed. It may caused by wrong arguments or lost target. Refreshing page may fix system problem. Before refreshing page, please backup all your unsaved data.
ccn-i18n-js-fail-delete=A delete operation failed. It may caused by no matched item. Refreshing page may fix system problem. Before refreshing page, please backup all your unsaved data.
ccn-i18n-js-success=Operation OK.
ccn-i18n-js-fail-form=Your filled event form is not fufilled or have error. Please check it and try again.
ccn-i18n-home-desc=<h1 class="title">coconut-leaf</h1><p>A light, self-host calendar system.</p><p>Originally, this app is served for yyc12345 personal use.</p><br /><p>Pull request / issue / translation are welcomed.</p><p>Submit them in our <a href="https://github.com/yyc12345/coconut-leaf">GitHub project</a>.</p><p>This project source code is licensed <a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL v3</a>.</p>
ccn-i18n-login-form-username=Username
ccn-i18n-login-form-password=Password
ccn-i18n-login-form-login=Login
ccn-i18n-todo-todoList=Todo list
ccn-i18n-calendar-calendar-today=Today
ccn-i18n-calendar-calendar-add=Add...
ccn-i18n-calendar-calendar-stripedEvents={0} items
ccn-i18n-calendar-calendar-scheduleList=Schedule
ccn-i18n-calendar-tabcontrol-tabCalendar=Calendar
ccn-i18n-calendar-tabcontrol-tabCollection=Collection
ccn-i18n-calendar-tabcontrol-tabDisplay=Display
ccn-i18n-calendar-owned-list=My collections
ccn-i18n-calendar-shared-list=Shared collections
ccn-i18n-calendar-display-firstDayOfWeek=The first day of week
ccn-i18n-calendar-display-subcalendar=Sub-Calendar
ccn-i18n-calendar-display-subcalendar-chineseLunisolarCalendar=Chinese Lunisolar Calendar
ccn-i18n-collection-owned-list=Owned
ccn-i18n-collection-sharing-list=Sharing target
ccn-i18n-collection-sharing-editing=Editing:
ccn-i18n-event-header=Edit Event
ccn-i18n-event-title=Title
ccn-i18n-event-description=Description
ccn-i18n-event-color=Color
ccn-i18n-event-collection=Collection
ccn-i18n-event-startDateTime=Start Date Time
ccn-i18n-event-endDateTime=Stop Date Time
ccn-i18n-event-btnSpot=Spot
ccn-i18n-event-btnFullDay=Full day
ccn-i18n-event-loop=Event Loop
ccn-i18n-event-loop-never=Never
ccn-i18n-event-loop-day=Day
ccn-i18n-event-loop-week=Week
ccn-i18n-event-loop-month=Month
ccn-i18n-event-loop-year=Year
ccn-i18n-event-loopDay-span=Day span
ccn-i18n-event-loopWeek-span=Week span
ccn-i18n-event-loopWeek-option=Week options
ccn-i18n-event-loopMonth-span=Month span
ccn-i18n-event-loopWeek-option=Month mode
ccn-i18n-event-loopWeek-optionA=Day {0} in month
ccn-i18n-event-loopWeek-optionB=Day {0} from the end of the month
ccn-i18n-event-loopWeek-optionC=Day {1} in week {0}
ccn-i18n-event-loopWeek-optionD=Day {1} in week {0} from the end of the month
ccn-i18n-event-loopYear-span=Year span
ccn-i18n-event-loopStop=Event Loop Stop
ccn-i18n-event-loopStop-forever=Forever
ccn-i18n-event-loopStop-datetime=Date Time
ccn-i18n-event-loopStop-times=Times
ccn-i18n-event-timezone-title=Timezone
ccn-i18n-event-timezone-warning=The timezone of this event is not corresponding with your current timezone. All of date and time in this page are shown as the original timezone of this event. You can choose a timezone option in follwing content. If you are not familar with this, please pick keep timezone.
ccn-i18n-event-timezone-keep=Keep timezone
ccn-i18n-event-timezone-replace=Use my timezone
ccn-i18n-event-strictMode-title=Strict Mode in Event Loop
ccn-i18n-event-strictMode-warning=You can choose strict mode or rough mode in following content. This is only effect on looped event.
ccn-i18n-event-strictMode-strict=Strict Mode. If ordered day is not existing, skip it.
ccn-i18n-event-strictMode-rough=Rough mode. If ordered day is not existing, choose the day closing with original day to arrange event.
ccn-i18n-event-btnSubmit=Submit
ccn-i18n-event-btnCancel=Cancel
ccn-i18n-sharedItem-sharedBy=Shared by:
ccn-i18n-admin-tabcontrol-tabProfile=My Profile
ccn-i18n-admin-tabcontrol-tabToken=Manage Multi-login
ccn-i18n-admin-tabcontrol-tabUserList=Manager User
ccn-i18n-admin-changePassword=Change Password
ccn-i18n-admin-manageToken=Manage multi-login
ccn-i18n-admin-manageToken-desc=Manage the multi-login of the current account. You can forced logout some login in there.
ccn-i18n-admin-userList=User List
ccn-i18n-userItem-newPassword=New Password
ccn-i18n-userItem-isAdmin=Is Admin
ccn-i18n-tokenItem-ua=User Agent:
ccn-i18n-tokenItem-ip=IP:
ccn-i18n-tokenItem-expireOn=Expire On:
ccn-i18n-tokenItem-isMe=This is the login credentials you are currently using.
@@ -1,153 +0,0 @@
ccn-i18n-pageName-home=coconut-leaf - 一个轻量的自建日历系统
ccn-i18n-pageName-collection=coconut-leaf - 集合
ccn-i18n-pageName-calendar=coconut-leaf - 日历
ccn-i18n-pageName-event=coconut-leaf - 事件
ccn-i18n-pageName-todo=coconut-leaf - 待办
ccn-i18n-pageName-admin=coconut-leaf - 管理
ccn-i18n-pageName-login=coconut-leaf - 登录
ccn-i18n-header-nav-home=主页
ccn-i18n-header-nav-collection=集合
ccn-i18n-header-nav-calendar=日历
ccn-i18n-header-nav-todo=待办
ccn-i18n-header-nav-admin=管理
ccn-i18n-header-user-login=登录
ccn-i18n-header-user-logout=登出
ccn-i18n-header-language=语言
ccn-i18n-universal-text-year=
ccn-i18n-universal-text-month=
ccn-i18n-universal-text-day=
ccn-i18n-universal-text-hour=
ccn-i18n-universal-text-minute=
ccn-i18n-universal-week-1=星期一
ccn-i18n-universal-week-2=星期二
ccn-i18n-universal-week-3=星期三
ccn-i18n-universal-week-4=星期四
ccn-i18n-universal-week-5=星期五
ccn-i18n-universal-week-6=星期六
ccn-i18n-universal-week-7=星期日
ccn-i18n-universal-month-1=1月
ccn-i18n-universal-month-2=2月
ccn-i18n-universal-month-3=3月
ccn-i18n-universal-month-4=4月
ccn-i18n-universal-month-5=5月
ccn-i18n-universal-month-6=6月
ccn-i18n-universal-month-7=7月
ccn-i18n-universal-month-8=8月
ccn-i18n-universal-month-9=9月
ccn-i18n-universal-month-10=10月
ccn-i18n-universal-month-11=11月
ccn-i18n-universal-month-12=12月
ccn-i18n-messagebox-confirm=确认
ccn-i18n-messagebox-title=通知
ccn-i18n-datetimepicker-confirm=确认
ccn-i18n-datetimepicker-cancel=取消
ccn-i18n-js-fail-login=登陆失败,请检查您的用户名和密码。
ccn-i18n-js-fail-logout=由于未知原因,登出失败,请考虑刷新页面解决问题。
ccn-i18n-js-fail-get=一个获取操作失败了,可能是系统错误或者您的权限不足。刷新页面可能会解决问题。请在刷新页面前备份好自己的数据。
ccn-i18n-js-fail-add=一个添加操作失败了,可能是您输入的参数有误。刷新页面可能会解决问题。请在刷新页面前备份好自己的数据。
ccn-i18n-js-fail-update=一个更新操作失败了,可能是没有找到匹配的条目或者您的参数输入错误。刷新页面可能会解决问题。请在刷新页面前备份好自己的数据。
ccn-i18n-js-fail-delete=一个删除操作失败了,可能是没有找到对应条目。刷新页面可能会解决问题。请在刷新页面前备份好自己的数据。
ccn-i18n-js-success=操作成功
ccn-i18n-js-fail-form=您所填写的事件内容存在缺漏或有错误字段,请检查后再提交。
ccn-i18n-home-desc=<h1 class="title">coconut-leaf</h1><p>一个轻量的自建日历系统</p><p>原本是出于yyc12345的个人使用而制作的。</p><br /><p>欢迎提出Pull request / issue / 翻译</p><p>将他们提交到我们的<a href="https://github.com/yyc12345/coconut-leaf">GitHub项目</a>.</p><p>本工程代码使用<a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL v3</a>授权。</p>
ccn-i18n-login-form-username=用户名
ccn-i18n-login-form-password=密码
ccn-i18n-login-form-login=登录
ccn-i18n-todo-todoList=待办列表
ccn-i18n-calendar-calendar-today=今天
ccn-i18n-calendar-calendar-add=添加...
ccn-i18n-calendar-calendar-stripedEvents=共{0}项
ccn-i18n-calendar-calendar-scheduleList=日程安排
ccn-i18n-calendar-tabcontrol-tabCalendar=日历
ccn-i18n-calendar-tabcontrol-tabCollection=集合
ccn-i18n-calendar-tabcontrol-tabDisplay=显示设置
ccn-i18n-calendar-owned-list=我的集合
ccn-i18n-calendar-shared-list=被共享的集合
ccn-i18n-calendar-display-firstDayOfWeek=每周开始星期
ccn-i18n-calendar-display-subcalendar=副日历
ccn-i18n-calendar-display-subcalendar-chineseLunisolarCalendar=中国农历
ccn-i18n-collection-owned-list=我的集合
ccn-i18n-collection-sharing-list=分享目标
ccn-i18n-collection-sharing-editing=正在编辑集合:
ccn-i18n-event-header=编辑事件
ccn-i18n-event-title=标题
ccn-i18n-event-description=描述
ccn-i18n-event-color=颜色
ccn-i18n-event-collection=集合
ccn-i18n-event-startDateTime=开始时间
ccn-i18n-event-endDateTime=结束时间
ccn-i18n-event-btnSpot=时间点
ccn-i18n-event-btnFullDay=全天
ccn-i18n-event-loop=事件循环
ccn-i18n-event-loop-never=从不
ccn-i18n-event-loop-day=按天
ccn-i18n-event-loop-week=按周
ccn-i18n-event-loop-month=按月
ccn-i18n-event-loop-year=按年
ccn-i18n-event-loopDay-span=间隔天数
ccn-i18n-event-loopWeek-span=间隔周数
ccn-i18n-event-loopWeek-option=在下列这些星期上循环
ccn-i18n-event-loopMonth-span=间隔月数
ccn-i18n-event-loopWeek-option=月份模式
ccn-i18n-event-loopWeek-optionA=第{0}天
ccn-i18n-event-loopWeek-optionB=倒数第{0}天
ccn-i18n-event-loopWeek-optionC=第{0}个星期{1}
ccn-i18n-event-loopWeek-optionD=倒数第{0}个星期{1}
ccn-i18n-event-loopYear-span=间隔年数
ccn-i18n-event-loopStop=事件循环停止方式
ccn-i18n-event-loopStop-forever=永不停止
ccn-i18n-event-loopStop-datetime=指定时间
ccn-i18n-event-loopStop-times=指定次数
ccn-i18n-event-timezone-title=时区设定
ccn-i18n-event-timezone-warning=您当前设置的事件的时区与您的时区不匹配,本页面以事件的原时区进行时间显示。您可以在下面修改您对于此事件的时区选择,如果您不熟悉时区,请选择保持原有时区。
ccn-i18n-event-timezone-keep=保持原有时区
ccn-i18n-event-timezone-replace=使用我现在的时区
ccn-i18n-event-strictMode-title=循环的严格与宽松
ccn-i18n-event-strictMode-warning=允许您在严格模式与宽松模式种进行选择,此选择只对循环事件有效。
ccn-i18n-event-strictMode-strict=严格模式,严格遵守日期要求,如果日期不存在,就不安排。
ccn-i18n-event-strictMode-rough=宽松模式,不在意日期要求的精确性,如果日期不存在,则找到最近的符合条件的日子安排。
ccn-i18n-event-btnSubmit=提交
ccn-i18n-event-btnCancel=取消
ccn-i18n-sharedItem-sharedBy=共享人:
ccn-i18n-admin-tabcontrol-tabProfile=我的资料
ccn-i18n-admin-tabcontrol-tabToken=管理多端登录
ccn-i18n-admin-tabcontrol-tabUserList=管理用户
ccn-i18n-admin-changePassword=更改密码
ccn-i18n-admin-manageToken=管理多端登录
ccn-i18n-admin-manageToken-desc=管理当前帐号的多端登录。您可以在这里强制下线某些地方的帐号。
ccn-i18n-admin-userList=用户列表
ccn-i18n-userItem-newPassword=新密码
ccn-i18n-userItem-isAdmin=是管理员
ccn-i18n-tokenItem-ua=UA
ccn-i18n-tokenItem-ip=IP
ccn-i18n-tokenItem-expireOn=过期时间:
ccn-i18n-tokenItem-isMe=这是你当前使用的登录凭据
ccn-i18n-datetime-loopStopRuleText-infinity=永远循环。
ccn-i18n-datetime-loopStopRuleText-datetime=到{0}停止循环。
ccn-i18n-datetime-loopStopRuleText-times=循环{0}次。
ccn-i18n-datetime-loopRuleText-modeStrict=严格模式。
ccn-i18n-datetime-loopRuleText-modeRough=宽松模式。
ccn-i18n-datetime-loopRuleText-year=每{0}年于{1}循环一次。
ccn-i18n-datetime-loopRuleText-monthA=每{0}月的第{1}日循环一次。
ccn-i18n-datetime-loopRuleText-monthB=每{0}月的倒数第{1}日循环一次。
ccn-i18n-datetime-loopRuleText-monthC=每{0}月的第{1}个星期{2}循环一次。
ccn-i18n-datetime-loopRuleText-monthD=每{0}月的倒数第{1}个星期{2}循环一次。
ccn-i18n-datetime-loopRuleText-week=每{0}周的{1}循环一次。
ccn-i18n-datetime-loopRuleText-day=每{0}天循环一次。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

-483
View File
@@ -1,483 +0,0 @@
// the api use bool to return status: fail: return false, true: return data(including true and false)
// the api use other type to return data: fail: return undefined, true: return data(if the returned value have change be null, return undefined instaed).
// var cached_salt = undefined
/*
function ccn_api_common_salt(_username) {
// true or false
// gotten salt store in cached_salt.
var gotten_data = undefined;
$.ajax({
url: '/api/common/salt',
type: "POST",
async: false,
data: {
username: _username
},
success: function (data) {
gotten_data = data;
}
});
if (IsResponseOK(gotten_data)) {
cached_salt = gotten_data['data'];
return true;
} else return false;
}
function ccn_api_common_login(_username, password) {
// return true or false, token is managed by this js file self.
// if cached_salt is undefined, return false directly
if (typeof(cached_salt) == undefined) return false;
var gotten_data = undefined;
$.ajax({
url: '/api/common/login',
type: "POST",
async: false,
data: {
username: _username,
password: ComputPasswordWithSalt(password, cached_salt)
},
success: function (data) {
gotten_data = data;
}
});
if (IsResponseOK(gotten_data) && gotten_data['data'] != '') {
ccn_localstorageAssist_SetApiToken(gotten_data['data']);
cached_salt = undefined;
return true;
} else return false;
}
*/
// ============================================ template
// all api can be implemented by these 2 function, except 3 token related func.
// so all api func should use these 2 func except 3 token process api.
function ccn_api_dataTemplate(_url, _data) {
// return data or undefined
var gotten_data = undefined;
$.ajax({
url: _url,
type: "POST",
async: false,
data: _data,
success: function (data) {
gotten_data = data;
}
});
if (IsResponseOK(gotten_data) && !(gotten_data['data'] === null)) return gotten_data['data'];
else return undefined;
}
function ccn_api_boolTemplate(_url, _data) {
// return true or false
var gotten_data = undefined;
$.ajax({
url: _url,
type: "POST",
async: false,
data: _data,
success: function (data) {
gotten_data = data;
}
});
return (IsResponseOK(gotten_data) && gotten_data['data']);
}
// deserialize & serialize calendar description function
function ccn_api_serializeDescription(_description, _color) {
var sobj = {
description: _description,
color: _color
}
return JSON.stringify(sobj);
}
function ccn_api_deserializeDescription(strl) {
try {
return $.parseJSON(strl);
} catch(err) {
return {
description: "",
color: DefaultColor
};
}
}
// ====================================================== common
function ccn_api_common_webLogin(_username, _password) {
var gotten_data = undefined;
$.ajax({
url: '/api/common/webLogin',
type: "POST",
async: false,
data: {
username: _username,
password: _password
},
success: function (data) {
gotten_data = data;
}
});
if (IsResponseOK(gotten_data)) {
ccn_localstorageAssist_SetApiToken(gotten_data['data']);
return true;
} else return false;
}
function ccn_api_common_logout() {
// return true or false
var gotten_data = undefined;
$.ajax({
url: '/api/common/logout',
type: "POST",
async: false,
data: {
token: ccn_localstorageAssist_GetApiToken()
},
success: function (data) {
gotten_data = data;
}
});
if (IsResponseOK(gotten_data) && gotten_data['data']) {
ccn_localstorageAssist_SetApiToken('');
return true;
} return false;
}
function ccn_api_common_tokenValid() {
// get from local database first, then judge it via post
// return true or false
var gotten_token = ccn_localstorageAssist_GetApiToken();
if (gotten_token == '') return false;
var gotten_data = undefined;
$.ajax({
url: '/api/common/tokenValid',
type: "POST",
async: false,
data: {
token: ccn_localstorageAssist_GetApiToken()
},
success: function (data) {
gotten_data = data;
}
});
if (IsResponseOK(gotten_data) && gotten_data['data']) return true;
else {
ccn_localstorageAssist_SetApiToken('');
return false;
}
}
// ====================================================== calendar
function ccn_api_calendar_getFull(_startDateTime, _endDateTime) {
return ccn_api_dataTemplate(
'/api/calendar/getFull',
{
token: ccn_localstorageAssist_GetApiToken(),
startDateTime: _startDateTime,
endDateTime: _endDateTime
}
);
}
function ccn_api_calendar_getDetail(_uuid) {
return ccn_api_dataTemplate(
'/api/calendar/getDetail',
{
token: ccn_localstorageAssist_GetApiToken(),
uuid: _uuid
}
);
}
function ccn_api_calendar_update(_uuid, _belongTo, _title, _description, _eventDateTimeStart, _eventDateTimeEnd, _loopRules, _timezoneOffset, _lastChange) {
var data = {};
if (typeof(_belongTo) != 'undefined')
data.belongTo = _belongTo;
if (typeof(_title) != 'undefined')
data.title = _title;
if (typeof(_description) != 'undefined')
data.description = _description;
if (typeof(_eventDateTimeStart) != 'undefined')
data.eventDateTimeStart = _eventDateTimeStart;
if (typeof(_eventDateTimeEnd) != 'undefined')
data.eventDateTimeEnd = _eventDateTimeEnd;
if (typeof(_loopRules) != 'undefined')
data.loopRules = _loopRules;
if (typeof(_timezoneOffset) != 'undefined')
data.timezoneOffset = _timezoneOffset;
data.token = ccn_localstorageAssist_GetApiToken();
data.uuid = _uuid;
data.lastChange = _lastChange;
return ccn_api_dataTemplate(
'/api/calendar/update',
data
);
}
function ccn_api_calendar_add(_belongTo, _title, _description, _eventDateTimeStart, _eventDateTimeEnd, _loopRules, _timezoneOffset) {
return ccn_api_dataTemplate(
'/api/calendar/add',
{
token: ccn_localstorageAssist_GetApiToken(),
belongTo: _belongTo,
title: _title,
description: _description,
eventDateTimeStart: _eventDateTimeStart,
eventDateTimeEnd: _eventDateTimeEnd,
loopRules: _loopRules,
timezoneOffset: _timezoneOffset
}
);
}
function ccn_api_calendar_delete(_uuid, _lastChange) {
return ccn_api_boolTemplate(
'/api/calendar/delete',
{
token: ccn_localstorageAssist_GetApiToken(),
uuid: _uuid,
lastChange: _lastChange
}
);
}
// ====================================================== collection
function ccn_api_collection_getFullOwn() {
return ccn_api_dataTemplate(
'/api/collection/getFullOwn',
{
token: ccn_localstorageAssist_GetApiToken()
}
);
}
function ccn_api_collection_getDetailOwn(_uuid) {
return ccn_api_dataTemplate(
'/api/collection/getDetailOwn',
{
token: ccn_localstorageAssist_GetApiToken(),
uuid: _uuid
}
);
}
function ccn_api_collection_addOwn(_name) {
return ccn_api_dataTemplate(
'/api/collection/addOwn',
{
token: ccn_localstorageAssist_GetApiToken(),
name: _name
}
);
}
function ccn_api_collection_updateOwn(_uuid, _name, _lastChange) {
return ccn_api_dataTemplate(
'/api/collection/updateOwn',
{
token: ccn_localstorageAssist_GetApiToken(),
uuid: _uuid,
name: _name,
lastChange: _lastChange
}
);
}
function ccn_api_collection_deleteOwn(_uuid, _lastChange) {
return ccn_api_boolTemplate(
'/api/collection/deleteOwn',
{
token: ccn_localstorageAssist_GetApiToken(),
uuid: _uuid,
lastChange: _lastChange
}
);
}
function ccn_api_collection_getSharing(_uuid) {
return ccn_api_dataTemplate(
'/api/collection/getSharing',
{
token: ccn_localstorageAssist_GetApiToken(),
uuid: _uuid
}
);
}
function ccn_api_collection_deleteSharing(_uuid, _target, _lastChange) {
return ccn_api_dataTemplate(
'/api/collection/deleteSharing',
{
token: ccn_localstorageAssist_GetApiToken(),
uuid: _uuid,
target: _target,
lastChange: _lastChange
}
);
}
function ccn_api_collection_addSharing(_uuid, _target, _lastChange) {
return ccn_api_dataTemplate(
'/api/collection/addSharing',
{
token: ccn_localstorageAssist_GetApiToken(),
uuid: _uuid,
target: _target,
lastChange: _lastChange
}
);
}
function ccn_api_collection_getShared() {
return ccn_api_dataTemplate(
'/api/collection/getShared',
{
token: ccn_localstorageAssist_GetApiToken()
}
);
}
// ====================================================== todo
function ccn_api_todo_getFull() {
return ccn_api_dataTemplate(
'/api/todo/getFull',
{
token: ccn_localstorageAssist_GetApiToken()
}
);
}
function ccn_api_todo_add() {
return ccn_api_dataTemplate(
'/api/todo/add',
{
token: ccn_localstorageAssist_GetApiToken()
}
);
}
function ccn_api_todo_update(_uuid, _data, _lastChange) {
return ccn_api_dataTemplate(
'/api/todo/update',
{
token: ccn_localstorageAssist_GetApiToken(),
uuid: _uuid,
data: _data,
lastChange: _lastChange
}
);
}
function ccn_api_todo_delete(_uuid, _lastChange) {
return ccn_api_boolTemplate(
'/api/todo/delete',
{
token: ccn_localstorageAssist_GetApiToken(),
uuid: _uuid,
lastChange: _lastChange
}
);
}
// ====================================================== admin
function ccn_api_admin_get() {
return ccn_api_dataTemplate(
'/api/admin/get',
{
token: ccn_localstorageAssist_GetApiToken()
}
);
}
function ccn_api_admin_add(_username) {
return ccn_api_dataTemplate(
'/api/admin/add',
{
token: ccn_localstorageAssist_GetApiToken(),
username: _username
}
);
}
function ccn_api_admin_update(_username, _password, _isAdmin) {
var data = {};
if (typeof(_password) != 'undefined')
data.password = _password;
if (typeof(_isAdmin) != 'undefined')
data.isAdmin = _isAdmin;
if (Object.getOwnPropertyNames(data).length == 0) return false;
data.token = ccn_localstorageAssist_GetApiToken();
data.username = _username;
return ccn_api_boolTemplate(
'/api/admin/update',
data
);
}
function ccn_api_admin_delete(_username) {
return ccn_api_boolTemplate(
'/api/admin/delete',
{
token: ccn_localstorageAssist_GetApiToken(),
username: _username
}
);
}
// ====================================================== profile
function ccn_api_profile_isAdmin() {
return ccn_api_boolTemplate(
'/api/profile/isAdmin',
{
token: ccn_localstorageAssist_GetApiToken()
}
);
}
function ccn_api_profile_changePassword(_password) {
return ccn_api_boolTemplate(
'/api/profile/changePassword',
{
token: ccn_localstorageAssist_GetApiToken(),
password: _password
}
);
}
function ccn_api_profile_getToken() {
return ccn_api_boolTemplate(
'/api/profile/getToken',
{
token: ccn_localstorageAssist_GetApiToken()
}
);
}
function ccn_api_profile_deleteToken(_deleteToken) {
return ccn_api_boolTemplate(
'/api/profile/deleteToken',
{
token: ccn_localstorageAssist_GetApiToken(),
deleteToken: _deleteToken
}
);
}
-441
View File
@@ -1,441 +0,0 @@
// NOTE: this file is sync with dt.py. if this file or dt.py have bugs, all code should be changed
var ccn_datetime_monthDayCount = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
var ccn_datetime_MIN_YEAR = 1950;
var ccn_datetime_MAX_YEAR = 2200;
var ccn_datetime_MIN_DATETIME = new Date(Date.UTC(ccn_datetime_MIN_YEAR, 0, 1, 0, 0, 0, 0));
var ccn_datetime_MAX_DATETIME = new Date(Date.UTC(ccn_datetime_MAX_YEAR, 0, 1, 0, 0, 0, 0));
var ccn_datetime_MIN_TIMESTAMP = Math.floor(ccn_datetime_MIN_DATETIME.getTime() / 60000);
var ccn_datetime_MAX_TIMESTAMP = Math.floor(ccn_datetime_MAX_DATETIME.getTime() / 60000);
var ccn_datetime_DAY1_SPAN = 60 * 24;
var ccn_datetime_DAY7_SPAN = 7 * ccn_datetime_DAY1_SPAN;
var ccn_datetime_precompiledLoopRules = {
year: new RegExp(/^Y([SR]{1})([1-9]\d*)$/),
month: new RegExp(/^M([SR]{1})([ABCD]{1})([1-9]\d*)$/),
week: new RegExp(/^W([TF]{7})([1-9]\d*)$/),
day: new RegExp(/^D([1-9]\d*)$/)
};
var ccn_datetime_precompiledLoopStopRules = {
infinity: new RegExp(/^F$/),
datetime: new RegExp(/^D([1-9]\d*|0)$/),
times: new RegExp(/^T([1-9]\d*)$/)
}
/*
return format
[loopRules, loopStopRules] or undefined(invalid or no loop)
loopRules:
year loop: [0, isStrict, yearSpan]
month loop: [1, isStrict, monthMode, monthSpan]
week loop: [2, 7 bool item..., weekSpan]
day loop: [3, daySpan]
loopStopRules:
infinity: [0]
datetime: [1, timestamp]
times: [2, times]
*/
function ccn_datetime_ResolveLoopRules4UI(strl) {
if (strl == '') return undefined;
var sp = strl.split('-');
if (sp.length != 2) return undefined;
var loopRules = undefined;
var loopStopRules = undefined;
if (ccn_datetime_precompiledLoopRules.year.test(sp[0])) {
loopRules = [0, RegExp.$1 == 'S', parseInt(RegExp.$2)];
} else if (ccn_datetime_precompiledLoopRules.month.test(sp[0])) {
loopRules = [1, RegExp.$1 == 'S', RegExp.$2, parseInt(RegExp.$3)];
} else if (ccn_datetime_precompiledLoopRules.week.test(sp[0])) {
loopRules = [2];
for (var i = 0; i < 7; i++)
loopRules.push(RegExp.$1[i] == 'T');
loopRules.push(parseInt(RegExp.$2));
} else if (ccn_datetime_precompiledLoopRules.day.test(sp[0])) {
loopRules = [3, parseInt(RegExp.$1)];
} else return undefined;
if (ccn_datetime_precompiledLoopStopRules.infinity.test(sp[1])) {
loopStopRules = [0];
} else if (ccn_datetime_precompiledLoopStopRules.datetime.test(sp[1])) {
loopStopRules = [1, parseInt(RegExp.$1)];
} else if (ccn_datetime_precompiledLoopStopRules.times.test(sp[1])) {
loopStopRules = [2, parseInt(RegExp.$1)];
} else return undefined;
return [loopRules, loopStopRules];
}
// loopDateTimeStart's value is not correspond with database.
// it is calculated by program, should be pointed to the closing
// protential event start datetime.
// also loopDateTimeEnd, it was clamped with the tail of legal event
// clampStartDateTime is real clamp datetime of event start datetime.
// loopDateTimeStart is the start datetime for detect.
// in this section, all time should be analysed with Date((time + timezoneOffset) * 60000)
// and use .getUTC...() functions.
function ccn_datetime_ResolveLoopRules4Event(loopRules, loopDateTimeStart, loopDateTimeEnd, eventDateTimeStart, eventDateTimeEnd, timezoneOffset, clampStartDateTime) {
if (loopRules == '') return [
[Math.max(eventDateTimeStart, clampStartDateTime),
Math.max(loopDateTimeEnd, eventDateTimeEnd)]
];
var sp = loopRules.split('-');
if (sp.length != 2) return undefined;
var loopRules = sp[0]; // we don't need consider stop flag
var result = new Array();
// compute offset and duration
var eventDateTime = new Date((eventDateTimeStart + timezoneOffset) * 60000);
eventDateTime.setUTCHours(0, 0, 0, 0);
var eventOffset = eventDateTimeStart - (Math.floor(eventDateTime.getTime() / 60000) - timezoneOffset);
var eventDuration = eventDateTimeEnd - eventDateTimeStart;
var detectDateTime = new Date(loopDateTimeStart * 60000);
detectDateTime.setUTCHours(0, 0, 0, 0);
var originalYear = eventDateTime.getUTCFullYear();
var originalMonth = eventDateTime.getUTCMonth() + 1;
var originalDay = eventDateTime.getUTCDate();
// compute event
if (ccn_datetime_precompiledLoopRules.year.test(loopRules)) {
var isStrict = RegExp.$1 == 'S';
var loopSpan = parseInt(RegExp.$2);
var yearCount = detectDateTime.getFullYear() - originalYear;
var isSpecial = (originalMonth == 2 && originalDay == 29);
var realLoopSpan = (isSpecial && isStrict) ? LCM(4, loopSpan) : loopSpan;
//var fullSpanCount = Math.floor(yearCount / realLoopSpan);
var remainYear = yearCount % realLoopSpan;
//detectDateTime.setUTCFullYear(fullSpanCount + detectDateTime.getUTCFullYear(), 1, 1);
if (remainYear != 0)
detectDateTime.setUTCFullYear(realLoopSpan - remainYear + detectDateTime.getUTCFullYear(), 1 - 1, 1);
var skipFlag = false;
while(Math.floor(detectDateTime.getTime() / 60000) + eventOffset - timezoneOffset <= loopDateTimeEnd) {
skipFlag = false;
if (isSpecial) {
// is special day, 29 Feb
// try set it in 29 Feb
if (isStrict) {
if (ccn_datetime_IsLeapYear(detectDateTime.getUTCFullYear())) detectDateTime.setUTCMonth(2 - 1, 29);
else skipFlag = true; // order skip
} else {
if (ccn_datetime_IsLeapYear(detectDateTime.getUTCFullYear())) detectDateTime.setUTCMonth(2 - 1, 29);
else detectDateTime.setUTCMonth(2 - 1, 28);
}
} else detectDateTime.setUTCMonth(originalMonth - 1, originalDay);
if (!skipFlag) {
result.push(
[Math.floor(detectDateTime.getTime() / 60000) + eventOffset - timezoneOffset,
Math.floor(detectDateTime.getTime() / 60000) + eventOffset + eventDuration - timezoneOffset]
);
}
detectDateTime.setUTCFullYear(realLoopSpan + detectDateTime.getUTCFullYear());
}
} else if (ccn_datetime_precompiledLoopRules.month.test(loopRules)) {
var isStrict = RegExp.$1 == 'S';
var loopMethod = RegExp.$2;
var loopSpan = parseInt(RegExp.$3);
var monthsCount = ccn_datetime_MonthsCount(detectDateTime.getUTCFullYear(), detectDateTime.getUTCMonth() + 1) -
ccn_datetime_MonthsCount(originalYear, originalMonth);
//var fullSpanCount = Math.floor(monthsCount / loopSpan);
var remainMonth = monthsCount % loopSpan;
//detectDateTime.setUTCMonth(fullSpanCount * loopSpan + detectDateTime.getUTCMonth(), 1);
detectDateTime.setUTCDate(1);
if (remainMonth != 0)
detectDateTime.setUTCMonth(loopSpan - remainMonth + detectDateTime.getUTCMonth(), 1);
while(Math.floor(detectDateTime.getTime() / 60000) + eventOffset - timezoneOffset <= loopDateTimeEnd) {
var data = ccn_datetime_GetRemanagedDayInMonth(originalYear, originalMonth, originalDay, detectDateTime.getUTCFullYear(), detectDateTime.getUTCMonth() + 1, isStrict);
var predictedDay = undefined;
switch(loopMethod) {
case 'A':
if (typeof(data[0]) != 'undefined') predictedDay = data[0];
break;
case 'B':
if (typeof(data[1]) != 'undefined') predictedDay = data[1];
break;
case 'C':
if (typeof(data[2]) != 'undefined') predictedDay = data[2];
break;
case 'D':
if (typeof(data[3]) != 'undefined') predictedDay = data[3];
break;
}
if (typeof(predictedDay) != 'undefined') {
detectDateTime.setUTCDate(predictedDay);
result.push(
[Math.floor(detectDateTime.getTime() / 60000) + eventOffset - timezoneOffset,
Math.floor(detectDateTime.getTime() / 60000) + eventOffset + eventDuration - timezoneOffset]
);
}
detectDateTime.setUTCMonth(loopSpan + detectDateTime.getUTCMonth(), 1);
}
} else if (ccn_datetime_precompiledLoopRules.week.test(loopRules)) {
var loopSpan = parseInt(RegExp.$2);
var weekOption = [];
var weekEventCount = 0
for (var i = 0; i < 7; i++) {
weekOption.push(RegExp.$1[i] == 'T');
if (RegExp.$1[i] == 'T') weekEventCount++;
}
var originalWeek = ccn_datetime_DayOfWeek(originalYear, originalMonth, originalDay);
// try insert original event
if (!weekOption[originalWeek]) {
result.push(
[eventDateTimeStart, eventDateTimeEnd]
);
}
var daysCount = ccn_datetime_DaysCount(detectDateTime.getUTCFullYear(), detectDateTime.getUTCMonth() + 1, detectDateTime.getDate()) -
ccn_datetime_DaysCount(originalYear, originalMonth, originalDay);
//var fullSpanCount = Math.floor(daysCount / (7 * loopSpan));
var remainFullSpanCount = Math.floor((daysCount % (7 * loopSpan)) / 7);
var remainDays = (daysCount % (7 * loopSpan)) % 7;
//detectDateTime.setUTCDate((7 * loopSpan * fullSpanCount) + detectDateTime.getUTCDate());
if (remainFullSpanCount != 0) {
detectDateTime.setUTCDate((loopSpan - remainFullSpanCount) * 7 + detectDateTime.getUTCDate());
}
var weekCounter = remainDays;
while(Math.floor(detectDateTime.getTime() / 60000) + eventOffset - timezoneOffset <= loopDateTimeEnd) {
if (weekOption[(weekCounter + originalWeek) % 7])
result.push(
[Math.floor(detectDateTime.getTime() / 60000) + eventOffset - timezoneOffset,
Math.floor(detectDateTime.getTime() / 60000) + eventOffset + eventDuration - timezoneOffset]
);
weekCounter = (weekCounter + 1) % 7;
detectDateTime.setUTCDate(detectDateTime.getUTCDate() + 1);
if (weekCounter == 0)
detectDateTime.setUTCDate(detectDateTime.getUTCDate() + (loopSpan - 1) * 7);
}
} else if (ccn_datetime_precompiledLoopRules.day.test(loopRules)) {
var loopSpan = parseInt(RegExp.$1);
var daysCount = ccn_datetime_DaysCount(detectDateTime.getUTCFullYear(), detectDateTime.getUTCMonth() + 1, detectDateTime.getUTCDate()) -
ccn_datetime_DaysCount(originalYear, originalMonth, originalDay);
//var fullSpanCount = Math.floor(daysCount / loopSpan);
var remainDays = daysCount % loopSpan;
//detectDateTime.setUTCDate(fullSpanCount * loopSpan + detectDateTime.getUTCDate());
if (remainDays != 0)
detectDateTime.setUTCDate(loopSpan - remainDays + detectDateTime.getUTCDate());
while(Math.floor(detectDateTime.getTime() / 60000) + eventOffset - timezoneOffset <= loopDateTimeEnd) {
result.push(
[Math.floor(detectDateTime.getTime() / 60000) + eventOffset - timezoneOffset,
Math.floor(detectDateTime.getTime() / 60000) + eventOffset + eventDuration - timezoneOffset]
);
detectDateTime.setUTCDate(detectDateTime.getUTCDate() + loopSpan);
}
} else return undefined;
// clamp item
var realResult = [];
for (var i in result) {
var start = result[i][0];
var end = result[i][1];
if (end > clampStartDateTime && start <= loopDateTimeEnd)
realResult.push([Math.max(start, clampStartDateTime), Math.min(end, loopDateTimeEnd)]);
}
return realResult;
}
function ccn_datetime_ResolveLoopRules4Text(strl, startDateTime, timezoneOffset) {
if (strl == '') return "";
var sp = strl.split('-');
if (sp.length != 2) return "";
var loopRules = undefined;
var loopStopRules = undefined;
var datetimeInstance = new Date((startDateTime + timezoneOffset) * 60000)
if (ccn_datetime_precompiledLoopRules.year.test(sp[0])) {
if (RegExp.$1 == 'S')
loopRules = $.i18n.prop('ccn-i18n-datetime-loopRuleText-modeStrict');
else
loopRules = $.i18n.prop('ccn-i18n-datetime-loopRuleText-modeRough');
loopRules += $.i18n.prop('ccn-i18n-datetime-loopRuleText-year')
.format(parseInt(RegExp.$2), datetimeInstance.toLocaleDateString(undefined, {timeZone: "UTC"}));
} else if (ccn_datetime_precompiledLoopRules.month.test(sp[0])) {
if (RegExp.$1 == 'S')
loopRules = $.i18n.prop('ccn-i18n-datetime-loopRuleText-modeStrict');
else
loopRules = $.i18n.prop('ccn-i18n-datetime-loopRuleText-modeRough');
var dayInMonth = ccn_datetime_GetDayInMonth(
datetimeInstance.getUTCFullYear(),
datetimeInstance.getUTCMonth() + 1,
datetimeInstance.getUTCDate());
switch(RegExp.$2) {
case 'A':
loopRules = $.i18n.prop('ccn-i18n-datetime-loopRuleText-monthA')
.format(parseInt(RegExp.$3), dayInMonth[0]);
break;
case 'B':
loopRules = $.i18n.prop('ccn-i18n-datetime-loopRuleText-monthB')
.format(parseInt(RegExp.$3), dayInMonth[1]);
break;
case 'C':
loopRules = $.i18n.prop('ccn-i18n-datetime-loopRuleText-monthC')
.format(parseInt(RegExp.$3), dayInMonth[2], dayInMonth[3]);
break;
case 'D':
loopRules = $.i18n.prop('ccn-i18n-datetime-loopRuleText-monthD')
.format(parseInt(RegExp.$3), dayInMonth[4], dayInMonth[5]);
break;
}
} else if (ccn_datetime_precompiledLoopRules.week.test(sp[0])) {
var weekOfDayCache = [];
for (var i = 0; i < 7; i++) {
if (RegExp.$1[i] == 'T')
weekOfDayCache.push(ccn_i18n_UniversalGetDayOfWeek(i));
}
loopRules = $.i18n.prop('ccn-i18n-datetime-loopRuleText-week')
.format(parseInt(RegExp.$2), weekOfDayCache.join(', '));
} else if (ccn_datetime_precompiledLoopRules.day.test(sp[0])) {
loopRules = $.i18n.prop('ccn-i18n-datetime-loopRuleText-day')
.format(parseInt(RegExp.$1));
} else return "";
if (ccn_datetime_precompiledLoopStopRules.infinity.test(sp[1])) {
loopStopRules = $.i18n.prop('ccn-i18n-datetime-loopStopRuleText-infinity');
} else if (ccn_datetime_precompiledLoopStopRules.datetime.test(sp[1])) {
loopStopRules = $.i18n.prop('ccn-i18n-datetime-loopStopRuleText-datetime')
.format(new Date(parseInt(RegExp.$1)).toLocaleDateString());
} else if (ccn_datetime_precompiledLoopStopRules.times.test(sp[1])) {
loopStopRules = $.i18n.prop('ccn-i18n-datetime-loopStopRuleText-times')
.format(parseInt(RegExp.$1));
} else return "";
return (loopRules + loopStopRules);
}
function ccn_datetime_LeapYearCountEx(endYear, includeThis, baseYear, includeBase) {
if (!includeThis) endYear--;
if (includeBase) baseYear--;
var endly = Math.floor(endYear / 4);
endly -= Math.floor(endYear / 100);
endly += Math.floor(endYear / 400);
var basely = Math.floor(baseYear / 4);
basely -= Math.floor(baseYear / 100);
basely += Math.floor(baseYear / 400);
return (endly - basely);
}
function ccn_datetime_DaysCount(year, month, day) {
var ly = ccn_datetime_LeapYearCountEx(year, false, 1, true);
var days = 365 * (year - 1);
days += ly;
for(var index = 1; index < month; index++)
days += ccn_datetime_monthDayCount[index - 1];
if (month > 2 && ccn_datetime_IsLeapYear(year))
days += 1;
days += day - 1;
return days;
}
function ccn_datetime_MonthsCount(year, month) {
return (year - 1) * 12 + (month - 1);
}
function ccn_datetime_DayOfWeek(year, month, day) {
return ccn_datetime_DaysCount(year, month, day) % 7;
}
function ccn_datetime_GetDayInMonth(year, month, day) {
var days = ccn_datetime_monthDayCount[month - 1] + ((month == 2 && ccn_datetime_IsLeapYear(year)) ? 1 : 0);
var firstDayOfWeek = ccn_datetime_DayOfWeek(year, month, 1);
var dayOfWeek = (firstDayOfWeek + day - 1) % 7;
var dayForwards = day;
var dayBackwards = days - day + 1;
var weeksForward = Math.floor((dayForwards - 1) / 7) + 1;
var weeksBackwards = Math.floor((dayBackwards - 1) / 7) + 1;
return [dayForwards, dayBackwards, weeksForward, dayOfWeek, weeksBackwards, dayOfWeek];
}
function ccn_datetime_GetRemanagedDayInMonth(oldYear, oldMonth, oldDay, newYear, newMonth, isStrict) {
var ddata = ccn_datetime_GetDayInMonth(oldYear, oldMonth, oldDay);
var mdata = ccn_datetime_GetMonthWeekStatistics(newYear, newMonth);
var days = ccn_datetime_monthDayCount[newMonth - 1] + ((newMonth == 2 && ccn_datetime_IsLeapYear(year)) ? 1 : 0);
var firstDayOfWeek = ccn_datetime_DayOfWeek(newYear, newMonth, 1);
//var lastDayOfWeek = (firstDayOfWeek + days - 1) % 7;
if (isStrict) {
var methodA = ddata[0] > days ? undefined : ddata[0];
var methodB = ddata[1] > days ? undefined : (days - ddata[1] + 1);
} else {
var methodA = Math.min(ddata[0], days);
var methodB = days - Math.min(ddata[1], days) + 1;
}
var methodC = undefined;
if (ddata[2] <= mdata[ddata[3]] || !isStrict) {
var targetWeek = Math.min(ddata[2], mdata[ddata[3]]);
methodC = 1 + (targetWeek - 1) * 7 + ((ddata[3] + 7 - firstDayOfWeek) % 7);
}
var methodD = undefined;
if (ddata[4] <= mdata[ddata[5]] || !isStrict) {
// convert to type c and calc
var targetWeek = mdata[ddata[5]] - Math.min(ddata[4], mdata[ddata[5]]) + 1;
methodD = 1 + (targetWeek - 1) * 7 + ((ddata[5] + 7 - firstDayOfWeek) % 7);
}
return [methodA, methodB, methodC, methodD];
}
function ccn_datetime_GetMonthWeekStatistics(year, month) {
var days = ccn_datetime_monthDayCount[month - 1] + ((month == 2 && ccn_datetime_IsLeapYear(year)) ? 1 : 0);
var firstDayOfWeek = ccn_datetime_DayOfWeek(year, month, 1);
var result = [4, 4, 4, 4, 4, 4, 4];
var remain = days % 7;
var week = firstDayOfWeek;
while (remain > 0) {
result[week % 7] += 1;
week++;
remain--;
}
return result;
}
function ccn_datetime_IsLeapYear(year) {
var isLeap = false;
if (year % 4 == 0) isLeap = true;
if (year % 100 == 0) isLeap = false;
if (year % 400 == 0) isLeap = true;
return isLeap;
}
-522
View File
@@ -1,522 +0,0 @@
var ccn_datetimepicker_tabType = {
year: 0,
month: 1,
day: 2,
hour: 3,
minute: 4
};
var ccn_datetimepicker_dialPlateWidth = 200;
var ccn_datetimepicker_dialPlateRadius = ccn_datetimepicker_dialPlateWidth / 2;
var ccn_datetimepicker_dialPlateHourInnerPercent = 0.6;
var ccn_datetimepicker_dialPlateHourOutterPercent = 0.8;
var ccn_datetimepicker_dialPlateHourDistinguishPercent = 0.7;
var ccn_datetimepicker_dialPlateMinutePercent = 0.8;
var ccn_datetimepicker_dialPlateHourResolution = Math.PI * 2 / 12;
var ccn_datetimepicker_dialPlateMinuteResolution = Math.PI * 2 / 60;
var ccn_datetimepicker_mode = undefined;
var ccn_datetimepicker_isUTC = undefined;
var ccn_datetimepicker_pickerIndex = undefined;
var ccn_datetimepicker_enableMinuteDrag = false;
var ccn_datetimepicker_enableHourDrag = false;
var ccn_datetimepicker_internalDateTime = new Date();
var ccn_datetimepicker_displayCacheDateTime = new Date();
// ========================================= export func
function ccn_datetimepicker_Insert() {
$('body').append(ccn_template_datetimepicker.render());
// bind size event and trigge once
$(window).resize(ccn_datetimepicker_RefreshSvg).resize();
// add data attr
for(var i = 0; i < 3; i++) {
for(var j = 0; j < 4; j++) {
$('#ccn-datetimepiacker-panelMonth-table > div:nth-child({0}) > div:nth-child({1})'.format(i + 1, j + 1))
.attr('data', i * 4 + j);
}
}
// bind header event
$('header.pickerHeader > div').click(function() {
ccn_datetimepicker_SwitchTab(ccn_datetimepicker_Str2TabType($(this).attr('type')));
});
// bind button event
$('#ccn-datetimepiacker-panelYear-prevBtn').click(function() {
ccn_datetimepicker_PrevNextYear(true);
});
$('#ccn-datetimepiacker-panelYear-nextBtn').click(function() {
ccn_datetimepicker_PrevNextYear(false);
});
$('#ccn-datetimepiacker-panelMonth-prevBtn').click(function() {
ccn_datetimepicker_PrevNextMonth(true);
});
$('#ccn-datetimepiacker-panelMonth-nextBtn').click(function() {
ccn_datetimepicker_PrevNextMonth(false);
});
$('#ccn-datetimepiacker-panelDay-prevBtn').click(function() {
ccn_datetimepicker_PrevNextDay(true);
});
$('#ccn-datetimepiacker-panelDay-nextBtn').click(function() {
ccn_datetimepicker_PrevNextDay(false);
});
$('#ccn-datetimepiacker-panelYear-table > div > div').click(ccn_datetimepicker_ClickYear);
$('#ccn-datetimepiacker-panelMonth-table > div > div').click(ccn_datetimepicker_ClickMonth);
$('#ccn-datetimepiacker-panelDay-table > div:nth-child(n+1) > div').click(ccn_datetimepicker_ClickDay);
$('#ccn-datetimepicker-panelHour')
.mousedown(ccn_datetimepicker_StartDragHour)
.mousemove(ccn_datetimepicker_DraggingHour)
.mouseup(ccn_datetimepicker_StopDragHour)
.on('touchstart', ccn_datetimepicker_StartDragHour)
.on('touchmove', ccn_datetimepicker_DraggingHour)
.on('touchend', ccn_datetimepicker_StopDragHour);
$('#ccn-datetimepicker-panelMinute')
.mousedown(ccn_datetimepicker_StartDragMinute)
.mousemove(ccn_datetimepicker_DraggingMinute)
.mouseup(ccn_datetimepicker_StopDragMinute)
.on('touchstart', ccn_datetimepicker_StartDragMinute)
.on('touchmove', ccn_datetimepicker_DraggingMinute)
.on('touchend', ccn_datetimepicker_StopDragMinute);
$('#ccn-datetimepicker-btnConfirm').click(ccn_datetimepicker_Confirm);
$('#ccn-datetimepicker-btnCancel').click(ccn_datetimepicker_Cancel);
}
function ccn_datetimepicker_Modal(mode, pickerIndex, isUTC) {
ccn_datetimepicker_mode = mode;
ccn_datetimepicker_isUTC = isUTC;
ccn_datetimepicker_pickerIndex = pickerIndex;
ccn_datetimepicker_internalDateTime = ccn_datetimepicker_Get(pickerIndex, false);
$('header.pickerHeader > div').hide();
switch(mode) {
case ccn_datetimepicker_tabType.minute:
$('header.pickerHeader > div[type=minute]').show();
case ccn_datetimepicker_tabType.hour:
$('header.pickerHeader > div[type=hour]').show();
case ccn_datetimepicker_tabType.day:
$('header.pickerHeader > div[type=day]').show();
case ccn_datetimepicker_tabType.month:
$('header.pickerHeader > div[type=month]').show();
case ccn_datetimepicker_tabType.year:
$('header.pickerHeader > div[type=year]').show();
break;
}
$('#ccn-datetimepicker-modal').addClass('is-active');
ccn_datetimepicker_SwitchTab(mode); // this call is set in there by design. if you don't show the dialog, the call of svg resize will fail.
}
function ccn_datetimepicker_Confirm() {
// update and call callback func
ccn_datetimepicker_Set(
ccn_datetimepicker_pickerIndex,
ccn_datetimepicker_internalDateTime,
ccn_datetimepicker_isUTC,
ccn_datetimepicker_mode
);
$('#ccn-datetimepicker-modal').removeClass('is-active');
}
function ccn_datetimepicker_Cancel() {
$('#ccn-datetimepicker-modal').removeClass('is-active');
}
// ========================================= internal func
function ccn_datetimepicker_OnSvgResize(ele) {
var scale = 200 / Math.min(ele.width(), ele.height());
ele.css('font-size', scale + 'em');
}
function ccn_datetimepicker_SwitchTab(newTab) {
$('div.pickerContainer > *').hide();
ccn_datetimepicker_displayCacheDateTime.setTime(ccn_datetimepicker_internalDateTime.getTime());
ccn_datetimepicker_RefreshDisplay(newTab);
switch(newTab) {
case ccn_datetimepicker_tabType.year:
$('#ccn-datetimepicker-panelYear').show();
break;
case ccn_datetimepicker_tabType.month:
$('#ccn-datetimepicker-panelMonth').show();
break;
case ccn_datetimepicker_tabType.day:
$('#ccn-datetimepicker-panelDay').show();
break;
case ccn_datetimepicker_tabType.hour:
$('#ccn-datetimepicker-panelHour').show();
ccn_datetimepicker_RefreshSvg(); // immediately trigger once svg resize
break;
case ccn_datetimepicker_tabType.minute:
$('#ccn-datetimepicker-panelMinute').show();
ccn_datetimepicker_RefreshSvg(); // immediately trigger once svg resize
break;
}
}
function ccn_datetimepicker_RefreshDisplay(tab) {
// header should be refreshed entirely
$('#ccn-datetimepicker-datetime-year').text(ccn_datetimepicker_internalDateTime.getFullYear());
$('#ccn-datetimepicker-datetime-month').text(ccn_datetimepicker_internalDateTime.getMonth() + 1);
$('#ccn-datetimepicker-datetime-day').text(ccn_datetimepicker_internalDateTime.getDate());
$('#ccn-datetimepicker-datetime-hour').text(ccn_datetimepicker_internalDateTime.getHours());
$('#ccn-datetimepicker-datetime-minute').text(ccn_datetimepicker_internalDateTime.getMinutes());
// refresh tab according to specific `tab`
switch(tab) {
case ccn_datetimepicker_tabType.year:
var startYear = Math.floor((ccn_datetimepicker_displayCacheDateTime.getFullYear() - ccn_datetime_MIN_YEAR) / 12) * 12 + ccn_datetime_MIN_YEAR;
var counter = startYear;
for(var i = 0; i < 3; i++) {
for(var j = 0; j < 4; j++, counter++) {
var ele = $('#ccn-datetimepiacker-panelYear-table > div:nth-child({0}) > div:nth-child({1})'.format(i + 1, j + 1));
if (counter < ccn_datetime_MAX_YEAR) {
ele.attr('data', counter)
.text(counter);
} else {
ele.attr('data', '')
.html('&nbsp;');
}
if (counter == ccn_datetimepicker_internalDateTime.getFullYear()) ele.attr('picked', 'true');
else ele.attr('picked', 'false');
}
}
$('#ccn-datetimepiacker-panelYear-title')
.text('{0} - {1}'.format(startYear, startYear + 12 < ccn_datetime_MAX_YEAR ? startYear + 12 : ccn_datetime_MAX_YEAR));
break;
case ccn_datetimepicker_tabType.month:
$('#ccn-datetimepiacker-panelMonth-table > div > div').attr('picked', 'false');
if (ccn_datetimepicker_internalDateTime.getFullYear() == ccn_datetimepicker_displayCacheDateTime.getFullYear()) {
var month = ccn_datetimepicker_internalDateTime.getMonth();
$('#ccn-datetimepiacker-panelMonth-table > div:nth-child({0}) > div:nth-child({1})'.format(Math.floor(month / 4) + 1, (month % 4) + 1))
.attr('picked', 'true');
}
$('#ccn-datetimepiacker-panelMonth-title')
.text(ccn_datetimepicker_displayCacheDateTime.getFullYear());
break;
case ccn_datetimepicker_tabType.day:
var gottenYear = ccn_datetimepicker_displayCacheDateTime.getFullYear();
var gottenMonth = ccn_datetimepicker_displayCacheDateTime.getMonth() + 1;
var counter = -ccn_datetime_DayOfWeek(gottenYear, gottenMonth, 1);
var days = ccn_datetime_monthDayCount[gottenMonth - 1] + ((gottenMonth == 2 && ccn_datetime_IsLeapYear(gottenYear)) ? 1 : 0);
for(var i = 0; i < 6; i++) {
for(var j = 0; j < 7; j++, counter++) {
var ele = $('#ccn-datetimepiacker-panelDay-table > div:nth-child({0}) > div:nth-child({1})'.format(i + 2, j + 1));
if (counter < 0 || counter >= days) ele.attr('data', '').html('&nbsp;');
else ele.attr('data', counter + 1).text(counter + 1);
if (counter + 1 == ccn_datetimepicker_internalDateTime.getDate()) ele.attr('picked', 'true');
else ele.attr('picked', 'false');
}
}
$('#ccn-datetimepiacker-panelDay-title')
.text('{0} - {1}'.format(
ccn_datetimepicker_displayCacheDateTime.getFullYear(),
ccn_i18n_UniversalGetMonth(ccn_datetimepicker_displayCacheDateTime.getMonth())
));
break;
case ccn_datetimepicker_tabType.hour:
var gottenHour = ccn_datetimepicker_displayCacheDateTime.getHours();
var newX = Math.cos((3 - gottenHour) * Math.PI * 2 / 12);
var newY = Math.sin((3 - gottenHour) * Math.PI * 2 / 12);
var radius = ccn_datetimepicker_dialPlateRadius * (gottenHour < 12 ? ccn_datetimepicker_dialPlateHourOutterPercent : ccn_datetimepicker_dialPlateHourInnerPercent);
newX = newX * radius + ccn_datetimepicker_dialPlateRadius;
newY = (-newY * radius) + ccn_datetimepicker_dialPlateRadius;
$('#ccn-datetimepicker-panelHour > line')
.attr('x2', newX)
.attr('y2', newY);
$('#ccn-datetimepicker-panelHour > circle[type=symbol]')
.attr('cx', newX)
.attr('cy', newY);
break;
case ccn_datetimepicker_tabType.minute:
var gottenMinute = ccn_datetimepicker_displayCacheDateTime.getMinutes();
var newX = Math.cos((15 - gottenMinute) * Math.PI * 2 / 60);
var newY = Math.sin((15 - gottenMinute) * Math.PI * 2 / 60);
var radius = ccn_datetimepicker_dialPlateRadius * ccn_datetimepicker_dialPlateMinutePercent;
newX = newX * radius + ccn_datetimepicker_dialPlateRadius;
newY = (-newY * radius) + ccn_datetimepicker_dialPlateRadius;
$('#ccn-datetimepicker-panelMinute > line')
.attr('x2', newX)
.attr('y2', newY);
$('#ccn-datetimepicker-panelMinute > circle[type=symbol]')
.attr('cx', newX)
.attr('cy', newY);
break;
}
}
function ccn_datetimepicker_RefreshSvg() {
// svg resize only can be called when the svg is showing.
// so call this func in window resize event or
// displaying svg.
$('div.pickerContainer > svg').each(function() {
ccn_datetimepicker_OnSvgResize($(this));
});
}
function ccn_datetimepicker_Str2TabType(strl) {
switch(strl) {
case 'year':
return ccn_datetimepicker_tabType.year
case 'month':
return ccn_datetimepicker_tabType.month
case 'day':
return ccn_datetimepicker_tabType.day
case 'hour':
return ccn_datetimepicker_tabType.hour
case 'minute':
return ccn_datetimepicker_tabType.minute
}
return undefined;
}
function ccn_datetimepicker_GetUniformedXY(mouseOrTouchEvent, elements) {
var offset = {
left: elements.offset().left,
top: elements.offset().top,
halfWidth: elements.width() / 2,
halfHeight: elements.height() / 2,
halfSquareWidthHeight: Math.min(elements.width(), elements.height()) / 2
}
if(typeof(mouseOrTouchEvent.pageX) != 'undefined' && typeof(mouseOrTouchEvent.pageY) != 'undefined') {
offset.realX = mouseOrTouchEvent.pageX;
offset.realY = mouseOrTouchEvent.pageY;
} else if(typeof(mouseOrTouchEvent.targetTouches) != 'undefined' && mouseOrTouchEvent.targetTouches.length >= 1) {
offset.realX = mouseOrTouchEvent.targetTouches[0].pageX;
offset.realY = mouseOrTouchEvent.targetTouches[0].pageY;
} else {
offset.realX = 0;
offset.realY = 0;
}
var _x = (offset.realX - offset.left - offset.halfWidth) / offset.halfSquareWidthHeight * ccn_datetimepicker_dialPlateRadius;
var _y = -((offset.realY - offset.top - offset.halfHeight) / offset.halfSquareWidthHeight * ccn_datetimepicker_dialPlateRadius);
return {x: _x, y: _y};
}
function ccn_datetimepicker_PrevNextYear(isPrev) {
ccn_datetimepicker_displayCacheDateTime.setFullYear(
ccn_datetimepicker_displayCacheDateTime.getFullYear() + (isPrev ? -12 : 12));
ccn_datetimepicker_ClampDateTime(ccn_datetimepicker_displayCacheDateTime);
ccn_datetimepicker_RefreshDisplay(ccn_datetimepicker_tabType.year);
}
function ccn_datetimepicker_PrevNextMonth(isPrev) {
ccn_datetimepicker_displayCacheDateTime.setFullYear(
ccn_datetimepicker_displayCacheDateTime.getFullYear() + (isPrev ? -1 : 1));
ccn_datetimepicker_ClampDateTime(ccn_datetimepicker_displayCacheDateTime);
ccn_datetimepicker_RefreshDisplay(ccn_datetimepicker_tabType.month);
}
function ccn_datetimepicker_PrevNextDay(isPrev) {
ccn_datetimepicker_displayCacheDateTime.setMonth(
ccn_datetimepicker_displayCacheDateTime.getMonth() + (isPrev ? -1 : 1));
ccn_datetimepicker_ClampDateTime(ccn_datetimepicker_displayCacheDateTime);
ccn_datetimepicker_RefreshDisplay(ccn_datetimepicker_tabType.day);
}
function ccn_datetimepicker_ClickYear() {
var ele = $(this);
if (ele.attr('data') == '') return;
ccn_datetimepicker_internalDateTime.setFullYear(parseInt(ele.attr('data')));
ccn_datetimepicker_ClampDateTime(ccn_datetimepicker_internalDateTime);
if (ccn_datetimepicker_mode != ccn_datetimepicker_tabType.year)
ccn_datetimepicker_SwitchTab(ccn_datetimepicker_tabType.month);
else
ccn_datetimepicker_RefreshDisplay(ccn_datetimepicker_tabType.year);
}
function ccn_datetimepicker_ClickMonth() {
var ele = $(this);
if (ele.attr('data') == '') return;
ccn_datetimepicker_internalDateTime.setFullYear(
ccn_datetimepicker_displayCacheDateTime.getFullYear(),
parseInt(ele.attr('data'))
);
ccn_datetimepicker_ClampDateTime(ccn_datetimepicker_internalDateTime);
if (ccn_datetimepicker_mode != ccn_datetimepicker_tabType.month)
ccn_datetimepicker_SwitchTab(ccn_datetimepicker_tabType.day);
else
ccn_datetimepicker_RefreshDisplay(ccn_datetimepicker_tabType.month);
}
function ccn_datetimepicker_ClickDay() {
var ele = $(this);
if (ele.attr('data') == '') return;
ccn_datetimepicker_internalDateTime.setFullYear(
ccn_datetimepicker_displayCacheDateTime.getFullYear(),
ccn_datetimepicker_displayCacheDateTime.getMonth(),
parseInt(ele.attr('data'))
);
ccn_datetimepicker_ClampDateTime(ccn_datetimepicker_internalDateTime);
if (ccn_datetimepicker_mode != ccn_datetimepicker_tabType.day)
ccn_datetimepicker_SwitchTab(ccn_datetimepicker_tabType.hour);
else
ccn_datetimepicker_RefreshDisplay(ccn_datetimepicker_tabType.day);
}
function ccn_datetimepicker_StartDragHour() { ccn_datetimepicker_enableHourDrag = true; }
function ccn_datetimepicker_DraggingHour(e) {
if (!ccn_datetimepicker_enableHourDrag) return;
var offset = ccn_datetimepicker_GetUniformedXY(e, $('#ccn-datetimepicker-panelHour'));
var x = offset.x;
var y = offset.y;
var distance = Math.sqrt(x * x + y * y);
var angle = Math.acos(x / distance);
if (y < 0) angle = Math.PI * 2 - angle; // correct negative y axis angle
angle += (ccn_datetimepicker_dialPlateHourResolution / 2); // correct offset
if (angle > Math.PI * 2)
angle -= Math.PI * 2;
var number = Math.floor(angle / ccn_datetimepicker_dialPlateHourResolution);
if (number >= 12) number = 11; // prevent unexpected result at the edge.
number = (15 - number) % 12;
if (distance < ccn_datetimepicker_dialPlateRadius * ccn_datetimepicker_dialPlateHourDistinguishPercent)
number += 12;
// judge
if (ccn_datetimepicker_displayCacheDateTime.getHours() != number) {
ccn_datetimepicker_displayCacheDateTime.setHours(number);
ccn_datetimepicker_RefreshDisplay(ccn_datetimepicker_tabType.hour);
}
e.preventDefault();
}
function ccn_datetimepicker_StopDragHour() {
ccn_datetimepicker_enableHourDrag = false;
ccn_datetimepicker_internalDateTime.setHours(ccn_datetimepicker_displayCacheDateTime.getHours());
ccn_datetimepicker_ClampDateTime(ccn_datetimepicker_internalDateTime);
if (ccn_datetimepicker_mode != ccn_datetimepicker_tabType.hour)
ccn_datetimepicker_SwitchTab(ccn_datetimepicker_tabType.minute);
}
function ccn_datetimepicker_StartDragMinute() { ccn_datetimepicker_enableMinuteDrag = true; }
function ccn_datetimepicker_DraggingMinute(e) {
if (!ccn_datetimepicker_enableMinuteDrag) return;
var offset = ccn_datetimepicker_GetUniformedXY(e, $('#ccn-datetimepicker-panelMinute'));
var x = offset.x;
var y = offset.y;
var distance = Math.sqrt(x * x + y * y);
var angle = Math.acos(x / distance);
if (y < 0) angle = Math.PI * 2 - angle; // correct negative y axis angle
angle += (ccn_datetimepicker_dialPlateMinuteResolution / 2); // correct offset
if (angle > Math.PI * 2)
angle -= Math.PI * 2;
var number = Math.floor(angle / ccn_datetimepicker_dialPlateMinuteResolution);
if (number >= 60) number = 59; // prevent unexpected result at the edge.
number = (75 - number) % 60;
// judge
if (ccn_datetimepicker_displayCacheDateTime.getMinutes() != number) {
ccn_datetimepicker_displayCacheDateTime.setMinutes(number);
ccn_datetimepicker_RefreshDisplay(ccn_datetimepicker_tabType.minute);
}
e.preventDefault();
}
function ccn_datetimepicker_StopDragMinute() {
ccn_datetimepicker_enableMinuteDrag = false;
ccn_datetimepicker_internalDateTime.setMinutes(ccn_datetimepicker_displayCacheDateTime.getMinutes());
ccn_datetimepicker_ClampDateTime(ccn_datetimepicker_internalDateTime);
// no page need to go to
// but we need refresh current page
ccn_datetimepicker_RefreshDisplay(ccn_datetimepicker_tabType.minute);
}
function ccn_datetimepicker_ClampDateTime(dateObj) {
if (dateObj < ccn_datetime_MIN_DATETIME)
dateObj.setTime(ccn_datetime_MIN_DATETIME.getTime());
if (dateObj >= ccn_datetime_MAX_DATETIME)
dateObj.setTime(ccn_datetime_MAX_DATETIME.getTime());
}
// ========================================================== universal function
function ccn_datetimepicker_Set(pickerIndex, dt, isUTC, mode) {
var ele = $('[datetimepicker=' + pickerIndex + ']');
while(true) {
if (mode < ccn_datetimepicker_tabType.year) break;
ele.attr('datetimepicker-year', isUTC ? dt.getUTCFullYear() : dt.getFullYear());
if (mode < ccn_datetimepicker_tabType.month) break;
ele.attr('datetimepicker-month', (isUTC ? dt.getUTCMonth() : dt.getMonth()) + 1);
if (mode < ccn_datetimepicker_tabType.day) break;
ele.attr('datetimepicker-day', isUTC ? dt.getUTCDate() : dt.getDate());
if (mode < ccn_datetimepicker_tabType.hour) break;
ele.attr('datetimepicker-hour', isUTC ? dt.getUTCHours() : dt.getHours());
if (mode < ccn_datetimepicker_tabType.minute) break;
ele.attr('datetimepicker-minute', isUTC ? dt.getUTCMinutes() : dt.getMinutes());
break;
}
if (typeof(ele.prop('funcs')) != 'undefined' && typeof(ele.prop('funcs').callback) == 'function')
ele.prop('funcs').callback();
}
function ccn_datetimepicker_Get(pickerIndex, isUTC) {
var ele = $('[datetimepicker=' + pickerIndex + ']');
year = ele.attr('datetimepicker-year');
month = ele.attr('datetimepicker-month');
day = ele.attr('datetimepicker-day');
hour = ele.attr('datetimepicker-hour');
minute = ele.attr('datetimepicker-minute');
if (IsUndefinedOrEmpty(year)) year = ccn_datetime_MIN_YEAR;
if (IsUndefinedOrEmpty(month)) month = 1;
if (IsUndefinedOrEmpty(day)) day = 1;
if (IsUndefinedOrEmpty(hour)) hour = 0;
if (IsUndefinedOrEmpty(minute)) minute = 0;
if (isUTC) return new Date(Date.UTC(year, parseInt(month) - 1, day, hour, minute, 0, 0));
else return new Date(year, parseInt(month) - 1, day, hour, minute, 0, 0);
}
-61
View File
@@ -1,61 +0,0 @@
function ccn_headerNav_Insert() {
$('body').prepend(ccn_template_headerNav.render());
}
function ccn_headerNav_LoggedRefresh() {
if (ccn_api_common_tokenValid()) {
// logged, show all nav button and logout button
$("#ccn-header-nav-home").show();
$("#ccn-header-nav-collection").show();
$("#ccn-header-nav-calendar").show();
$("#ccn-header-nav-todo").show();
$("#ccn-header-nav-admin").show();
$("#ccn-header-user-login").hide();
$("#ccn-header-user-logout").show();
} else {
$("#ccn-header-nav-home").show();
$("#ccn-header-nav-collection").hide();
$("#ccn-header-nav-calendar").hide();
$("#ccn-header-nav-todo").hide();
$("#ccn-header-nav-admin").hide();
$("#ccn-header-user-login").show();
$("#ccn-header-user-logout").hide();
}
}
// bind language process and internal process function such as logout and expand menu
function ccn_headerNav_BindEvents() {
// bind function
$("#ccn-header-language > *").each(function(){
$(this).click(function(){
ccn_i18n_ChangeLanguage($(this).attr("language"));
ccn_i18n_LoadLanguage();
ccn_i18n_ApplyLanguage();
});
});
// bind logout
$("#ccn-header-user-logout").click(function() {
if (ccn_api_common_logout()) {
// ok, logout
// jump into home page again
window.location.href = '/web/home';
return;
} else ccn_messagebox_Show($.i18n.prop("ccn-i18n-js-fail-logout"));
});
// bind burger menu
// copy from bulma website
// Check for click events on the navbar burger icon
$(".navbar-burger").click(function() {
// Toggle the "is-active" class on both the "navbar-burger" and the "navbar-menu"
$(".navbar-burger").toggleClass("is-active");
$(".navbar-menu").toggleClass("is-active");
});
}
-89
View File
@@ -1,89 +0,0 @@
var ccn_i18n_i18nSupported = ['en-US', 'zh-CN'];
var ccn_i18n_currentLanguage = 'en-US';
var ccn_pages_enumPages = {
home : 0,
calendar: 1,
todo: 2,
admin: 3,
login: 4,
collection: 5,
event: 6
};
var ccn_pages_currentPage = ccn_pages_enumPages.home;
// judge current language
ccn_i18n_currentLanguage = ccn_localstorageAssist_Get('ccn-i18n', 'en-US');
if (ccn_i18n_i18nSupported.indexOf(ccn_i18n_currentLanguage) == -1){
ccn_localstorageAssist_Set('ccn-i18n', 'en-US');
ccn_i18n_currentLanguage = 'en-US';
}
function ccn_i18n_ChangeLanguage(newLang) {
if (ccn_i18n_i18nSupported.indexOf(newLang) == -1) return false;
ccn_i18n_currentLanguage = newLang;
ccn_localstorageAssist_Set('ccn-i18n', newLang);
return true;
}
function ccn_i18n_LoadLanguage() {
$.i18n.properties({
name: 'strings_' + ccn_i18n_currentLanguage,
path: '/static/i18n/',
encoding: 'utf-8',
mode: 'map',
async: false,
cache: false,
language: ccn_i18n_currentLanguage
});
}
function ccn_i18n_ApplyLanguage() {
//set usual block
var cache = $("[i18n-name]");
cache.each(function() {
$(this).html($.i18n.prop($(this).attr('i18n-name')));
});
//set unusual block
//set title
switch(ccn_pages_currentPage) {
case ccn_pages_enumPages.home:
$('#ccn-pageName').html($.i18n.prop('ccn-i18n-pageName-home'));
break;
case ccn_pages_enumPages.calendar:
$('#ccn-pageName').html($.i18n.prop('ccn-i18n-pageName-calendar'));
break;
case ccn_pages_enumPages.todo:
$('#ccn-pageName').html($.i18n.prop('ccn-i18n-pageName-todo'));
break;
case ccn_pages_enumPages.admin:
$('#ccn-pageName').html($.i18n.prop('ccn-i18n-pageName-admin'));
break;
case ccn_pages_enumPages.login:
$('#ccn-pageName').html($.i18n.prop('ccn-i18n-pageName-login'));
break;
case ccn_pages_enumPages.collection:
$('#ccn-pageName').html($.i18n.prop('ccn-i18n-pageName-collection'));
break;
case ccn_pages_enumPages.event:
$('#ccn-pageName').html($.i18n.prop('ccn-i18n-pageName-event'));
break;
}
}
function ccn_i18n_ApplyLanguage2Content(ctx) {
ctx.find("[i18n-name]").each(function() {
$(this).html($.i18n.prop($(this).attr('i18n-name')));
});
}
// note: month is zero based
function ccn_i18n_UniversalGetMonth(month) {
return $.i18n.prop('ccn-i18n-universal-month-' + (month + 1));
}
// note: day of week is zero based
function ccn_i18n_UniversalGetDayOfWeek(dayOfWeek) {
return $.i18n.prop('ccn-i18n-universal-week-' + (dayOfWeek + 1));
}
@@ -1,21 +0,0 @@
function ccn_localstorageAssist_Get(index, defaultValue) {
var cache = localStorage.getItem(index);
if (cache == null) {
ccn_localstorageAssist_Set(index, defaultValue);
return defaultValue;
} else return cache;
}
function ccn_localstorageAssist_Set(index, value) {
localStorage.setItem(index, value);
}
// =================================== seperated data getter setter
function ccn_localstorageAssist_GetApiToken() {
return ccn_localstorageAssist_Get('ccn-token', '');
}
function ccn_localstorageAssist_SetApiToken(value) {
ccn_localstorageAssist_Set('ccn-token', value);
}
-19
View File
@@ -1,19 +0,0 @@
function ccn_messagebox_Insert() {
$('body').append(ccn_template_messagebox.render());
}
function ccn_messagebox_Show(/*title,*/ info) {
//$('#ccn-messagebox-title').text(title);
$('#ccn-messagebox-body').text(info);
$('#ccn-messagebox-modal').addClass('is-active');
}
function ccn_messagebox_BindEvent() {
$('#ccn-messagebox-btnClose').click(ccn_messagebox_Hide);
$('#ccn-messagebox-btnConfirm').click(ccn_messagebox_Hide);
}
function ccn_messagebox_Hide() {
$('#ccn-messagebox-modal').removeClass('is-active');
}
-265
View File
@@ -1,265 +0,0 @@
var ccn_admin_userListCache = [];
var ccn_admin_tokenListCache = [];
$(document).ready(function() {
ccn_pages_currentPage = ccn_pages_enumPages.admin;
// template process
ccn_template_Load();
// nav process
ccn_headerNav_Insert();
ccn_headerNav_BindEvents();
ccn_headerNav_LoggedRefresh();
// messagebox process
ccn_messagebox_Insert();
ccn_messagebox_BindEvent();
// bind tab control switcher and set current tab
$("#tabcontrol-tab-1-1").click(function(){
ccn_tabcontrol_SwitchTab(1, 1);
});
$("#tabcontrol-tab-1-2").click(function(){
ccn_tabcontrol_SwitchTab(1, 2);
});
$("#tabcontrol-tab-1-3").click(function(){
ccn_tabcontrol_SwitchTab(1, 3);
});
ccn_tabcontrol_SwitchTab(1, 1);
// load user tab according to admin status
if(!ccn_api_profile_isAdmin())
$('#tabcontrol-tab-1-3').hide();
// apply i18n
ccn_i18n_LoadLanguage();
ccn_i18n_ApplyLanguage();
// bind event
$('#ccn-admin-profile-btnChangePassword').click(ccn_admin_profile_ChangePassword);
$('#ccn-admin-tokenList-btnRefresh').click(ccn_admin_tokenList_Refresh);
$('#ccn-admin-userList-btnAdd').click(ccn_admin_userList_Add);
$('#ccn-admin-userList-btnRefresh').click(ccn_admin_userList_Refresh);
});
// ================== profile
function ccn_admin_profile_ChangePassword() {
var newpassword = $('#ccn-admin-profile-inputPassword').val();
if (newpassword == "") return;
var result = ccn_api_profile_changePassword(newpassword);
if(result) {
ccn_messagebox_Show($.i18n.prop("ccn-i18n-js-success"));
$('#ccn-admin-profile-inputPassword').val('');
} else
ccn_messagebox_Show($.i18n.prop("ccn-i18n-js-fail-update"));
}
// ================== token
function ccn_admin_tokenList_Refresh() {
ccn_admin_tokenListCache = new Array();
var listDOM = $('#ccn-admin-tokenList');
listDOM.empty();
var renderdata = {
uuid: undefined,
isMe: undefined,
ua: undefined,
ip: undefined,
expireOn: undefined
}
var gottenDateTime = new Date();
var result = ccn_api_profile_getToken();
if(typeof(result) != 'undefined') {
for(var index in result) {
var item = result[index];
renderdata.uuid = item[1];
renderdata.isMe = ccn_localstorageAssist_GetApiToken() == item[1];
renderdata.ua = item[3];
renderdata.ip = item[4];
gottenDateTime.setTime(item[2] * 1000);
renderdata.expireOn = gottenDateTime.toLocaleString();
listDOM.append(ccn_template_tokenItem.render(renderdata));
// bind event
var uuid = renderdata.uuid;
$("#ccn-tokenItem-btnLogout-" + uuid).click(ccn_admin_tokenList_ItemDelete);
// add into cache
ccn_admin_tokenListCache[uuid] = item;
}
ccn_i18n_ApplyLanguage2Content(listDOM);
}
}
function ccn_admin_tokenList_ItemDelete() {
var uuid = $(this).attr("uuid");
var result = ccn_api_profile_deleteToken(uuid);
if(!result) {
// fail
ccn_messagebox_Show($.i18n.prop("ccn-i18n-js-fail-delete"));
} else {
// remove body
$("#ccn-tokenItem-" + uuid).remove();
}
}
// ================== user list
function ccn_admin_userList_RefreshCacheList() {
ccn_admin_userListCache = new Array();
var result = ccn_api_admin_get();
if(typeof(result) != 'undefined') {
for(var index in result) {
ccn_admin_userListCache[index] = result[index];
}
}
}
function ccn_admin_userList_RenderItem(item, index, listDOM) {
var renderdata = {
uuid: index, // use index for uuid. there are no uuid for user
username: item[0]
}
// render
listDOM.append(ccn_template_userItem.render(renderdata));
// set mode
var uuid = index;
ccn_admin_userList_ChangeDisplayMode(uuid, false, item[1])
// bind event
$("#ccn-userItem-btnEdit-" + uuid).click(ccn_admin_userList_ItemEdit);
$("#ccn-userItem-btnDelete-" + uuid).click(ccn_admin_userList_ItemDelete);
$("#ccn-userItem-btnUpdate-" + uuid).click(ccn_admin_userList_ItemUpdate);
$("#ccn-userItem-btnCancelUpdate-" + uuid).click(ccn_admin_userList_ItemCancelUpdate);
}
function ccn_admin_userList_RenderCacheList() {
$('#ccn-admin-userList').empty();
var listDOM = $('#ccn-admin-userList');
for(var index in ccn_admin_userListCache) {
ccn_admin_userList_RenderItem(
ccn_admin_userListCache[index],
index,
listDOM
)
}
ccn_i18n_ApplyLanguage2Content(listDOM);
}
function ccn_admin_userList_ChangeDisplayMode(uuid, isEdit, isAdmin) {
if (typeof(isAdmin) != 'undefined') {
if (isAdmin)
$("#ccn-userItem-iconIsAdmin-" + uuid).show();
else
$("#ccn-userItem-iconIsAdmin-" + uuid).hide();
}
if (typeof(isEdit) != 'undefined') {
if (isEdit) {
$("#ccn-userItem-btnEdit-" + uuid).hide();
$("#ccn-userItem-btnDelete-" + uuid).hide();
$("#ccn-userItem-btnUpdate-" + uuid).show();
$("#ccn-userItem-btnCancelUpdate-" + uuid).show();
$("#ccn-userItem-boxPassword-" + uuid).show();
$("#ccn-userItem-boxIsAdmin-" + uuid).show();
} else {
$("#ccn-userItem-btnEdit-" + uuid).show();
$("#ccn-userItem-btnDelete-" + uuid).show();
$("#ccn-userItem-btnUpdate-" + uuid).hide();
$("#ccn-userItem-btnCancelUpdate-" + uuid).hide();
$("#ccn-userItem-boxPassword-" + uuid).hide();
$("#ccn-userItem-boxIsAdmin-" + uuid).hide();
}
}
}
function ccn_admin_userList_Refresh() {
// refresh and render once
ccn_admin_userList_RefreshCacheList();
ccn_admin_userList_RenderCacheList();
}
function ccn_admin_userList_Add() {
var username = $('#ccn-admin-userList-inputUsername').val();
if (username == "") return;
var result = ccn_api_admin_add(username);
if (typeof(result) == 'undefined') {
ccn_messagebox_Show($.i18n.prop("ccn-i18n-js-fail-add"));
} else {
// render
var index = ccn_admin_userListCache.push(result) - 1;
var listDOM = $('#ccn-admin-userList');
ccn_admin_userList_RenderItem(result, index, listDOM);
ccn_i18n_ApplyLanguage2Content(listDOM);
}
}
function ccn_admin_userList_ItemEdit() {
var uuid = $(this).attr("uuid");
// copy isAdmin to checkbox and clean password box
$('#ccn-userItem-inputIsAdmin-' + uuid).prop("checked", ccn_admin_userListCache[uuid][1]);
$('#ccn-userItem-inputPassword-' + uuid).val('');
// switch to edit mode
ccn_admin_userList_ChangeDisplayMode(uuid, true, undefined);
}
function ccn_admin_userList_ItemDelete() {
var uuid = $(this).attr("uuid");
var result = ccn_api_admin_delete(ccn_admin_userListCache[uuid][0]);
if(!result) {
// fail
ccn_messagebox_Show($.i18n.prop("ccn-i18n-js-fail-delete"));
} else {
// remove body
$("#ccn-userItem-" + uuid).remove();
}
}
function ccn_admin_userList_ItemUpdate() {
var uuid = $(this).attr("uuid");
var newpassword = $('#ccn-userItem-inputPassword-' + uuid).val();
var isAdmin = $('#ccn-userItem-inputIsAdmin-' + uuid).prop("checked");
var result = ccn_api_admin_update(
ccn_admin_userListCache[uuid][0],
newpassword == "" ? undefined : newpassword,
isAdmin == ccn_admin_userListCache[uuid][1] ? undefined : isAdmin);
if (!result) {
// fail
ccn_messagebox_Show($.i18n.prop("ccn-i18n-js-fail-update"));
} else {
// safely update data
ccn_admin_userListCache[uuid][1] = isAdmin
// switch to normal mode
ccn_admin_userList_ChangeDisplayMode(uuid, false, isAdmin);
}
}
function ccn_admin_userList_ItemCancelUpdate() {
var uuid = $(this).attr("uuid");
ccn_admin_userList_ChangeDisplayMode(uuid, false, undefined);
}
-376
View File
@@ -1,376 +0,0 @@
// 2 list which will store sharing and shared collection's display mode.
// key is uuid, value is bool
var ccn_calendar_owned_displayCache = [];
var ccn_calendar_shared_displayCache = [];
// modal editing object.
// undefined mean add
// not undefined mean update(a copy of calendar event)
var ccn_calendar_eventModal_editing = undefined;
var ccn_calendar_eventModal_collectionCache = [];
var ccn_calendar_calendar_listCache = [];
var ccn_calendar_calendar_displayCache = [];
var ccn_calendar_calendar_displayDateTime = 0;
$(document).ready(function() {
ccn_pages_currentPage = ccn_pages_enumPages.calendar;
// template process
ccn_template_Load();
// nav process
ccn_headerNav_Insert();
ccn_headerNav_BindEvents();
ccn_headerNav_LoggedRefresh();
// messagebox process
ccn_messagebox_Insert();
ccn_messagebox_BindEvent();
// process calendar it self
ccn_calendar_calendar_LoadCalendarBody();
// init datetimepicker and preset
ccn_datetimepicker_Insert();
var nowtime = new Date();
ccn_datetimepicker_Set(1, nowtime, false, ccn_datetimepicker_tabType.month);
// bind tab control switcher and set current tab
$("#tabcontrol-tab-1-1").click(function(){
ccn_tabcontrol_SwitchTab(1, 1);
});
$("#tabcontrol-tab-1-2").click(function(){
ccn_tabcontrol_SwitchTab(1, 2);
});
$("#tabcontrol-tab-1-3").click(function(){
ccn_tabcontrol_SwitchTab(1, 3);
});
ccn_tabcontrol_SwitchTab(1, 1);
// apply i18n
ccn_i18n_LoadLanguage();
ccn_i18n_ApplyLanguage();
//refresh once
ccn_calendar_collection_Refresh();
ccn_calendar_calendar_Refresh();
ccn_calendar_calendar_Analyse();
ccn_calendar_calendar_Render();
// bind event
$('#ccn-calendar-collection-btnRefresh').click(ccn_calendar_collection_Refresh);
$('#ccn-calendar-calendar-btnJump')
.prop('funcs', {callback: ccn_calendar_calendar_btnRefresh})
.click(function() {
ccn_datetimepicker_Modal(
ccn_datetimepicker_tabType.month,
1,
false);
});
$('#ccn-calendar-calendar-btnToday').click(ccn_calendar_calendar_btnToday);
$('#ccn-calendar-calendar-btnAdd').click(ccn_calendar_calendar_btnAdd);
});
// ================== calendar
function ccn_calendar_calendar_LoadCalendarBody() {
$('#ccn-calendar-calendarBody').append(ccn_template_calendarItem.render());
}
// this function only refresh cache list
function ccn_calendar_calendar_Refresh() {
var gottenDateTime = ccn_datetimepicker_Get(1, false);
var gottenYear = gottenDateTime.getFullYear();
var gottenMonth = gottenDateTime.getMonth() + 1;
$('#ccn-calendar-calendar-textMonth').text('{0} - {1}'.format(gottenYear, ccn_i18n_UniversalGetMonth(gottenMonth - 1)));
// don't need to set anything, because its default value is enough to use.
var gottenWeek = ccn_datetime_DayOfWeek(gottenYear, gottenMonth, 1);
var startTimestamp = Math.floor(gottenDateTime.getTime() / 60000) - gottenWeek * ccn_datetime_DAY1_SPAN;
var endTimestamp = startTimestamp + ccn_datetime_DAY1_SPAN * 6 * 7 - 1;
ccn_calendar_calendar_listCache = new Array();
var result = ccn_api_calendar_getFull(startTimestamp, endTimestamp);
if (typeof(result) != 'undefined') {
for(var index in result) {
ccn_calendar_calendar_listCache[result[index][0]] = result[index];
}
}
}
// this function take responsibility to analyse event
// call datetime function to resolve loop event
// and split event if some event cross 2+ days
function ccn_calendar_calendar_Analyse() {
// first, we need construct ccn_calendar_calendar_displayCache
ccn_calendar_calendar_displayCache = new Array();
var gottenDateTime = ccn_datetimepicker_Get(1, false);
var gottenYear = gottenDateTime.getFullYear();
var gottenMonth = gottenDateTime.getMonth() + 1;
var gottenWeek = ccn_datetime_DayOfWeek(gottenYear, gottenMonth, 1);
var startTimestamp = Math.floor(gottenDateTime.getTime() / 60000) - gottenWeek * ccn_datetime_DAY1_SPAN;
var endTimestamp = startTimestamp + ccn_datetime_DAY1_SPAN * 6 * 7 - 1;
gottenDateTime.setTime(startTimestamp * 60000);
for(var index = 0; index < 6 * 7; index++) {
ccn_calendar_calendar_displayCache.push({
month: gottenDateTime.getMonth() + 1,
day: gottenDateTime.getDate(),
dayOfWeek: gottenDateTime.getWeekday() + 1,
subcalendar: "",
isCurrentMonth: (gottenDateTime.getMonth() + 1) == gottenMonth,
events: new Array()
});
gottenDateTime.setTime(gottenDateTime.getTime() + ccn_datetime_DAY1_SPAN * 60000);
}
var mytimezone = -(new Date().getTimezoneOffset());
// then analyse each event
for(var index in ccn_calendar_calendar_listCache) {
var item = ccn_calendar_calendar_listCache[index];
var deserializedDescription = ccn_api_deserializeDescription(item[3]);
var minStartTimestamp = startTimestamp - (item[6] - item[5]);
var result = ccn_datetime_ResolveLoopRules4Event(
item[8],
item[9] < minStartTimestamp ? minStartTimestamp : item[9],
Math.min(item[10], endTimestamp),
item[5],
item[6],
item[7],
startTimestamp
);
if(typeof(result) != 'undefined') {
for(var i in result) {
var it = result[i];
// try get event belong to which cell
var eventDateTime = new Date(it[0] * 60000);
var count = Math.floor((it[0] - startTimestamp) / ccn_datetime_DAY1_SPAN);
var exitFlag = false;
// then split event
while(count < 6 * 7) {
var eventItem = {
uuid: item[0],
belongTo: item[1],
title: item[2],
description: deserializedDescription.description,
color: deserializedDescription.color,
isVisible: true,
isLocked: typeof(ccn_calendar_owned_displayCache[item[0]]) != 'undefined',
loopText: ccn_datetime_ResolveLoopRules4Text(item[8], item[5], item[7]),
timezoneWarning: mytimezone != item[7],
start: eventDateTime.toLocaleTimeString(),
end: undefined // filled in follwing code
}
eventDateTime.setHours(23, 59, 0, 0);
if (it[1] <= Math.floor(eventDateTime.getTime() / 60000)) {
exitFlag = true;
eventDateTime.setTime(it[1] * 60000);
}
eventItem.end = eventDateTime.toLocaleTimeString();
ccn_calendar_calendar_displayCache[count].events.push(eventItem);
if (exitFlag) break;
else eventDateTime.setMinutes(eventDateTime.getMinutes() + 1, 0, 0);
count++;
}
}
}
}
}
// just use produced ccn_calendar_calendar_displayCache
// to re-generate ui
function ccn_calendar_calendar_Render() {
// todo: add / migrate subcalendar feature here
// analyse visible data
for(var i in ccn_calendar_calendar_displayCache) {
for(var j in ccn_calendar_calendar_displayCache[i].events) {
var gottenOwnedVisible = ccn_calendar_owned_displayCache[
ccn_calendar_calendar_displayCache[i].events[j].belongTo
];
if (typeof(gottenOwnedVisible) == 'undefined') gottenOwnedVisible = false;
var gottenSharedVisible = ccn_calendar_shared_displayCache[
ccn_calendar_calendar_displayCache[i].events[j].belongTo
];
if (typeof(gottenSharedVisible) == 'undefined') gottenSharedVisible = false;
ccn_calendar_calendar_displayCache[i].events[j].isVisible = gottenOwnedVisible || gottenSharedVisible;
}
}
// just render them
var listDOM = $('#ccn-calendar-scheduleList');
listDOM.empty();
listDOM.append(ccn_template_scheduleItem.render({renderdata: ccn_calendar_calendar_displayCache}));
// link click event
$('div.schedule-event-outter').click(ccn_calendar_calendar_ItemUpdate);
// all data has been alanysed, feedback to calendar body.
var counter = 0;
for(var i = 0; i < 6; i++) {
for(var j = 0; j < 7; j++) {
var item = ccn_calendar_calendar_displayCache[counter];
var lenEvents = item.events.length;
var eventsCounter = 0;
$('#ccn-calendarItem-' + i + '-' + j).attr('isCurrentMonth', item.isCurrentMonth ? 'true' : 'false');
$('#ccn-calendarItem-title-' + i + '-' + j).text(item.day);
$('#ccn-calendarItem-desc-' + i + '-' + j).text(item.subcalendar);
for(; eventsCounter < Math.min(lenEvents, 4); eventsCounter++) {
$('#ccn-calendarItem-eventBox' + (eventsCounter + 1) + '-' + i + '-' + j)
.css('background', item.events[eventsCounter].color)
.attr('enableDisplay', 'true');
}
if (lenEvents > 4) {
// more than 4 item, write number
$('#ccn-calendarItem-task-' + i + '-' + j).text(
$.i18n.prop('ccn-i18n-calendar-calendar-stripedEvents').format(lenEvents.toString())
);
} else {
// otherwise, wipe out number
$('#ccn-calendarItem-task-' + i + '-' + j).html('&nbsp;');
// set others div are blank
for(; eventsCounter < 4; eventsCounter++) {
$('#ccn-calendarItem-eventBox' + (eventsCounter + 1) + '-' + i + '-' + j)
.attr('enableDisplay', 'false');
}
}
counter++;
}
}
ccn_i18n_ApplyLanguage2Content(listDOM);
}
function ccn_calendar_calendar_btnRefresh() {
ccn_calendar_calendar_Refresh();
ccn_calendar_calendar_Analyse();
ccn_calendar_calendar_Render();
}
function ccn_calendar_calendar_btnToday() {
var nowtime = new Date();
ccn_datetimepicker_Set(1, nowtime, false, ccn_datetimepicker_tabType.month);
ccn_calendar_calendar_Refresh();
ccn_calendar_calendar_Analyse();
ccn_calendar_calendar_Render();
}
function ccn_calendar_calendar_btnAdd() {
window.location.href = '/web/eventAdd';
}
function ccn_calendar_calendar_ItemUpdate() {
var uuid = $(this).attr("uuid");
window.location.href = '/web/eventUpdate/' + uuid;
}
// ============================= collection
function ccn_calendar_collection_Refresh() {
ccn_calendar_owned_displayCache = new Array();
ccn_calendar_shared_displayCache = new Array();
// render shared
var result = ccn_api_collection_getShared();
var listDOM = $('#ccn-calendar-sharedList');
listDOM.empty();
var renderdata = {
uuid: undefined,
name: undefined,
username: undefined
}
if (typeof(result) != 'undefined') {
for(var index in result) {
var item = result[index];
renderdata.uuid = item[0];
renderdata.name = item[1];
renderdata.username = item[2];
listDOM.append(ccn_template_displaySharedItem.render(renderdata));
// change display
var uuid = renderdata.uuid;
ccn_calendar_shared_ChangeDisplayMode(uuid, true);
// push into display list
ccn_calendar_shared_displayCache[uuid] = true;
// bind event
$('#ccn-displaySharedItem-btnHide-' + uuid).click(ccn_calendar_shared_ItemSwitchDisplay);
$('#ccn-displaySharedItem-btnShow-' + uuid).click(ccn_calendar_shared_ItemSwitchDisplay);
}
}
ccn_i18n_ApplyLanguage2Content(listDOM);
// render owned
result = ccn_api_collection_getFullOwn();
listDOM = $('#ccn-calendar-ownedList');
listDOM.empty();
renderdata = {
uuid: undefined,
name: undefined
}
if (typeof(result) != 'undefined') {
for(var index in result) {
var item = result[index];
renderdata.uuid = item[0];
renderdata.name = item[1];
// render
listDOM.append(ccn_template_displayOwnedItem.render(renderdata));
// set mode
var uuid = renderdata.uuid;
ccn_calendar_owned_ChangeDisplayMode(uuid, true);
// push into display list
ccn_calendar_owned_displayCache[uuid] = true;
// bind event
$('#ccn-displayOwnedItem-btnHide-' + uuid).click(ccn_calendar_owned_ItemSwitchDisplay);
$('#ccn-displayOwnedItem-btnShow-' + uuid).click(ccn_calendar_owned_ItemSwitchDisplay);
}
}
}
function ccn_calendar_owned_ItemSwitchDisplay() {
var uuid = $(this).attr("uuid");
ccn_calendar_owned_displayCache[uuid] = !(ccn_calendar_owned_displayCache[uuid]);
ccn_calendar_owned_ChangeDisplayMode(uuid, ccn_calendar_owned_displayCache[uuid]);
}
function ccn_calendar_shared_ItemSwitchDisplay() {
var uuid = $(this).attr("uuid");
ccn_calendar_shared_displayCache[uuid] = !(ccn_calendar_shared_displayCache[uuid]);
ccn_calendar_shared_ChangeDisplayMode(uuid, ccn_calendar_shared_displayCache[uuid]);
}
function ccn_calendar_shared_ChangeDisplayMode(uuid, isShow) {
if (isShow) {
$('#ccn-displaySharedItem-btnHide-' + uuid).show();
$('#ccn-displaySharedItem-btnShow-' + uuid).hide();
} else {
$('#ccn-displaySharedItem-btnHide-' + uuid).hide();
$('#ccn-displaySharedItem-btnShow-' + uuid).show();
}
}
function ccn_calendar_owned_ChangeDisplayMode(uuid, isShow) {
if (isShow) {
$('#ccn-displayOwnedItem-btnHide-' + uuid).show();
$('#ccn-displayOwnedItem-btnShow-' + uuid).hide();
} else {
$('#ccn-displayOwnedItem-btnHide-' + uuid).hide();
$('#ccn-displayOwnedItem-btnShow-' + uuid).show();
}
}
@@ -1,288 +0,0 @@
// 3 used cache list
var ccn_collection_owned_listCache = [];
var ccn_collection_sharing_listCache = [];
// current editing sharing collection
var ccn_collection_sharing_editingOwned = undefined; // the uuid of owned collection
$(document).ready(function() {
ccn_pages_currentPage = ccn_pages_enumPages.collection;
// template process
ccn_template_Load();
// nav process
ccn_headerNav_Insert();
ccn_headerNav_BindEvents();
ccn_headerNav_LoggedRefresh();
// messagebox process
ccn_messagebox_Insert();
ccn_messagebox_BindEvent();
// apply i18n
ccn_i18n_LoadLanguage();
ccn_i18n_ApplyLanguage();
//refresh once
ccn_collection_owned_Refresh();
// bind event
//$('#ccn-calendar-shared-btnRefresh').click(ccn_calendar_shared_Refresh);
$('#ccn-collection-owned-btnAdd').click(ccn_collection_owned_Add);
$('#ccn-collection-owned-btnRefresh').click(ccn_collection_owned_Refresh);
$('#ccn-collection-sharing-btnAdd').click(ccn_collection_sharing_Add);
$('#ccn-collection-sharing-btnRefresh').click(ccn_collection_sharing_Refresh);
});
function ccn_collection_owned_Refresh() {
ccn_collection_owned_listCache = new Array();
ccn_collection_sharing_displayCache = new Array();
var result = ccn_api_collection_getFullOwn();
if(typeof(result) != 'undefined') {
for(var index in result) {
ccn_collection_owned_listCache[result[index][0]] = result[index];
}
}
// render
var listDOM = $('#ccn-collection-ownedList');
listDOM.empty();
for(var index in ccn_collection_owned_listCache) {
ccn_collection_owned_RenderItem(
ccn_collection_owned_listCache[index],
listDOM
);
}
// also, order sharing list clean
ccn_collection_sharing_editingOwned = undefined;
ccn_collection_sharing_Refresh();
}
function ccn_collection_owned_RenderItem(item, listDOM) {
var renderdata = {
uuid: item[0],
name: item[1]
}
// render
listDOM.append(ccn_template_ownedItem.render(renderdata));
// set mode
var uuid = renderdata.uuid;
ccn_collection_owned_ChangeDisplayMode(uuid, false);
// bind event
$('#ccn-ownedItem-btnEdit-' + uuid).click(ccn_collection_owned_ItemEdit);
$('#ccn-ownedItem-btnDelete-' + uuid).click(ccn_collection_owned_ItemDelete);
$('#ccn-ownedItem-btnShare-' + uuid).click(ccn_collection_owned_ItemShare);
$('#ccn-ownedItem-btnUpdate-' + uuid).click(ccn_collection_owned_ItemUpdate);
$('#ccn-ownedItem-btnCancelUpdate-' + uuid).click(ccn_collection_owned_ItemCancelUpdate);
}
function ccn_collection_owned_ChangeDisplayMode(uuid, isEdit) {
if (isEdit) {
$('#ccn-ownedItem-btnEdit-' + uuid).hide();
$('#ccn-ownedItem-btnShare-' + uuid).hide();
$('#ccn-ownedItem-btnDelete-' + uuid).hide();
$('#ccn-ownedItem-btnUpdate-' + uuid).show();
$('#ccn-ownedItem-btnCancelUpdate-' + uuid).show();
$('#ccn-ownedItem-textName-' + uuid).hide();
$('#ccn-ownedItem-boxName-' + uuid).show();
} else {
$('#ccn-ownedItem-btnEdit-' + uuid).show();
$('#ccn-ownedItem-btnShare-' + uuid).show();
$('#ccn-ownedItem-btnDelete-' + uuid).show();
$('#ccn-ownedItem-btnUpdate-' + uuid).hide();
$('#ccn-ownedItem-btnCancelUpdate-' + uuid).hide();
$('#ccn-ownedItem-textName-' + uuid).show();
$('#ccn-ownedItem-boxName-' + uuid).hide();
}
}
function ccn_collection_sharing_Refresh() {
ccn_collection_sharing_listCache = new Array();
if (typeof(ccn_collection_sharing_editingOwned) != 'undefined') {
var result = ccn_api_collection_getSharing(ccn_collection_sharing_editingOwned);
if (typeof(result) != 'undefined') {
for(var index in result) {
ccn_collection_sharing_listCache[index] = result[index];
// also, sharingTarget don't have uuid, use index instead
}
}
}
// update editing text
$('#ccn-collection-sharing-sharingEditing').text(
typeof(ccn_collection_sharing_editingOwned) == 'undefined' ?
'' :
ccn_collection_owned_listCache[ccn_collection_sharing_editingOwned][1]
);
// if editing is undefined, hide container
if (typeof(ccn_collection_sharing_editingOwned) == 'undefined')
$('#ccn-collection-sharing-container').hide();
else
$('#ccn-collection-sharing-container').show();
var listDOM = $('#ccn-collection-sharingList');
listDOM.empty();
for(var index in ccn_collection_sharing_listCache) {
ccn_collection_sharing_RenderItem(
ccn_collection_sharing_listCache[index],
index,
listDOM
)
}
}
function ccn_collection_sharing_RenderItem(item, index, listDOM) {
var renderdata = {
uuid: index,
username: item
}
// render
listDOM.append(ccn_template_sharingItem.render(renderdata));
// bind event
var uuid = index;
$("#ccn-sharingItem-btnDelete-" + uuid).click(ccn_collection_sharing_ItemDelete);
}
// ========================= input operation
function ccn_collection_owned_Add() {
var newname = $('#ccn-collection-owned-inputAdd').val();
if (newname == "") return;
var result = ccn_api_collection_addOwn(newname);
if (typeof(result) == 'undefined') ccn_messagebox_Show($.i18n.prop("ccn-i18n-js-fail-add"));
else {
// second get. get detail
result = ccn_api_collection_getDetailOwn(result);
if (typeof(result) == 'undefined') ccn_messagebox_Show($.i18n.prop("ccn-i18n-js-fail-get"));
else {
// render
ccn_collection_owned_listCache[result[0]] = result;
var listDOM = $('#ccn-collection-ownedList');
ccn_collection_owned_RenderItem(result, listDOM);
}
}
}
function ccn_collection_owned_ItemEdit() {
var uuid = $(this).attr("uuid");
// preset inputbox
$('#ccn-ownedItem-inputName-' + uuid).val(
ccn_collection_owned_listCache[uuid][1]
);
// switch to edit mode
ccn_collection_owned_ChangeDisplayMode(uuid, true);
}
function ccn_collection_owned_ItemDelete() {
var uuid = $(this).attr("uuid");
var result = ccn_api_collection_deleteOwn(
uuid,
ccn_collection_owned_listCache[uuid][2]
);
if (!result) ccn_messagebox_Show($.i18n.prop("ccn-i18n-js-fail-delete"));
else {
$('#ccn-ownedItem-' + uuid).remove();
// also, we should notice sharing target, and try clean it
if (ccn_collection_sharing_editingOwned == uuid) {
ccn_collection_sharing_editingOwned = undefined;
ccn_collection_sharing_Refresh();
}
}
}
function ccn_collection_owned_ItemUpdate() {
var uuid = $(this).attr("uuid");
var newname = $('#ccn-ownedItem-inputName-' + uuid).val();
var result = ccn_api_collection_updateOwn(uuid, newname, ccn_collection_owned_listCache[uuid][2]);
if (typeof(result) == 'undefined') ccn_messagebox_Show($.i18n.prop("ccn-i18n-js-fail-update"));
else {
// update last change
ccn_collection_owned_listCache[uuid][2] = result;
ccn_collection_owned_listCache[uuid][1] = newname;
// update elements
$('#ccn-ownedItem-textName-' + uuid).text(newname);
// if editing, update sharing target
if (ccn_collection_sharing_editingOwned == uuid)
ccn_collection_sharing_Refresh();
// back to normal mode
ccn_collection_owned_ChangeDisplayMode(uuid, false);
}
}
function ccn_collection_owned_ItemCancelUpdate() {
var uuid = $(this).attr("uuid");
ccn_collection_owned_ChangeDisplayMode(uuid, false);
}
function ccn_collection_owned_ItemShare() {
var uuid = $(this).attr("uuid");
ccn_collection_sharing_editingOwned = uuid;
ccn_collection_sharing_Refresh();
}
function ccn_collection_sharing_Add() {
var newusername = $('#ccn-collection-sharing-inputAdd').val();
if (newusername == "" || typeof(ccn_collection_sharing_editingOwned) == 'undefined') return;
var result = ccn_api_collection_addSharing(
ccn_collection_sharing_editingOwned,
newusername,
ccn_collection_owned_listCache[ccn_collection_sharing_editingOwned][2]
);
if (typeof(result) == 'undefined') ccn_messagebox_Show($.i18n.prop("ccn-i18n-js-fail-add"));
else {
// add new item
var index = ccn_collection_sharing_listCache.push(newusername) - 1;
var listDOM = $('#ccn-collection-sharingList');
ccn_collection_sharing_RenderItem(newusername, index, listDOM);
// update last change
ccn_collection_owned_listCache[ccn_collection_sharing_editingOwned][2] = result;
}
}
function ccn_collection_sharing_ItemDelete() {
var uuid = $(this).attr("uuid");
var username = ccn_collection_sharing_listCache[uuid];
var result = ccn_api_collection_deleteSharing(
ccn_collection_sharing_editingOwned,
username,
ccn_collection_owned_listCache[ccn_collection_sharing_editingOwned][2]
);
if (typeof(result) == 'undefined') ccn_messagebox_Show($.i18n.prop("ccn-i18n-js-fail-delete"));
else {
// remove item in ui
$('#ccn-sharingItem-' + uuid).remove();
// update last change
ccn_collection_owned_listCache[ccn_collection_sharing_editingOwned][2] = result;
}
}
-447
View File
@@ -1,447 +0,0 @@
// if it is undefined, current mode is add
// or it is the detail data gotten from api
var ccn_event_editingEvent = undefined;
var ccn_event_collectionCache = [];
$(document).ready(function() {
ccn_pages_currentPage = ccn_pages_enumPages.event;
// template process
ccn_template_Load();
// nav process
ccn_headerNav_Insert();
ccn_headerNav_BindEvents();
ccn_headerNav_LoggedRefresh();
// messagebox process
ccn_messagebox_Insert();
ccn_messagebox_BindEvent();
// init datetimepicker
ccn_datetimepicker_Insert();
// apply i18n
ccn_i18n_LoadLanguage();
ccn_i18n_ApplyLanguage();
// bind event
$('input[type=radio][name=loop-method]').click(ccn_event_RefreshRadioDiaplay);
$('input[type=radio][name=loop-end]').click(ccn_event_RefreshRadioDiaplay);
$('#ccn-event-btnSubmit').click(ccn_event_btnSubmit);
$('#ccn-event-btnCancel').click(ccn_event_btnCancel);
$('#ccn-event-btnSpot').click(ccn_event_btnSpot);
$('#ccn-event-btnFullDay').click(ccn_event_btnFullDay);
$('#ccn-event-btnStartDateTime')
.prop('funcs', {callback: function() {
ccn_event_UpdateDateTimePickerButton(1);
ccn_event_RefreshLoopMonthType();
}})
.click(ccn_event_btnDateTimePicker);
$('#ccn-event-btnEndDateTime')
.prop('funcs', {callback: function() {ccn_event_UpdateDateTimePickerButton(2);}})
.click(ccn_event_btnDateTimePicker);
$('#ccn-event-btnLoopStopDateTime')
.prop('funcs', {callback: function() {ccn_event_UpdateDateTimePickerButton(3);}})
.click(ccn_event_btnDateTimePicker);
// init form
ccn_event_Init();
// refresh once
ccn_event_RefreshRadioDiaplay();
ccn_event_RefreshLoopMonthType();
});
function ccn_event_Init() {
// we need init some elements first
// we need all radio and checkbox's checked is false, not undefined.
$('input[type=radio]').prop("checked", false);
$('input[type=checkbox]').prop("checked", false);
// init span picker
$('.spanpicker').attr('max', 100)
.attr('min', 1)
.attr('step', 1)
.val(1);
// in there, we need get uuid from meta
var uuid = $('meta[name=uuid]').attr('content');
if (uuid != "")
ccn_event_editingEvent = ccn_api_calendar_getDetail(uuid);
// if ccn_event_editingEvent is undefined, init following content with add mode
// otherwise, init as update mode
var isAdd = typeof(ccn_event_editingEvent) == 'undefined';
var deserializeDescription = isAdd ? undefined : ccn_api_deserializeDescription(ccn_event_editingEvent[3]);
// init title and description
$('#ccn-event-inputTitle').val(
isAdd ? '' : ccn_event_editingEvent[2]
);
$('#ccn-event-inputDescription').val(
isAdd ? '' : deserializeDescription.description
);
$('#ccn-event-inputColor').val(
isAdd ? DefaultColor : deserializeDescription.color
);
// init collection picker, first we need query data
// and render it
var collectionDOM = $('#ccn-event-inputCollection');
collectionDOM.empty();
ccn_event_collectionCache = new Array();
var result = ccn_api_collection_getFullOwn();
if (typeof(result) != 'undefined') {
var renderdata = {
val: undefined,
name: undefined
}
for (var index in result) {
var item = result[index];
ccn_event_collectionCache.push(item[0])
renderdata.val = item[0];
renderdata.name = item[1];
collectionDOM.append(
ccn_template_optionItem.render(renderdata)
);
}
}
// in add mode, set as -1, otherwise try to match original data
// indexOf will return -1 if no matched item
collectionDOM.val(isAdd ? '' : ccn_event_editingEvent[1]);
// init start and end datetime
if (isAdd) {
// in add mode, init 2 datetime picker as close hours based time.
var currentDateTime = new Date();
currentDateTime.setMilliseconds(0);
currentDateTime.setSeconds(0);
currentDateTime.setMinutes(0);
ccn_datetimepicker_Set(1, currentDateTime, false);
// time span is 2 hours
currentDateTime.setHours(currentDateTime.getHours() + 2);
ccn_datetimepicker_Set(2, currentDateTime, false);
} else {
// in update mode, match it with original data
var originalDateTime = new Date((ccn_event_editingEvent[5] + ccn_event_editingEvent[7]) * 60000);
ccn_datetimepicker_Set(1, originalDateTime, true);
originalDateTime = new Date((ccn_event_editingEvent[6] + ccn_event_editingEvent[7]) * 60000);
ccn_datetimepicker_Set(2, originalDateTime, true);
}
// setup timezone here
// to prevent some error
// because following isAdd will change its meaning
$('#ccn-event-timezone-radioKeep').prop('checked', true); // give a default value
var nowtime = new Date();
SmarterShowHide(
(!isAdd) && (-nowtime.getTimezoneOffset()) != ccn_event_editingEvent[7],
$('#ccn-event-boxTimezone')
);
// ========================
// now we need resolve loop rules and set related data
if (!isAdd) {
data = ccn_datetime_ResolveLoopRules4UI(ccn_event_editingEvent[8]);
if (typeof(data) == 'undefined') isAdd = true; // init as add
}
// give some value with a default value
$('#ccn-event-loopMonth-radioA').prop('checked', true);
$('#ccn-event-loopWeek-check' + (nowtime.getWeekday() + 1)).prop('checked', true);
$('#ccn-event-strictMode-radioStrict').prop('checked', true);
// real process
if (isAdd) {
$('#ccn-event-radioLoopNever').prop('checked', true);
} else {
switch(data[0][0]) {
case 0:
$('#ccn-event-radioLoopYear').prop('checked', true);
$('#ccn-event-loopYear-inputSpan').val(data[0][2]);
if (data[0][1]) $('#ccn-event-strictMode-radioStrict').prop('checked', true);
else $('#ccn-event-strictMode-radioRough').prop('checked', true);
break;
case 1:
$('#ccn-event-radioLoopMonth').prop('checked', true);
$('#ccn-event-loopMonth-inputSpan').val(data[0][3]);
$('#ccn-event-loopMonth-radio' + data[0][2]).prop('checked', true);
if (data[0][1]) $('#ccn-event-strictMode-radioStrict').prop('checked', true);
else $('#ccn-event-strictMode-radioRough').prop('checked', true);
break;
case 2:
$('#ccn-event-radioLoopWeek').prop('checked', true);
$('#ccn-event-loopWeek-inputSpan').val(data[0][8]);
for(var i = 1; i <= 7; i++) {
$('#ccn-event-loopWeek-check' + i).prop('checked', data[0][i]);
}
break;
case 3:
$('#ccn-event-radioLoopDay').prop('checked', true);
$('#ccn-event-loopDay-inputSpan').val(data[0][1]);
break;
}
}
// give some item a default value
ccn_datetimepicker_Set(3, nowtime, false);
if (isAdd) {
$('#ccn-event-loopStop-radioForever').prop('checked', true);
} else {
switch(data[1][0]) {
case 0:
$('#ccn-event-loopStop-radioForever').prop('checked', true);
break;
case 1:
$('#ccn-event-loopStop-radioDateTime').prop('checked', true);
var stopDatetime = new Date((data[1][1] + ccn_event_editingEvent[7]) * 60000);
ccn_datetimepicker_Set(3, stopDatetime, true);
break;
case 2:
$('#ccn-event-loopStop-radioTimes').prop('checked', true);
$('#ccn-event-loopStop-inputTimes').val(data[1][1]);
break;
}
}
}
// refresh some ui element according to form options
function ccn_event_RefreshRadioDiaplay() {
// loop method
// note: no loop control loop stop's display
// note: year and month loop also control strict mode display
SmarterShowHide(!$('#ccn-event-radioLoopNever').prop('checked'), $('#ccn-event-boxLoopStop'));
SmarterShowHide($('#ccn-event-radioLoopDay').prop('checked'), $('#ccn-event-boxLoopDay'));
SmarterShowHide($('#ccn-event-radioLoopWeek').prop('checked'), $('#ccn-event-boxLoopWeek'));
SmarterShowHide($('#ccn-event-radioLoopMonth').prop('checked'), $('#ccn-event-boxLoopMonth'));
SmarterShowHide($('#ccn-event-radioLoopYear').prop('checked'), $('#ccn-event-boxLoopYear'));
SmarterShowHide(
$('#ccn-event-radioLoopMonth').prop('checked') || $('#ccn-event-radioLoopYear').prop('checked'),
$('#ccn-event-boxStrictMode')
);
// loop stop
SmarterShowHide($('#ccn-event-loopStop-radioForever').prop('checked'), undefined);
SmarterShowHide($('#ccn-event-loopStop-radioDateTime').prop('checked'), $('#ccn-event-boxLoopStopDateTime'));
SmarterShowHide($('#ccn-event-loopStop-radioTimes').prop('checked'), $('#ccn-event-boxLoopStopTimes'));
}
function ccn_event_RefreshLoopMonthType() {
var picker = ccn_datetimepicker_Get(1, false);
var data = ccn_datetime_GetDayInMonth(picker.getFullYear(), picker.getMonth() + 1, picker.getDate());
$('#ccn-event-loopMonth-textA').text($.i18n.prop('ccn-i18n-event-loopWeek-optionA').format(data[0]));
$('#ccn-event-loopMonth-textB').text($.i18n.prop('ccn-i18n-event-loopWeek-optionB').format(data[1]));
$('#ccn-event-loopMonth-textC').text($.i18n.prop('ccn-i18n-event-loopWeek-optionC').format(data[2], data[3] + 1));
$('#ccn-event-loopMonth-textD').text($.i18n.prop('ccn-i18n-event-loopWeek-optionD').format(data[4], data[5] + 1));
}
function ccn_event_UpdateDateTimePickerButton(index) {
switch(index) {
case 1:
$('#ccn-event-btnStartDateTime-text').text(
ccn_datetimepicker_Get(1, false).toLocaleString()
);
break;
case 2:
$('#ccn-event-btnEndDateTime-text').text(
ccn_datetimepicker_Get(2, false).toLocaleString()
);
break;
case 3:
$('#ccn-event-btnLoopStopDateTime-text').text(
ccn_datetimepicker_Get(3, false).toLocaleDateString()
);
break;
}
}
// return undefined to indicate an error
// or
// [belongTo, title, description, eventDateTimeStart, eventDateTimeEnd, timezoneOffset, loopRules]
function ccn_event_GetForm() {
// basic
var title = $('#ccn-event-inputTitle').val();
if (title == '') return undefined;
var description = $('#ccn-event-inputDescription').val();
if (description == '') return undefined;
var color = $('#ccn-event-inputColor').val();
if (color == '') return undefined;
var belongTo = $('#ccn-event-inputCollection').val();
if (belongTo == null) return undefined; // if no selected item, val return null, not undefined
var isAdd = typeof(ccn_event_editingEvent) == 'undefined';
var keepTimezone = $('#ccn-event-timezone-radioKeep').prop('checked');
var isStrict = $('#ccn-event-strictMode-radioStrict').prop('checked');
// time
var eventDateTimeStart = undefined;
var eventDateTimeEnd = undefined;
var timezoneOffset = undefined;
if ((!isAdd) && (!keepTimezone)) {
// get datetime as utc, then minus original timezone to get unix timestamp
timezoneOffset = ccn_event_editingEvent[7]; // keep timezone
eventDateTimeStart = Math.floor(ccn_datetimepicker_Get(1, true).getTime() / 60000) - timezoneOffset;
eventDateTimeEnd = Math.floor(ccn_datetimepicker_Get(2, true).getTime() / 60000) - timezoneOffset;
} else {
// use my timezone, resolve presented data as my local time
var cache = ccn_datetimepicker_Get(1, false);
timezoneOffset = -cache.getTimezoneOffset();
eventDateTimeStart = Math.floor(cache.getTime() / 60000);
eventDateTimeEnd = Math.floor(ccn_datetimepicker_Get(2, false).getTime() / 60000);
}
// loopRules
var loopRules = undefined;
if ($('#ccn-event-radioLoopNever').prop('checked')) {
loopRules = "";
} else if ($('#ccn-event-radioLoopDay').prop('checked')) {
loopRules = "D{0}".format($('#ccn-event-loopDay-inputSpan').val());
} else if ($('#ccn-event-radioLoopWeek').prop('checked')) {
var cache = ""
for(var i = 1; i < 8; i++)
cache += $('#ccn-event-loopWeek-check' + i).prop('checked') ? 'T' : 'F';
loopRules = 'W{0}{1}'.format(
cache,
$('#ccn-event-loopWeek-inputSpan').val()
);
} else if ($('#ccn-event-radioLoopMonth').prop('checked')) {
var cache = undefined;
if ($('#ccn-event-loopMonth-radioA').prop('checked')) cache='A';
else if ($('#ccn-event-loopMonth-radioB').prop('checked')) cache='B';
else if ($('#ccn-event-loopMonth-radioC').prop('checked')) cache='C';
else if ($('#ccn-event-loopMonth-radioD').prop('checked')) cache='D';
else return undefined;
loopRules = "M{0}{1}{2}".format(
isStrict ? "S" : "R",
cache,
$('#ccn-event-loopMonth-inputSpan').val()
);
} else if ($('#ccn-event-radioLoopYear').prop('checked')) {
loopRules = "Y{0}{1}".format(
isStrict ? "S" : "R",
$('#ccn-event-loopYear-inputSpan').val()
);
}
// no need to process stop if this is not a loop event
if (loopRules != "") {
loopRules += '-';
if ($('#ccn-event-loopStop-radioForever').prop('checked')) {
loopRules += 'F';
} else if ($('#ccn-event-loopStop-radioDateTime').prop('checked')) {
var timestamp = undefined;
if ((!isAdd) && (!keepTimezone)) {
// keep timezone
var cache = ccn_datetimepicker_Get(3, true);
cache.setUTCHours(23);
cache.setUTCMinutes(59);
timestamp = Math.floor(cache.getTime() / 60000) - timezoneOffset;
} else {
// use my timezone
timestamp = Math.floor(ccn_datetimepicker_Get(3, false).getTime() / 60000);
}
loopRules += 'D{0}'.format(timestamp);
} else if ($('#ccn-event-loopStop-radioTimes').prop('checked')) {
loopRules += 'T{0}'.format($('#ccn-event-loopStop-inputTimes').val());
}
}
return [
belongTo,
title,
ccn_api_serializeDescription(
description,
color
),
eventDateTimeStart,
eventDateTimeEnd,
timezoneOffset,
loopRules
];
}
function ccn_event_btnSpot() {
var datetime = ccn_datetimepicker_Get(1, false);
datetime.setMinutes(datetime.getMinutes() + 1);
ccn_datetimepicker_Set(2, datetime, false);
}
function ccn_event_btnFullDay() {
var datetime = ccn_datetimepicker_Get(1, false);
datetime.setMinutes(0);
datetime.setHours(0);
ccn_datetimepicker_Set(1, datetime, false);
datetime.setMinutes(59);
datetime.setHours(23);
ccn_datetimepicker_Set(2, datetime, false);
}
function ccn_event_btnDateTimePicker() {
switch(parseInt($(this).attr('datetimepicker'))) {
case 1:
ccn_datetimepicker_Modal(ccn_datetimepicker_tabType.minute, 1, false);
break;
case 2:
ccn_datetimepicker_Modal(ccn_datetimepicker_tabType.minute, 2, false);
break;
case 3:
ccn_datetimepicker_Modal(ccn_datetimepicker_tabType.day, 3, false);
break;
}
}
function ccn_event_btnCancel() {
window.location.href = '/web/calendar';
}
function ccn_event_btnSubmit() {
var submitData = ccn_event_GetForm();
if (typeof(submitData) == 'undefined') {
ccn_messagebox_Show($.i18n.prop("ccn-i18n-js-fail-form"));
return;
}
var isAdd = typeof(ccn_event_editingEvent) == 'undefined';
if (isAdd) {
var result = ccn_api_calendar_add(
submitData[0],
submitData[1],
submitData[2],
submitData[3],
submitData[4],
submitData[6],
submitData[5]
);
if (typeof(result) == 'undefined') ccn_messagebox_Show($.i18n.prop("ccn-i18n-js-fail-add"));
else window.location.href = '/web/calendar';
} else {
var result = ccn_api_calendar_update(
ccn_event_editingEvent[0],
ccn_event_editingEvent[1] == submitData[0] ? undefined : submitData[0],
ccn_event_editingEvent[2] == submitData[1] ? undefined : submitData[1],
ccn_event_editingEvent[3] == submitData[2] ? undefined : submitData[2],
ccn_event_editingEvent[5] == submitData[3] ? undefined : submitData[3],
ccn_event_editingEvent[6] == submitData[4] ? undefined : submitData[4],
ccn_event_editingEvent[8] == submitData[6] ? undefined : submitData[6],
ccn_event_editingEvent[7] == submitData[5] ? undefined : submitData[5],
ccn_event_editingEvent[4]
);
if (typeof(result) == 'undefined') ccn_messagebox_Show($.i18n.prop("ccn-i18n-js-fail-update"));
else window.location.href = '/web/calendar';
}
}
-19
View File
@@ -1,19 +0,0 @@
$(document).ready(function() {
ccn_pages_currentPage = ccn_pages_enumPages.home;
// template process
ccn_template_Load();
// nav process
ccn_headerNav_Insert();
ccn_headerNav_BindEvents();
ccn_headerNav_LoggedRefresh();
// messagebox process
ccn_messagebox_Insert();
ccn_messagebox_BindEvent();
// apply i18n
ccn_i18n_LoadLanguage();
ccn_i18n_ApplyLanguage();
});
-58
View File
@@ -1,58 +0,0 @@
$(document).ready(function() {
ccn_pages_currentPage = ccn_pages_enumPages.login;
// template process
ccn_template_Load();
// nav process
ccn_headerNav_Insert();
ccn_headerNav_BindEvents();
ccn_headerNav_LoggedRefresh();
// messagebox process
ccn_messagebox_Insert();
ccn_messagebox_BindEvent();
// apply i18n
ccn_i18n_LoadLanguage();
ccn_i18n_ApplyLanguage();
// bind login event
$("#ccn-login-form-login").click(ccn_login_startLogin);
});
function ccn_login_startLogin() {
// disable all ui first
$("#ccn-login-form-login").attr("disabled",true);
$("#ccn-login-form-username").attr("disabled",true);
$("#ccn-login-form-password").attr("disabled",true);
// get form data
username = $("#ccn-login-form-username").val();
password = $("#ccn-login-form-password").val();
/*
// try get salt
if (ccn_api_common_salt(username)) {
// continue login
if (ccn_api_common_login(username, password)) {
// ok, logged
// jump into home page again
window.location.href = '/web/home';
} else ccn_messagebox_Show($.i18n.prop("ccn-i18n-js-fail-login"));
} else ccn_messagebox_Show($.i18n.prop("ccn-i18n-js-fail-login"));
*/
if (ccn_api_common_webLogin(username, password)) {
// ok, logged
// jump into home page again
window.location.href = '/web/home';
return;
} else ccn_messagebox_Show($.i18n.prop("ccn-i18n-js-fail-login"));
// retore ui
$("#ccn-login-form-login").removeAttr("disabled");
$("#ccn-login-form-username").removeAttr("disabled");
$("#ccn-login-form-password").removeAttr("disabled");
}
-190
View File
@@ -1,190 +0,0 @@
var ccn_todo_todoListCache = [];
$(document).ready(function() {
ccn_pages_currentPage = ccn_pages_enumPages.todo;
// template process
ccn_template_Load();
// nav process
ccn_headerNav_Insert();
ccn_headerNav_BindEvents();
ccn_headerNav_LoggedRefresh();
// messagebox process
ccn_messagebox_Insert();
ccn_messagebox_BindEvent();
// apply i18n
ccn_i18n_LoadLanguage();
ccn_i18n_ApplyLanguage();
// refresh once
ccn_todo_Refresh();
// bind event
$("#ccn-todo-btnAdd").click(ccn_todo_Add);
$("#ccn-todo-btnRefresh").click(ccn_todo_Refresh);
});
function ccn_todo_RefreshCacheList() {
// clean list cache first
ccn_todo_todoListCache = new Array();
var result = ccn_api_todo_getFull();
if(typeof(result) != 'undefined') {
for(var index in result) {
ccn_todo_todoListCache[result[index][0]] = result[index];
}
}
}
function ccn_todo_RenderCacheList() {
// clean list first
$("#ccn-todo-todoList").empty();
var renderdata = {
uuid: undefined,
data: undefined
};
var listDOM = $("#ccn-todo-todoList");
for(var index in ccn_todo_todoListCache) {
// update render data
var item = ccn_todo_todoListCache[index];
renderdata.uuid = item[0];
renderdata.data = LineBreaker2Br(item[2]);
// render
listDOM.append(ccn_template_todoItem.render(renderdata));
// set mode
var uuid = renderdata.uuid;
ccn_todo_ChangeDisplayMode(uuid, false);
// bind event
$("#ccn-todoItem-btnEdit-" + uuid).click(ccn_todo_ItemEdit);
$("#ccn-todoItem-btnDelete-" + uuid).click(ccn_todo_ItemDelete);
$("#ccn-todoItem-btnUpdate-" + uuid).click(ccn_todo_ItemUpdate);
$("#ccn-todoItem-btnCancelUpdate-" + uuid).click(ccn_todo_ItemCancelUpdate);
}
}
function ccn_todo_ChangeDisplayMode(uuid, isEdit) {
if(isEdit) {
// 4 buttons
$("#ccn-todoItem-btnEdit-" + uuid).hide();
$("#ccn-todoItem-btnDelete-" + uuid).hide();
$("#ccn-todoItem-btnUpdate-" + uuid).show();
$("#ccn-todoItem-btnCancelUpdate-" + uuid).show();
// 2 elements
$("#ccn-todoItem-p-" + uuid).hide();
$("#ccn-todoItem-textarea-" + uuid).show();
} else {
$("#ccn-todoItem-btnEdit-" + uuid).show();
$("#ccn-todoItem-btnDelete-" + uuid).show();
$("#ccn-todoItem-btnUpdate-" + uuid).hide();
$("#ccn-todoItem-btnCancelUpdate-" + uuid).hide();
$("#ccn-todoItem-p-" + uuid).show();
$("#ccn-todoItem-textarea-" + uuid).hide();
}
}
function ccn_todo_Refresh() {
// refresh and render once
ccn_todo_RefreshCacheList();
ccn_todo_RenderCacheList();
}
function ccn_todo_Add() {
var result = ccn_api_todo_add();
if (typeof(result) == 'undefined') {
// fail
ccn_messagebox_Show($.i18n.prop("ccn-i18n-js-fail-add"));
} else {
// add into cache
ccn_todo_todoListCache[result[0]] = result;
// render
var listDOM = $("#ccn-todo-todoList");
listDOM.append(ccn_template_todoItem.render({
uuid: result[0],
data: LineBreaker2Br(result[2])
}));
// set mode
var uuid = result[0];
ccn_todo_ChangeDisplayMode(uuid, false);
// bind event
$("#ccn-todoItem-btnEdit-" + uuid).click(ccn_todo_ItemEdit);
$("#ccn-todoItem-btnDelete-" + uuid).click(ccn_todo_ItemDelete);
$("#ccn-todoItem-btnUpdate-" + uuid).click(ccn_todo_ItemUpdate);
$("#ccn-todoItem-btnCancelUpdate-" + uuid).click(ccn_todo_ItemCancelUpdate);
}
}
function ccn_todo_ItemEdit() {
var uuid = $(this).attr("uuid");
// copy current data to textarea
$("#ccn-todoItem-textarea-" + uuid).val(
ccn_todo_todoListCache[uuid][2]
);
// switch to edit mode
ccn_todo_ChangeDisplayMode(uuid, true);
}
function ccn_todo_ItemDelete() {
var uuid = $(this).attr("uuid");
var result = ccn_api_todo_delete(
uuid,
ccn_todo_todoListCache[uuid][3]
);
if(!result) {
// fail
ccn_messagebox_Show($.i18n.prop("ccn-i18n-js-fail-delete"));
} else {
// remove body
$("#ccn-todoItem-" + uuid).remove();
}
}
function ccn_todo_ItemUpdate() {
var uuid = $(this).attr("uuid");
var newData = $("#ccn-todoItem-textarea-" + uuid).val();
var result = ccn_api_todo_update(
uuid,
newData,
ccn_todo_todoListCache[uuid][3]
);
if (typeof(result) == 'undefined') {
// fail
ccn_messagebox_Show($.i18n.prop("ccn-i18n-js-fail-update"));
} else {
// safely update data & lastChanged and control
ccn_todo_todoListCache[uuid][2] = newData;
ccn_todo_todoListCache[uuid][3] = result;
$("#ccn-todoItem-p-" + uuid).html(LineBreaker2Br(newData));
// switch to normal mode
ccn_todo_ChangeDisplayMode(uuid, false);
}
}
function ccn_todo_ItemCancelUpdate() {
var uuid = $(this).attr("uuid");
// clean data
$("#ccn-todoItem-textarea-" + uuid).val("");
// switch to normal mode
ccn_todo_ChangeDisplayMode(uuid, false);
}
-10
View File
@@ -1,10 +0,0 @@
// all args are based on 1
function ccn_tabcontrol_SwitchTab(tabcontrolGroup, targetTabIndex) {
// close all panel and tab
$(".tabcontrol-tab-" + tabcontrolGroup).removeClass("is-active");
$(".tabcontrol-panel-" + tabcontrolGroup).hide();
// show specific
$("#tabcontrol-tab-" + tabcontrolGroup + "-" + targetTabIndex).addClass("is-active");
$("#tabcontrol-panel-" + tabcontrolGroup + "-" + targetTabIndex).show();
}
-50
View File
@@ -1,50 +0,0 @@
var ccn_template_headerNav = undefined;
var ccn_template_messagebox = undefined;
var ccn_template_datetimepicker = undefined;
var ccn_template_calendarItem = undefined;
var ccn_template_scheduleItem = undefined;
var ccn_template_ownedItem = undefined;
var ccn_template_sharingItem = undefined;
var ccn_template_displayOwnedItem = undefined;
var ccn_template_displaySharedItem = undefined;
var ccn_template_userItem = undefined;
var ccn_template_todoItem = undefined;
var ccn_template_optionItem = undefined;
var ccn_template_tokenItem = undefined;
function ccn_template_Load() {
ccn_template_headerNav = ccn_template_TemplateLoader('headerNav');
ccn_template_messagebox = ccn_template_TemplateLoader('messagebox');
ccn_template_datetimepicker = ccn_template_TemplateLoader('datetimepicker');
ccn_template_calendarItem = ccn_template_TemplateLoader('calendarItem');
ccn_template_scheduleItem = ccn_template_TemplateLoader('scheduleItem');
ccn_template_displayOwnedItem = ccn_template_TemplateLoader('displayOwnedItem');
ccn_template_displaySharedItem = ccn_template_TemplateLoader('displaySharedItem');
ccn_template_todoItem = ccn_template_TemplateLoader('todoItem');
ccn_template_userItem = ccn_template_TemplateLoader('userItem');
ccn_template_tokenItem = ccn_template_TemplateLoader('tokenItem');
ccn_template_ownedItem = ccn_template_TemplateLoader('ownedItem');
ccn_template_sharingItem = ccn_template_TemplateLoader('sharingItem');
ccn_template_optionItem = ccn_template_TemplateLoader('optionItem');
}
function ccn_template_TemplateLoader(templateName) {
var elements = $("#jsrender-tmpl-" + templateName);
if (elements.length == 0) return undefined;
var cache = undefined;
$.ajax({
url: elements.attr('src'),
type: "GET",
async: false,
success: function (data) {
cache = $.templates(data);
}
});
return cache;
}
-70
View File
@@ -1,70 +0,0 @@
/*
function ComputPasswordWithSalt(password, salt) {
return ComputeSHA256(ComputeSHA256(password) + salt.toString());
}
function ComputeSHA256(strl) {
var tempstr = new TextEncoder().encode(strl);
var hashedStrl = undefined
var shitpromise = crypto.subtle.digest('SHA-256', tempstr);
Promise.all(shitpromise).then(function(result) {
hashedStrl = result;
});
var hashArray = Array.from(new Uint8Array(hashedStrl));
var hashHex = hashArray.map(b => ('00' + b.toString(16)).slice(-2)).join('');
return hashHex.toLowerCase();
}
*/
var DefaultColor = '#536dfe';
function IsResponseOK(data) {
if (typeof (data) == 'undefined') {
console.log("Fail to execute an api!");
return false;
}
if (!data['success']) {
console.log("Fail to execute an api! Reason:");
console.log(data['error']);
return false;
}
return true;
}
function LineBreaker2Br(strl) {
return $('<div>').text(strl).html().replace(/\n/g, '<br />');
}
function IsUndefinedOrEmpty(data) {
return (typeof (data) == 'undefined' || data == "");
}
function SmarterShowHide(boolean, element) {
if (typeof (element) == 'undefined') return;
if (boolean) element.show();
else element.hide();
}
function GCD(a, b) {
if (b == 0) return a;
return GCD(b, a % b);
}
function LCM(a, b) {
return a / GCD(a, b) * b;
}
String.prototype.format = function() {
var e = arguments;
return !!this && this.replace(
/\{(\d+)\}/g,
function (t, n) {
return e[n].toString() ? e[n].toString() : t;
}
);
};
Date.prototype.getWeekday = function() {
var temp = this.getDay();
if (temp == 0) return 6;
else return temp - 1;
};
@@ -1,16 +0,0 @@
{{for start=0 end=6 step=1 itemVar="~row"}}
<div>
{{for start=0 end=7 step=1 itemVar="~column"}}
<div id="ccn-calendarItem-{{:~row}}-{{:~column}}">
<p><b id="ccn-calendarItem-title-{{:~row}}-{{:~column}}">&nbsp;</b>
<span id="ccn-calendarItem-desc-{{:~row}}-{{:~column}}"></span>
</p>
<div id="ccn-calendarItem-eventBox1-{{:~row}}-{{:~column}}" class="calendarItem-eventBox" enableDisplay="false"></div>
<div id="ccn-calendarItem-eventBox2-{{:~row}}-{{:~column}}" class="calendarItem-eventBox" enableDisplay="false"></div>
<div id="ccn-calendarItem-eventBox3-{{:~row}}-{{:~column}}" class="calendarItem-eventBox" enableDisplay="false"></div>
<div id="ccn-calendarItem-eventBox4-{{:~row}}-{{:~column}}" class="calendarItem-eventBox" enableDisplay="false"></div>
<p id="ccn-calendarItem-task-{{:~row}}-{{:~column}}">&nbsp;</p>
</div>
{{/for}}
</div>
{{/for}}
@@ -1,200 +0,0 @@
<div id="ccn-datetimepicker-modal" class="modal" 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 type="year"><small i18n-name="ccn-i18n-universal-text-year"></small><span id="ccn-datetimepicker-datetime-year">&nbsp;</span></div>
<div type="month"><small i18n-name="ccn-i18n-universal-text-month"></small><span id="ccn-datetimepicker-datetime-month">&nbsp;</span></div>
<div type="day"><small i18n-name="ccn-i18n-universal-text-day"></small><span id="ccn-datetimepicker-datetime-day">&nbsp;</span></div>
<div type="hour"><small i18n-name="ccn-i18n-universal-text-hour"></small><span id="ccn-datetimepicker-datetime-hour">&nbsp;</span></div>
<div type="minute"><small i18n-name="ccn-i18n-universal-text-minute"></small><span id="ccn-datetimepicker-datetime-minute">&nbsp;</span></div>
</header>
<div class="modal-card-body pickerContainer">
<div id="ccn-datetimepicker-panelYear">
<nav class="level is-mobile">
<div class="level-left">
<div class="level-item control">
<a id="ccn-datetimepiacker-panelYear-prevBtn" class="button">
<span class="icon is-small"><i class="fas fa-chevron-circle-left"></i></span>
</a>
</div>
</div>
<div id="ccn-datetimepiacker-panelYear-title" class="level-item"></div>
<div class="level-right">
<div class="level-item control">
<a id="ccn-datetimepiacker-panelYear-nextBtn" class="button">
<span class="icon is-small"><i class="fas fa-chevron-circle-right"></i></span>
</a>
</div>
</div>
</nav>
<div id="ccn-datetimepiacker-panelYear-table" class="perfectTable">
<div>
<div></div>
<div></div>
<div></div>
<div></div>
</div>
<div>
<div></div>
<div></div>
<div></div>
<div></div>
</div>
<div>
<div></div>
<div></div>
<div></div>
<div></div>
</div>
</div>
</div>
<div id="ccn-datetimepicker-panelMonth">
<nav class="level is-mobile">
<div class="level-left">
<div class="level-item control">
<a id="ccn-datetimepiacker-panelMonth-prevBtn" class="button">
<span class="icon is-small"><i class="fas fa-chevron-circle-left"></i></span>
</a>
</div>
</div>
<div id="ccn-datetimepiacker-panelMonth-title" class="level-item"></div>
<div class="level-right">
<div class="level-item control">
<a id="ccn-datetimepiacker-panelMonth-nextBtn" class="button">
<span class="icon is-small"><i class="fas fa-chevron-circle-right"></i></span>
</a>
</div>
</div>
</nav>
<div id="ccn-datetimepiacker-panelMonth-table" class="perfectTable">
<div>
<div i18n-name="ccn-i18n-universal-month-1"></div>
<div i18n-name="ccn-i18n-universal-month-2"></div>
<div i18n-name="ccn-i18n-universal-month-3"></div>
<div i18n-name="ccn-i18n-universal-month-4"></div>
</div>
<div>
<div i18n-name="ccn-i18n-universal-month-5"></div>
<div i18n-name="ccn-i18n-universal-month-6"></div>
<div i18n-name="ccn-i18n-universal-month-7"></div>
<div i18n-name="ccn-i18n-universal-month-8"></div>
</div>
<div>
<div i18n-name="ccn-i18n-universal-month-9"></div>
<div i18n-name="ccn-i18n-universal-month-10"></div>
<div i18n-name="ccn-i18n-universal-month-11"></div>
<div i18n-name="ccn-i18n-universal-month-12"></div>
</div>
</div>
</div>
<div id="ccn-datetimepicker-panelDay">
<nav class="level is-mobile">
<div class="level-left">
<div class="level-item control">
<a id="ccn-datetimepiacker-panelDay-prevBtn" class="button">
<span class="icon is-small"><i class="fas fa-chevron-circle-left"></i></span>
</a>
</div>
</div>
<div id="ccn-datetimepiacker-panelDay-title" class="level-item"></div>
<div class="level-right">
<div class="level-item control">
<a id="ccn-datetimepiacker-panelDay-nextBtn" class="button">
<span class="icon is-small"><i class="fas fa-chevron-circle-right"></i></span>
</a>
</div>
</div>
</nav>
<div id="ccn-datetimepiacker-panelDay-table" class="perfectTable">
<div>
<div i18n-name="ccn-i18n-universal-week-1"></div>
<div i18n-name="ccn-i18n-universal-week-2"></div>
<div i18n-name="ccn-i18n-universal-week-3"></div>
<div i18n-name="ccn-i18n-universal-week-4"></div>
<div i18n-name="ccn-i18n-universal-week-5"></div>
<div i18n-name="ccn-i18n-universal-week-6"></div>
<div i18n-name="ccn-i18n-universal-week-7"></div>
</div>
<div>
<div></div><div></div><div></div><div></div><div></div><div></div><div></div>
</div>
<div>
<div></div><div></div><div></div><div></div><div></div><div></div><div></div>
</div>
<div>
<div></div><div></div><div></div><div></div><div></div><div></div><div></div>
</div>
<div>
<div></div><div></div><div></div><div></div><div></div><div></div><div></div>
</div>
<div>
<div></div><div></div><div></div><div></div><div></div><div></div><div></div>
</div>
<div>
<div></div><div></div><div></div><div></div><div></div><div></div><div></div>
</div>
</div>
</div>
<svg id="ccn-datetimepicker-panelHour" xmlns="http://www.w3.org/2000/svg" version="1.1" preserveAspectRatio="xMidYMid" viewBox="0 0 200 200">
<circle cx="100.000000" cy="100.000000" r="100.000000" type="background"></circle>
<line x1="100" y1="100" x2="100.000000" y2="20.000000"></line>
<circle cx="100.000000" cy="20.000000" 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 id="ccn-datetimepicker-panelMinute" xmlns="http://www.w3.org/2000/svg" version="1.1" preserveAspectRatio="xMidYMid" viewBox="0 0 200 200">
<circle cx="100.000000" cy="100.000000" r="100.000000" type="background"></circle>
<line x1="100" y1="100" x2="100.000000" y2="20.000000"></line>
<circle cx="100.000000" cy="20.000000" 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 id="ccn-datetimepicker-btnConfirm" class="button is-success">
<span i18n-name="ccn-i18n-datetimepicker-confirm"></span>
</a>
<a id="ccn-datetimepicker-btnCancel" class="button">
<span i18n-name="ccn-i18n-datetimepicker-cancel"></span>
</a>
</footer>
</div>
</div>
@@ -1,12 +0,0 @@
<div id="ccn-displayOwnedItem-{{:uuid}}" class="collection-item card">
<div class="collection-item-words">
<p>{{>name}}</p>
</div>
<div id="ccn-displayOwnedItem-btnHide-{{:uuid}}" uuid="{{:uuid}}" class="collection-item-icon control">
<a class="button"><span class="icon is-small"><i class="fas fa-eye"></i></span></a>
</div>
<div id="ccn-displayOwnedItem-btnShow-{{:uuid}}" uuid="{{:uuid}}" class="collection-item-icon control">
<a class="button"><span class="icon is-small"><i class="fas fa-eye-slash"></i></span></a>
</div>
</div>
@@ -1,16 +0,0 @@
<div id="ccn-displaySharedItem-{{:uuid}}" class="collection-item card">
<div class="collection-item-words">
<b>{{>name}}</b>
<p>
<span i18n-name="ccn-i18n-sharedItem-sharedBy"></span>
<span>{{>username}}</span>
</p>
</div>
<div id="ccn-displaySharedItem-btnHide-{{:uuid}}" uuid="{{:uuid}}" class="collection-item-icon control">
<a class="button"><span class="icon is-small"><i class="fas fa-eye"></i></span></a>
</div>
<div id="ccn-displaySharedItem-btnShow-{{:uuid}}" uuid="{{:uuid}}" class="collection-item-icon control">
<a class="button"><span class="icon is-small"><i class="fas fa-eye-slash"></i></span></a>
</div>
</div>
@@ -1,42 +0,0 @@
<nav class="navbar has-shadow is-spaced bd-navbar" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<a class="navbar-item" href="home">
<img src="/static/image/icon.png"><b style="margin:0 0 0 14px;">coconut-leaf</b>
</a>
<a role="button" class="navbar-burger burger" aria-label="menu" aria-expanded="false"
data-target="navbarBasicExample">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div id="navbarBasicExample" class="navbar-menu">
<div class="navbar-start">
<a i18n-name="ccn-i18n-header-nav-home" id="ccn-header-nav-home" class="navbar-item" href="/web/home"></a>
<a i18n-name="ccn-i18n-header-nav-collection" id="ccn-header-nav-collection" class="navbar-item" href="/web/collection"></a>
<a i18n-name="ccn-i18n-header-nav-calendar" id="ccn-header-nav-calendar" class="navbar-item" href="/web/calendar"></a>
<a i18n-name="ccn-i18n-header-nav-todo" id="ccn-header-nav-todo" class="navbar-item" href="/web/todo"></a>
<a i18n-name="ccn-i18n-header-nav-admin" id="ccn-header-nav-admin" class="navbar-item" href="/web/admin"></a>
</div>
<div class="navbar-end">
<p id="ccn-header-user-login" class="navbar-item">
<a class="button is-primary" i18n-name="ccn-i18n-header-user-login" href="/web/login"></a>
</p>
<p id="ccn-header-user-logout" class="navbar-item">
<a class="button is-primary" i18n-name="ccn-i18n-header-user-logout"></a>
</p>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" i18n-name="ccn-i18n-header-language"></a>
<div id="ccn-header-language" class="navbar-dropdown">
<a language="en-US" class="navbar-item">English</a>
<a language="zh-CN" class="navbar-item">简体中文</a>
</div>
</div>
</div>
</div>
</nav>
@@ -1,15 +0,0 @@
<div id="ccn-messagebox-modal" class="modal" 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 id="ccn-messagebox-title" class="modal-card-title" i18n-name="ccn-i18n-messagebox-title"></p>
<button id="ccn-messagebox-btnClose" class="delete" aria-label="close"></button>
</header>
<div class="modal-card-body">
<p id="ccn-messagebox-body"></p>
</div>
<footer class="modal-card-foot">
<button id="ccn-messagebox-btnConfirm" class="button is-success" i18n-name="ccn-i18n-messagebox-confirm"></button>
</footer>
</div>
</div>
@@ -1 +0,0 @@
<option value="{{:val}}">{{>name}}</option>
@@ -1,24 +0,0 @@
<div id="ccn-ownedItem-{{:uuid}}" class="collection-item card">
<div class="collection-item-words">
<p id="ccn-ownedItem-textName-{{:uuid}}">{{>name}}</p>
<div id="ccn-ownedItem-boxName-{{:uuid}}" class="control">
<input id="ccn-ownedItem-inputName-{{:uuid}}" class="input" type="text"></input>
</div>
</div>
<div id="ccn-ownedItem-btnEdit-{{:uuid}}" uuid="{{:uuid}}" class="collection-item-icon control">
<a class="button"><span class="icon is-small"><i class="fas fa-pen"></i></span></a>
</div>
<div id="ccn-ownedItem-btnShare-{{:uuid}}" uuid="{{:uuid}}" class="collection-item-icon control">
<a class="button"><span class="icon is-small"><i class="fas fa-share"></i></a>
</div>
<div id="ccn-ownedItem-btnDelete-{{:uuid}}" uuid="{{:uuid}}" class="collection-item-icon control">
<a class="button"><span class="icon is-small"><i class="fas fa-trash"></i></a>
</div>
<div id="ccn-ownedItem-btnUpdate-{{:uuid}}" uuid="{{:uuid}}" class="collection-item-icon control">
<button class="button"><span class="icon is-small"><i class="fas fa-check"></i></span></button>
</div>
<div id="ccn-ownedItem-btnCancelUpdate-{{:uuid}}" uuid="{{:uuid}}" class="collection-item-icon control">
<button class="button"><span class="icon is-small"><i class="fas fa-times"></i></button>
</div>
</div>
@@ -1,36 +0,0 @@
{{for renderdata}}
<div class="schedule-day container">
<div class="schedule-day-words">
<b i18n-name="ccn-i18n-universal-month-{{:month}}"></b>
<b>{{>day}}</b>
<b i18n-name="ccn-i18n-universal-week-{{:dayOfWeek}}"></b>
</div>
<div class="schedule-event-list">
{{for events}}
{{if isVisible}}
<div class="schedule-event-outter card" uuid="{{:uuid}}">
<div class="schedule-event-color" style="background: {{:color}};"></div>
<div class="schedule-event-inner">
<div class="schedule-event-words">
<p class="level-item"><b>{{>title}}</b></p>
<p class="level-item">{{>description}}</p>
<p class="level-item"><span>{{>start}}</span>-<span>{{>end}}</span></p>
{{if loopText != ""}}
<p><span class="icon is-small"><i class="fas fa-retweet"></i></span><span>{{>loopText}}</span></p>
{{/if}}
</div>
<div class="schedule-event-icon">
{{if isLocked}}
<span class="icon is-small"><i class="fas fa-lock"></i></span>
{{/if}}
{{if timezoneWarning}}
<span class="icon is-small"><i class="fas fa-globe"></i></span>
{{/if}}
</div>
</div>
</div>
{{/if}}
{{/for}}
</div>
</div>
{{/for}}
@@ -1,9 +0,0 @@
<div id="ccn-sharingItem-{{:uuid}}" class="collection-item card">
<div class="collection-item-words">
<p>{{>username}}</p>
</div>
<div id="ccn-sharingItem-btnDelete-{{:uuid}}" uuid="{{:uuid}}" class="collection-item-icon control">
<a class="button"><span class="icon is-small"><i class="fas fa-trash"></i></span></a>
</div>
</div>
-20
View File
@@ -1,20 +0,0 @@
<div id="ccn-todoItem-{{:uuid}}" class="todo-item card">
<div class="todo-item-words">
<p id="ccn-todoItem-p-{{:uuid}}">{{:data}}</p>
<textarea id="ccn-todoItem-textarea-{{:uuid}}" class="textarea"></textarea>
</div>
<div id="ccn-todoItem-btnEdit-{{:uuid}}" uuid="{{:uuid}}" class="todo-item-icon control">
<button class="button"><span class="icon is-small"><i class="fas fa-pen"></i></span></button>
</div>
<div id="ccn-todoItem-btnDelete-{{:uuid}}" uuid="{{:uuid}}" class="todo-item-icon control">
<button class="button"><span class="icon is-small"><i class="fas fa-trash"></i></button>
</div>
<div id="ccn-todoItem-btnUpdate-{{:uuid}}" uuid="{{:uuid}}" class="todo-item-icon control">
<button class="button"><span class="icon is-small"><i class="fas fa-check"></i></span></button>
</div>
<div id="ccn-todoItem-btnCancelUpdate-{{:uuid}}" uuid="{{:uuid}}" class="todo-item-icon control">
<button class="button"><span class="icon is-small"><i class="fas fa-times"></i></button>
</div>
</div>
@@ -1,27 +0,0 @@
<div id="ccn-tokenItem-{{:uuid}}" class="token-item card">
<div class="token-item-words">
<b>{{>uuid}}</b>
<p>
<span i18n-name="ccn-i18n-tokenItem-ua"></span>
<span>{{>ua}}</span>
</p>
<p>
<span i18n-name="ccn-i18n-tokenItem-ip"></span>
<span>{{>ip}}</span>
</p>
<p>
<span i18n-name="ccn-i18n-tokenItem-expireOn"></span>
<span>{{>expireOn}}</span>
</p>
{{if isMe}}
<p>
<span class="icon is-small"><i class="fas fa-exclamation-triangle"></i></span>
<span i18n-name="ccn-i18n-tokenItem-isMe"></span>
</p>
{{/if}}
</div>
<div id="ccn-tokenItem-btnLogout-{{:uuid}}" uuid="{{:uuid}}" class="token-item-icon control">
<a class="button"><span class="icon is-small"><i class="fas fa-sign-out-alt"></i></span></a>
</div>
</div>
-35
View File
@@ -1,35 +0,0 @@
<div id="ccn-userItem-{{:uuid}}" class="user-item card">
<div class="user-item-words">
<div class="control" style="display: flex; flex-flow: row; align-items: center;">
<div id="ccn-userItem-iconIsAdmin-{{:uuid}}" class="icon is-small" style="margin-right: 1rem;">
<i class="fas fa-wrench"></i>
</div>
<div id="ccn-userItem-textName-{{:uuid}}">{{>username}}</div>
</div>
<div id="ccn-userItem-boxPassword-{{:uuid}}" class="field">
<label class="label" i18n-name="ccn-i18n-userItem-newPassword"></label>
<div class="control">
<input id="ccn-userItem-inputPassword-{{:uuid}}" class="input" type="password"></input>
</div>
</div>
<div id="ccn-userItem-boxIsAdmin-{{:uuid}}" class="field">
<label class="checkbox">
<input id="ccn-userItem-inputIsAdmin-{{:uuid}}" type="checkbox"><span i18n-name="ccn-i18n-userItem-isAdmin"></span>
</label>
</div>
</div>
<div id="ccn-userItem-btnEdit-{{:uuid}}" uuid="{{:uuid}}" class="user-item-icon control">
<a class="button"><span class="icon is-small"><i class="fas fa-pen"></i></span></a>
</div>
<div id="ccn-userItem-btnDelete-{{:uuid}}" uuid="{{:uuid}}" class="user-item-icon control">
<a class="button"><span class="icon is-small"><i class="fas fa-trash"></i></span></a>
</div>
<div id="ccn-userItem-btnUpdate-{{:uuid}}" uuid="{{:uuid}}" class="user-item-icon control">
<a class="button"><span class="icon is-small"><i class="fas fa-check"></i></span></a>
</div>
<div id="ccn-userItem-btnCancelUpdate-{{:uuid}}" uuid="{{:uuid}}" class="user-item-icon control">
<a class="button"><span class="icon is-small"><i class="fas fa-times"></i></span></a>
</div>
</div>
-100
View File
@@ -1,100 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title id="ccn-pageName"></title>
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.1/css/bulma.min.css">
<script src="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@5.12.1/js/all.min.js"></script>
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/jquery@3.4.1/dist/jquery.js"></script>
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/jquery-i18n-properties@1.2.7/jquery.i18n.properties.js"></script>
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/jsrender@1.0.10/jsrender.min.js"></script>
<script type="text/x-jsrender" id="jsrender-tmpl-headerNav" src="/static/tmpl/headerNav.tmpl"></script>
<script type="text/x-jsrender" id="jsrender-tmpl-messagebox" src="/static/tmpl/messagebox.tmpl"></script>
<script type="text/x-jsrender" id="jsrender-tmpl-userItem" src="/static/tmpl/userItem.tmpl"></script>
<script type="text/x-jsrender" id="jsrender-tmpl-tokenItem" src="/static/tmpl/tokenItem.tmpl"></script>
<script type="text/javascript" src="/static/js/localStorageAssist.js"></script>
<script type="text/javascript" src="/static/js/i18n.js"></script>
<script type="text/javascript" src="/static/js/utils.js"></script>
<script type="text/javascript" src="/static/js/api.js"></script>
<script type="text/javascript" src="/static/js/template.js"></script>
<script type="text/javascript" src="/static/js/headerNav.js"></script>
<script type="text/javascript" src="/static/js/tabcontrol.js"></script>
<script type="text/javascript" src="/static/js/messagebox.js"></script>
<script type="text/javascript" src="/static/js/page/admin.js"></script>
<link rel="stylesheet" href="/static/css/admin.css">
</head>
<body>
<div class="container" style="margin-top: 20px;">
<div class="tabs">
<ul>
<li id="tabcontrol-tab-1-1" class="tabcontrol-tab-1"><a
i18n-name="ccn-i18n-admin-tabcontrol-tabProfile"></a></li>
<li id="tabcontrol-tab-1-2" class="tabcontrol-tab-1"><a
i18n-name="ccn-i18n-admin-tabcontrol-tabToken"></a></li>
<li id="tabcontrol-tab-1-3" class="tabcontrol-tab-1"><a
i18n-name="ccn-i18n-admin-tabcontrol-tabUserList"></a></li>
</ul>
</div>
</div>
<div id="tabcontrol-panel-1-1" class="container tabcontrol-panel-1" style="margin-top: 20px;">
<h1 class="title" i18n-name="ccn-i18n-admin-changePassword"></h1>
<div class="field has-addons">
<div class="control">
<input id="ccn-admin-profile-inputPassword" class="input" type="password">
</div>
<div class="control">
<a id="ccn-admin-profile-btnChangePassword" class="button is-primary">
<span class="icon is-small"><i class="fas fa-key"></i></span>
</a>
</div>
</div>
</div>
<div id="tabcontrol-panel-1-2" class="container tabcontrol-panel-1" style="margin-top: 20px;">
<h1 class="title" i18n-name="ccn-i18n-admin-manageToken"></h1>
<h2 class="subtitle" i18n-name="ccn-i18n-admin-manageToken-desc"></h2>
<div id="ccn-admin-tokenList-btnRefresh" class="control">
<a class="button is-primary">
<span class="icon is-small"><i class="fas fa-sync"></i></span>
</a>
</div>
<div id="ccn-admin-tokenList" style="display: flex; flex-flow: column; margin-top: 1.25rem;">
</div>
</div>
<div id="tabcontrol-panel-1-3" class="container tabcontrol-panel-1" style="margin-top: 20px;">
<h1 class="title" i18n-name="ccn-i18n-admin-userList"></h1>
<div class="control-list">
<div class="field has-addons">
<div class="control">
<input id="ccn-admin-userList-inputUsername" class="input" type="text">
</div>
<div id="ccn-admin-userList-btnAdd" class="control">
<a class="button is-primary">
<span class="icon is-small"><i class="fas fa-plus"></i></span>
</a>
</div>
</div>
<div id="ccn-admin-userList-btnRefresh" class="control">
<a class="button is-primary">
<span class="icon is-small"><i class="fas fa-sync"></i></span>
</a>
</div>
</div>
<div id="ccn-admin-userList" style="display: flex; flex-flow: column; margin-top: 1.25rem;">
</div>
</div>
</body>
</html>
-156
View File
@@ -1,156 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title id="ccn-pageName"></title>
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.1/css/bulma.min.css">
<script src="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@5.12.1/js/all.min.js"></script>
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/jquery@3.4.1/dist/jquery.js"></script>
<script type="text/javascript"
src="https://cdn.jsdelivr.net/npm/jquery-i18n-properties@1.2.7/jquery.i18n.properties.js"></script>
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/jsrender@1.0.10/jsrender.min.js"></script>
<script type="text/x-jsrender" id="jsrender-tmpl-headerNav" src="/static/tmpl/headerNav.tmpl"></script>
<script type="text/x-jsrender" id="jsrender-tmpl-messagebox" src="/static/tmpl/messagebox.tmpl"></script>
<script type="text/x-jsrender" id="jsrender-tmpl-datetimepicker" src="/static/tmpl/datetimepicker.tmpl"></script>
<script type="text/x-jsrender" id="jsrender-tmpl-calendarItem" src="/static/tmpl/calendarItem.tmpl"></script>
<script type="text/x-jsrender" id="jsrender-tmpl-scheduleItem" src="/static/tmpl/scheduleItem.tmpl"></script>
<script type="text/x-jsrender" id="jsrender-tmpl-displayOwnedItem" src="/static/tmpl/displayOwnedItem.tmpl"></script>
<script type="text/x-jsrender" id="jsrender-tmpl-displaySharedItem" src="/static/tmpl/displaySharedItem.tmpl"></script>
<script type="text/javascript" src="/static/js/localStorageAssist.js"></script>
<script type="text/javascript" src="/static/js/datetime.js"></script>
<script type="text/javascript" src="/static/js/i18n.js"></script>
<script type="text/javascript" src="/static/js/utils.js"></script>
<script type="text/javascript" src="/static/js/api.js"></script>
<script type="text/javascript" src="/static/js/template.js"></script>
<script type="text/javascript" src="/static/js/headerNav.js"></script>
<script type="text/javascript" src="/static/js/tabcontrol.js"></script>
<script type="text/javascript" src="/static/js/messagebox.js"></script>
<script type="text/javascript" src="/static/js/datetimepicker.js"></script>
<script type="text/javascript" src="/static/js/page/calendar.js"></script>
<link rel="stylesheet" href="/static/css/calendar.css">
<link rel="stylesheet" href="/static/css/datetimepicker.css">
</head>
<body>
<div class="container" style="margin-top: 20px;">
<div class="tabs">
<ul>
<li id="tabcontrol-tab-1-1" class="tabcontrol-tab-1"><a
i18n-name="ccn-i18n-calendar-tabcontrol-tabCalendar"></a></li>
<li id="tabcontrol-tab-1-2" class="tabcontrol-tab-1"><a
i18n-name="ccn-i18n-calendar-tabcontrol-tabCollection"></a></li>
<li id="tabcontrol-tab-1-3" class="tabcontrol-tab-1"><a
i18n-name="ccn-i18n-calendar-tabcontrol-tabDisplay"></a></li>
</ul>
</div>
</div>
<div id="tabcontrol-panel-1-1" class="container tabcontrol-panel-1" style="margin-top: 20px;">
<nav class="level is-mobile">
<div class="level-left">
<div class="level-item control">
<a id="ccn-calendar-calendar-btnPrevMonth" class="button">
<span class="icon is-small"><i class="fas fa-chevron-circle-left"></i></span>
</a>
</div>
</div>
<div class="level-item control">
<a id="ccn-calendar-calendar-btnJump" class="button" datetimepicker="1">
<span id="ccn-calendar-calendar-textMonth"></span>
</a>
</div>
<div class="level-right">
<div class="level-item control">
<a id="ccn-calendar-calendar-btnNextMonth" class="button">
<span class="icon is-small"><i class="fas fa-chevron-circle-right"></i></span>
</a>
</div>
</div>
</nav>
<nav class="level is-mobile">
<div class="level-item control">
<a id="ccn-calendar-calendar-btnToday" i18n-name="ccn-i18n-calendar-calendar-today" class="button is-info"></a>
</div>
<div class="level-item control">
<a id="ccn-calendar-calendar-btnAdd" i18n-name="ccn-i18n-calendar-calendar-add" class="button is-primary"></a>
</div>
</nav>
<div id="ccn-calendar-calendarBody" class="card" style="padding: 1.25rem; display: flex; flex-flow: column;">
<div style="margin: 0 0 0.75em 0;">
<div><b i18n-name="ccn-i18n-universal-week-1"></b></div>
<div><b i18n-name="ccn-i18n-universal-week-2"></b></div>
<div><b i18n-name="ccn-i18n-universal-week-3"></b></div>
<div><b i18n-name="ccn-i18n-universal-week-4"></b></div>
<div><b i18n-name="ccn-i18n-universal-week-5"></b></div>
<div><b i18n-name="ccn-i18n-universal-week-6" style="color: red;"></b></div>
<div><b i18n-name="ccn-i18n-universal-week-7" style="color: red;"></b></div>
</div>
</div>
<div class="container" style="padding: 1.25rem; display: flex; flex-flow: column; margin-top: 1.25rem;">
<h1 i18n-name="ccn-i18n-calendar-calendar-scheduleList" class="title"></h1>
<div id="ccn-calendar-scheduleList">
</div>
</div>
</div>
<div id="tabcontrol-panel-1-2" class="container tabcontrol-panel-1" style="margin-top: 20px;">
<div id="ccn-calendar-collection-btnRefresh" class="control" style="margin: 0.75rem;">
<a class="button is-primary">
<span class="icon is-small"><i class="fas fa-sync"></i></span>
</a>
</div>
<h1 i18n-name="ccn-i18n-calendar-owned-list" class="title"></h1>
<div id="ccn-calendar-ownedList" style="display: flex; flex-flow: column; margin-top: 1.25rem; margin-bottom: 1.25rem;">
</div>
<h1 i18n-name="ccn-i18n-calendar-shared-list" class="title"></h1>
<div id="ccn-calendar-sharedList" style="display: flex; flex-flow: column; margin-top: 1.25rem; margin-bottom: 1.25rem;">
</div>
</div>
<div id="tabcontrol-panel-1-3" class="container tabcontrol-panel-1" style="margin-top: 20px;">
<div class="field">
<label class="label" i18n-name="ccn-i18n-calendar-display-firstDayOfWeek"></label>
<div class="control">
<div class="select">
<select id="ccn-calendar-display-firstDayOfWeek">
<option i18n-name="ccn-i18n-universal-week-1"></option>
<option i18n-name="ccn-i18n-universal-week-2"></option>
<option i18n-name="ccn-i18n-universal-week-3"></option>
<option i18n-name="ccn-i18n-universal-week-4"></option>
<option i18n-name="ccn-i18n-universal-week-5"></option>
<option i18n-name="ccn-i18n-universal-week-6"></option>
<option i18n-name="ccn-i18n-universal-week-7"></option>
</select>
</div>
</div>
</div>
<div class="field">
<label class="label" i18n-name="ccn-i18n-calendar-display-subcalendar"></label>
<div class="control">
<div class="select">
<select id="ccn-calendar-display-subcalendar">
<option i18n-name="ccn-i18n-calendar-display-subcalendar-chineseLunisolarCalendar"></option>
</select>
</div>
</div>
</div>
</div>
</body>
</html>
-94
View File
@@ -1,94 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title id="ccn-pageName"></title>
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.1/css/bulma.min.css">
<script src="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@5.12.1/js/all.min.js"></script>
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/jquery@3.4.1/dist/jquery.js"></script>
<script type="text/javascript"
src="https://cdn.jsdelivr.net/npm/jquery-i18n-properties@1.2.7/jquery.i18n.properties.js"></script>
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/jsrender@1.0.10/jsrender.min.js"></script>
<script type="text/x-jsrender" id="jsrender-tmpl-headerNav" src="/static/tmpl/headerNav.tmpl"></script>
<script type="text/x-jsrender" id="jsrender-tmpl-messagebox" src="/static/tmpl/messagebox.tmpl"></script>
<script type="text/x-jsrender" id="jsrender-tmpl-ownedItem" src="/static/tmpl/ownedItem.tmpl"></script>
<script type="text/x-jsrender" id="jsrender-tmpl-sharingItem" src="/static/tmpl/sharingItem.tmpl"></script>
<script type="text/javascript" src="/static/js/localStorageAssist.js"></script>
<script type="text/javascript" src="/static/js/i18n.js"></script>
<script type="text/javascript" src="/static/js/utils.js"></script>
<script type="text/javascript" src="/static/js/api.js"></script>
<script type="text/javascript" src="/static/js/template.js"></script>
<script type="text/javascript" src="/static/js/headerNav.js"></script>
<script type="text/javascript" src="/static/js/tabcontrol.js"></script>
<script type="text/javascript" src="/static/js/messagebox.js"></script>
<script type="text/javascript" src="/static/js/page/collection.js"></script>
<link rel="stylesheet" href="/static/css/collection.css">
</head>
<body>
<div style="margin-top: 20px;">
<div class="container" style="display: flex; flex-flow: column;">
<h1 i18n-name="ccn-i18n-collection-owned-list" class="title"></h1>
<div class="control-list">
<div class="field has-addons">
<div class="control">
<input id="ccn-collection-owned-inputAdd" class="input" type="text">
</div>
<div id="ccn-collection-owned-btnAdd" class="control">
<a class="button is-primary">
<span class="icon is-small"><i class="fas fa-plus"></i></span>
</a>
</div>
</div>
<div id="ccn-collection-owned-btnRefresh" class="control">
<a class="button is-primary">
<span class="icon is-small"><i class="fas fa-sync"></i></span>
</a>
</div>
</div>
<div id="ccn-collection-ownedList" style="display: flex; flex-flow: column; margin-top: 1.25rem;">
</div>
</div>
<div id="ccn-collection-sharing-container" class="container" style="display: flex; flex-flow: column;">
<h1 i18n-name="ccn-i18n-collection-sharing-list" class="title"></h1>
<label class="label"><span i18n-name="ccn-i18n-collection-sharing-editing"></span>
<span id="ccn-collection-sharing-sharingEditing"></span>
</label>
<div class="control-list">
<div class="field has-addons">
<div class="control">
<input id="ccn-collection-sharing-inputAdd" class="input" type="text">
</div>
<div id="ccn-collection-sharing-btnAdd" class="control">
<a class="button is-primary">
<span class="icon is-small"><i class="fas fa-plus"></i></span>
</a>
</div>
</div>
<div id="ccn-collection-sharing-btnRefresh" class="control">
<a class="button is-primary">
<span class="icon is-small"><i class="fas fa-sync"></i></span>
</a>
</div>
</div>
<div id="ccn-collection-sharingList" style="display: flex; flex-flow: column; margin-top: 1.25rem;">
</div>
</div>
</div>
</body>
</html>
-277
View File
@@ -1,277 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title id="ccn-pageName"></title>
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.1/css/bulma.min.css">
<script src="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@5.12.1/js/all.min.js"></script>
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/jquery@3.4.1/dist/jquery.js"></script>
<script type="text/javascript"
src="https://cdn.jsdelivr.net/npm/jquery-i18n-properties@1.2.7/jquery.i18n.properties.js"></script>
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/jsrender@1.0.10/jsrender.min.js"></script>
<!-- if is empty, mean add, otherwise, it is a uuid-->
<meta name="uuid" content="{{uuidPath}}">
<script type="text/x-jsrender" id="jsrender-tmpl-headerNav" src="/static/tmpl/headerNav.tmpl"></script>
<script type="text/x-jsrender" id="jsrender-tmpl-messagebox" src="/static/tmpl/messagebox.tmpl"></script>
<script type="text/x-jsrender" id="jsrender-tmpl-datetimepicker" src="/static/tmpl/datetimepicker.tmpl"></script>
<script type="text/x-jsrender" id="jsrender-tmpl-optionItem" src="/static/tmpl/optionItem.tmpl"></script>
<script type="text/javascript" src="/static/js/localStorageAssist.js"></script>
<script type="text/javascript" src="/static/js/datetime.js"></script>
<script type="text/javascript" src="/static/js/i18n.js"></script>
<script type="text/javascript" src="/static/js/utils.js"></script>
<script type="text/javascript" src="/static/js/api.js"></script>
<script type="text/javascript" src="/static/js/template.js"></script>
<script type="text/javascript" src="/static/js/headerNav.js"></script>
<script type="text/javascript" src="/static/js/messagebox.js"></script>
<script type="text/javascript" src="/static/js/datetimepicker.js"></script>
<script type="text/javascript" src="/static/js/page/event.js"></script>
<link rel="stylesheet" href="/static/css/event.css">
<link rel="stylesheet" href="/static/css/datetimepicker.css">
</head>
<body>
<!-- add is-active in class to show this-->
<div id="ccn-event-eventFormBody" class="container" style="margin-top: 20px;">
<h1 i18n-name="ccn-i18n-event-header" class="title"></h1>
<section class="section">
<div class="field">
<label class="label" i18n-name="ccn-i18n-event-title"></label>
<div class="control">
<input id="ccn-event-inputTitle" class="input" type="text">
</div>
</div>
<div class="field">
<label class="label" i18n-name="ccn-i18n-event-description"></label>
<div class="control">
<textarea id="ccn-event-inputDescription" class="textarea"></textarea>
</div>
</div>
<div class="field">
<label class="label" i18n-name="ccn-i18n-event-color"></label>
<div class="control">
<input id="ccn-event-inputColor" class="input" type="color">
</div>
</div>
<div class="field">
<label class="label" i18n-name="ccn-i18n-event-collection"></label>
<div class="control">
<div class="select">
<select id="ccn-event-inputCollection"></select>
</div>
</div>
</div>
</section>
<section class="section">
<h2 class="subtitle" i18n-name="ccn-i18n-event-startDateTime"></h2>
<a id="ccn-event-btnStartDateTime" class="button" datetimepicker="1">
<span id="ccn-event-btnStartDateTime-text"></span>
</a>
<h2 class="subtitle" i18n-name="ccn-i18n-event-endDateTime"></h2>
<div class="control-list">
<div class="control">
<a id="ccn-event-btnSpot" class="button is-link" i18n-name="ccn-i18n-event-btnSpot"></a>
</div>
<div class="control">
<a id="ccn-event-btnFullDay" class="button is-link" i18n-name="ccn-i18n-event-btnFullDay"></a>
</div>
</div>
<a id="ccn-event-btnEndDateTime" class="button" datetimepicker="2">
<span id="ccn-event-btnEndDateTime-text"></span>
</a>
</section>
<section class="section">
<h2 class="subtitle" i18n-name="ccn-i18n-event-loop"></h2>
<div class="control-list">
<label class="radio">
<input id="ccn-event-radioLoopNever" type="radio" name="loop-method">
<span i18n-name="ccn-i18n-event-loop-never"></span>
</label>
<label class="radio">
<input id="ccn-event-radioLoopDay" type="radio" name="loop-method">
<span i18n-name="ccn-i18n-event-loop-day"></span>
</label>
<label class="radio">
<input id="ccn-event-radioLoopWeek" type="radio" name="loop-method">
<span i18n-name="ccn-i18n-event-loop-week"></span>
</label>
<label class="radio">
<input id="ccn-event-radioLoopMonth" type="radio" name="loop-method">
<span i18n-name="ccn-i18n-event-loop-month"></span>
</label>
<label class="radio">
<input id="ccn-event-radioLoopYear" type="radio" name="loop-method">
<span i18n-name="ccn-i18n-event-loop-year"></span>
</label>
</div>
<div id="ccn-event-boxLoopDay">
<div class="field">
<label class="label" i18n-name="ccn-i18n-event-loopDay-span"></label>
<div class="control">
<input id="ccn-event-loopDay-inputSpan" class="input spanpicker" type="number">
</div>
</div>
</div>
<div id="ccn-event-boxLoopWeek">
<div class="field">
<label class="label" i18n-name="ccn-i18n-event-loopWeek-span"></label>
<div class="control">
<input id="ccn-event-loopWeek-inputSpan" class="input spanpicker" type="number">
</div>
</div>
<div class="field">
<label class="label" i18n-name="ccn-i18n-event-loopWeek-option"></label>
<div class="control-list">
<label class="checkbox">
<input id="ccn-event-loopWeek-check1" type="checkbox">
<span i18n-name="ccn-i18n-universal-week-1"></span>
</label>
<label class="checkbox">
<input id="ccn-event-loopWeek-check2" type="checkbox">
<span i18n-name="ccn-i18n-universal-week-2"></span>
</label>
<label class="checkbox">
<input id="ccn-event-loopWeek-check3" type="checkbox">
<span i18n-name="ccn-i18n-universal-week-3"></span>
</label>
<label class="checkbox">
<input id="ccn-event-loopWeek-check4" type="checkbox">
<span i18n-name="ccn-i18n-universal-week-4"></span>
</label>
<label class="checkbox">
<input id="ccn-event-loopWeek-check5" type="checkbox">
<span i18n-name="ccn-i18n-universal-week-5"></span>
</label>
<label class="checkbox">
<input id="ccn-event-loopWeek-check6" type="checkbox">
<span i18n-name="ccn-i18n-universal-week-6"></span>
</label>
<label class="checkbox">
<input id="ccn-event-loopWeek-check7" type="checkbox">
<span i18n-name="ccn-i18n-universal-week-7"></span>
</label>
</div>
</div>
</div>
<div id="ccn-event-boxLoopMonth">
<div class="field">
<label class="label" i18n-name="ccn-i18n-event-loopMonth-span"></label>
<div class="control">
<input id="ccn-event-loopMonth-inputSpan" class="input spanpicker" type="number">
</div>
</div>
<div class="field">
<label class="label" i18n-name="ccn-i18n-event-loopWeek-option"></label>
<div class="control-list">
<label class="radio">
<input id="ccn-event-loopMonth-radioA" type="radio" name="month-loop-method">
<span id="ccn-event-loopMonth-textA"></span>
</label>
<label class="radio">
<input id="ccn-event-loopMonth-radioB" type="radio" name="month-loop-method">
<span id="ccn-event-loopMonth-textB"></span>
</label>
<label class="radio">
<input id="ccn-event-loopMonth-radioC" type="radio" name="month-loop-method">
<span id="ccn-event-loopMonth-textC"></span>
</label>
<label class="radio">
<input id="ccn-event-loopMonth-radioD" type="radio" name="month-loop-method">
<span id="ccn-event-loopMonth-textD"></span>
</label>
</div>
</div>
</div>
<div id="ccn-event-boxLoopYear">
<div class="field">
<label class="label" i18n-name="ccn-i18n-event-loopYear-span"></label>
<div class="control">
<input id="ccn-event-loopYear-inputSpan" class="input spanpicker" type="number">
</div>
</div>
</div>
</section>
<section id="ccn-event-boxLoopStop" class="section">
<h2 class="subtitle" i18n-name="ccn-i18n-event-loopStop"></h2>
<div class="control-list">
<label class="radio">
<input id="ccn-event-loopStop-radioForever" type="radio" name="loop-end">
<span i18n-name="ccn-i18n-event-loopStop-forever"></span>
</label>
<label class="radio">
<input id="ccn-event-loopStop-radioDateTime" type="radio" name="loop-end">
<span i18n-name="ccn-i18n-event-loopStop-datetime"></span>
</label>
<label class="radio">
<input id="ccn-event-loopStop-radioTimes" type="radio" name="loop-end">
<span i18n-name="ccn-i18n-event-loopStop-times"></span>
</label>
</div>
<div id="ccn-event-boxLoopStopDateTime">
<a id="ccn-event-btnLoopStopDateTime" class="button" datetimepicker="3">
<span id="ccn-event-btnLoopStopDateTime-text"></span>
</a>
</div>
<div id="ccn-event-boxLoopStopTimes">
<div class="field">
<div class="control">
<input id="ccn-event-loopStop-inputTimes" class="input spanpicker" type="number">
</div>
</div>
</div>
</section>
<section id="ccn-event-boxStrictMode" class="section">
<h2 class="subtitle" i18n-name="ccn-i18n-event-strictMode-title"></h2>
<p i18n-name="ccn-i18n-event-strictMode-warning"></p>
<div class="control-list">
<label class="radio">
<input id="ccn-event-strictMode-radioStrict" type="radio" name="timezone">
<span i18n-name="ccn-i18n-event-strictMode-strict"></span>
</label>
<label class="radio">
<input id="ccn-event-strictMode-radioRough" type="radio" name="timezone">
<span i18n-name="ccn-i18n-event-strictMode-rough"></span>
</label>
</div>
</section>
<section id="ccn-event-boxTimezone" class="section">
<h2 class="subtitle" i18n-name="ccn-i18n-event-timezone-title"></h2>
<p i18n-name="ccn-i18n-event-timezone-warning"></p>
<div class="control-list">
<label class="radio">
<input id="ccn-event-timezone-radioKeep" type="radio" name="timezone">
<span i18n-name="ccn-i18n-event-timezone-keep"></span>
</label>
<label class="radio">
<input id="ccn-event-timezone-radioReplace" type="radio" name="timezone">
<span i18n-name="ccn-i18n-event-timezone-replace"></span>
</label>
</div>
</section>
<section class="section">
<div class="control-list">
<a id="ccn-event-btnSubmit" class="button is-success" i18n-name="ccn-i18n-event-btnSubmit"></a>
<a id="ccn-event-btnCancel" class="button" i18n-name="ccn-i18n-event-btnCancel"></a>
</div>
</section>
</div>
</body>
</html>
-35
View File
@@ -1,35 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title id="ccn-pageName"></title>
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.1/css/bulma.min.css">
<script src="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@5.12.1/js/all.min.js"></script>
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/jquery@3.4.1/dist/jquery.js"></script>
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/jquery-i18n-properties@1.2.7/jquery.i18n.properties.js"></script>
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/jsrender@1.0.10/jsrender.min.js"></script>
<script type="text/x-jsrender" id="jsrender-tmpl-headerNav" src="/static/tmpl/headerNav.tmpl"></script>
<script type="text/x-jsrender" id="jsrender-tmpl-messagebox" src="/static/tmpl/messagebox.tmpl"></script>
<script type="text/javascript" src="/static/js/localStorageAssist.js"></script>
<script type="text/javascript" src="/static/js/i18n.js"></script>
<script type="text/javascript" src="/static/js/utils.js"></script>
<script type="text/javascript" src="/static/js/api.js"></script>
<script type="text/javascript" src="/static/js/template.js"></script>
<script type="text/javascript" src="/static/js/headerNav.js"></script>
<script type="text/javascript" src="/static/js/messagebox.js"></script>
<script type="text/javascript" src="/static/js/page/home.js"></script>
</head>
<body>
<div class="container" style="margin-top: 1.25rem;">
<article i18n-name="ccn-i18n-home-desc">
</article>
</div>
</body>
</html>
-60
View File
@@ -1,60 +0,0 @@
<!DOCTYPE html>
<html style="height: 100%; overflow: hidden;">
<head>
<meta charset="utf-8">
<title id="ccn-pageName"></title>
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.1/css/bulma.min.css">
<script src="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@5.12.1/js/all.min.js"></script>
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/jquery@3.4.1/dist/jquery.js"></script>
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/jquery-i18n-properties@1.2.7/jquery.i18n.properties.js"></script>
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/jsrender@1.0.10/jsrender.min.js"></script>
<script type="text/x-jsrender" id="jsrender-tmpl-headerNav" src="/static/tmpl/headerNav.tmpl"></script>
<script type="text/x-jsrender" id="jsrender-tmpl-messagebox" src="/static/tmpl/messagebox.tmpl"></script>
<script type="text/javascript" src="/static/js/localStorageAssist.js"></script>
<script type="text/javascript" src="/static/js/i18n.js"></script>
<script type="text/javascript" src="/static/js/utils.js"></script>
<script type="text/javascript" src="/static/js/api.js"></script>
<script type="text/javascript" src="/static/js/template.js"></script>
<script type="text/javascript" src="/static/js/headerNav.js"></script>
<script type="text/javascript" src="/static/js/messagebox.js"></script>
<script type="text/javascript" src="/static/js/page/login.js"></script>
</head>
<body style="height: 100%; display: flex; flex-flow: column;">
<div style="height: 100%; width: 100%; display: flex; justify-content: center; align-items: center;">
<div class="card" style="padding: 1.25rem;">
<div class="field">
<label class="label" i18n-name="ccn-i18n-login-form-username"></label>
<div class="control has-icons-left has-icons-right">
<input id="ccn-login-form-username" class="input" type="text">
<span class="icon is-small is-left">
<i class="fas fa-user"></i>
</span>
</div>
</div>
<div class="field">
<label class="label" i18n-name="ccn-i18n-login-form-password"></label>
<p class="control has-icons-left">
<input id="ccn-login-form-password" class="input" type="password">
<span class="icon is-small is-left">
<i class="fas fa-lock"></i>
</span>
</p>
</div>
<div class="control">
<button id="ccn-login-form-login" class="button is-primary" i18n-name="ccn-i18n-login-form-login"></button>
</div>
</div>
</div>
</body>
</html>
-51
View File
@@ -1,51 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title id="ccn-pageName"></title>
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.1/css/bulma.min.css">
<script src="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@5.12.1/js/all.min.js"></script>
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/jquery@3.4.1/dist/jquery.js"></script>
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/jquery-i18n-properties@1.2.7/jquery.i18n.properties.js"></script>
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/jsrender@1.0.10/jsrender.min.js"></script>
<script type="text/x-jsrender" id="jsrender-tmpl-headerNav" src="/static/tmpl/headerNav.tmpl"></script>
<script type="text/x-jsrender" id="jsrender-tmpl-messagebox" src="/static/tmpl/messagebox.tmpl"></script>
<script type="text/x-jsrender" id="jsrender-tmpl-todoItem" src="/static/tmpl/todoItem.tmpl"></script>
<script type="text/javascript" src="/static/js/localStorageAssist.js"></script>
<script type="text/javascript" src="/static/js/i18n.js"></script>
<script type="text/javascript" src="/static/js/utils.js"></script>
<script type="text/javascript" src="/static/js/api.js"></script>
<script type="text/javascript" src="/static/js/template.js"></script>
<script type="text/javascript" src="/static/js/headerNav.js"></script>
<script type="text/javascript" src="/static/js/messagebox.js"></script>
<script type="text/javascript" src="/static/js/page/todo.js"></script>
<link rel="stylesheet" href="/static/css/todo.css">
</head>
<body>
<div class="container" style="display: flex; flex-flow: column; margin-top: 1.25rem;">
<h1 i18n-name="ccn-i18n-todo-todoList" class="title"></h1>
<div class="control-list">
<div id="ccn-todo-btnAdd" class="control">
<a class="button is-primary"><span class="icon is-small"><i class="fas fa-plus"></i></span></a>
</div>
<div id="ccn-todo-btnRefresh" class="control">
<a class="button is-primary">
<span class="icon is-small"><i class="fas fa-sync"></i></span>
</a>
</div>
</div>
<div id="ccn-todo-todoList" style="display: flex; flex-flow: column; margin-top: 1.25rem;">
</div>
</div>
</body>
</html>
+45 -14
View File
@@ -1,27 +1,43 @@
# coleaf-frontend
This template should help get you started developing with Vue 3 in Vite.
The web frontend of **coconut-leaf** — a light, self-hosted, multi-account calendar system.
This package is a from-scratch rewrite of the legacy JavaScript + jQuery frontend (`frontend-legacy/`) on a modern toolchain.
## Recommended IDE Setup
## Tech Stack
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
- **Framework:** Vue 3 (`<script setup>` + TypeScript)
- **Build tool:** Vite
- **State management:** Pinia (with `pinia-plugin-persistedstate`)
- **Routing:** Vue Router (navigation guards + per-route window titles)
- **Internationalization:** vue-i18n (`en-US` / `zh-CN`, message files under `src/locales/`)
- **Styling:** Bulma 0.9 (imported via SCSS) + Font Awesome icons
- **Linting:** oxlint + ESLint
## Recommended Browser Setup
Requires Node `^20.19` or `>=22.12` (see `package.json``engines`).
- 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/)
## Project Layout
## Type Support for `.vue` Imports in TS
```text
src/
├── api/ # backend client (fetch-based apiWrapper + typed endpoints)
├── components/ # reusable UI (calendar/, collection/, todo/, admin/, DateTimePicker, MessageBox)
├── locales/ # i18n messages (enUS.ts, zhCN.ts)
├── router/ # route table + beforeEach guard (auth check + document.title)
├── stores/ # Pinia stores (token, language)
├── utils/ # datetime recurrence engine, i18n bootstrap, helpers
└── views/ # page-level views (Home, Login, Collection, Todo, Calendar, CalendarEvent, Admin, NotFound)
```
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.
## Development Server
## Customize configuration
`vite.config.ts` is preconfigured:
See [Vite Configuration Reference](https://vite.dev/config/).
- **`base: '/web/'`** — matches the `/web` prefix routed by the production Nginx.
- **Dev port:** `5173`, opens `/web/` on start.
- **API proxy:** `/api/<path>``http://127.0.0.1:8848/<path>` (the local
backend), with the `/api` prefix stripped on the fly.
Start the backend (listening on `127.0.0.1:8848`) before launching the frontend.
## Project Setup
@@ -46,3 +62,18 @@ pnpm build
```sh
pnpm lint
```
## Internationalization
- Translation strings live in `src/locales/enUS.ts` and `src/locales/zhCN.ts`
(keys omit the legacy `ccn-i18n-` prefix).
- The active language is held in the Pinia `language` store (persisted under
the `ccn-i18n` localStorage key) and synced to `i18n.global.locale` via a
watcher in `src/App.vue`.
- Switch languages from the navbar dropdown; all visible text — including the
window title (driven by each route's `meta.title`) — updates immediately.
## License
Source code is licensed under [AGPL v3](https://www.gnu.org/licenses/agpl-3.0.html).
See the repository root for details.
+17
View File
@@ -20,4 +20,21 @@ export default defineConfigWithVueTs(
vueTsConfigs.recommended,
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
// 该前端由旧前端迁移而来,不得已使用 any,因此关闭此规则
{
name: 'app/legacy-no-explicit-any',
rules: {
'@typescript-eslint/no-explicit-any': 'off',
},
},
// views/ 目录下为路由页面级组件,非通用组件,不会与 HTML 原生元素冲突,因此关闭多词组件名检查
{
name: 'app/views-single-word',
files: ['src/views/**/*.vue'],
rules: {
'vue/multi-word-component-names': 'off',
},
},
)
+1 -1
View File
@@ -17,11 +17,11 @@
"@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-i18n": "^11.4.7",
"vue-router": "^5.0.4"
},
"devDependencies": {
+56 -191
View File
@@ -17,9 +17,6 @@ importers:
'@fortawesome/vue-fontawesome':
specifier: ^3.2.0
version: 3.2.0(@fortawesome/fontawesome-svg-core@7.2.0)(vue@3.5.33(typescript@6.0.3))
axios:
specifier: 1.14.0
version: 1.14.0
bulma:
specifier: 0.9.1
version: 0.9.1
@@ -32,6 +29,9 @@ importers:
vue:
specifier: ^3.5.32
version: 3.5.33(typescript@6.0.3)
vue-i18n:
specifier: ^11.4.7
version: 11.4.7(vue@3.5.33(typescript@6.0.3))
vue-router:
specifier: ^5.0.4
version: 5.0.6(@vue/compiler-sfc@3.5.33)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.33(typescript@6.0.3)))(vue@3.5.33(typescript@6.0.3))
@@ -301,6 +301,22 @@ packages:
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'}
'@intlify/core-base@11.4.7':
resolution: {integrity: sha512-MSB/sBKwEWJTILvQIhg2rnIcwPpLayo3wGwvVA+dJTNeUBD9GoqQgAaSOLdI9iOPDHCm9YoVnLqpfzza98MpkQ==}
engines: {node: '>= 22'}
'@intlify/devtools-types@11.4.7':
resolution: {integrity: sha512-GSz+J+hqH+AEpAHIYya6fSufS30OaMnG39HiZX7DmGKi3+aaLvassCfsXENEc4Wr4m68q2YP0QdMdB3D9UeAXg==}
engines: {node: '>= 22'}
'@intlify/message-compiler@11.4.7':
resolution: {integrity: sha512-bHxmh7n94N4N1evADeb7XTkc3jTw6Ki5biMFZVSX6Jmk+iehy8/maeH2XUsBI27rtKIK+Hzc6QnVAKggUwylKw==}
engines: {node: '>= 22'}
'@intlify/shared@11.4.7':
resolution: {integrity: sha512-OtjPZan3No2OZZFnMUiCVsXC6+j+XRwEywaFDk0AoayAbLuPesyDloXhJZLl9JUl5vHZeQUkYSbEA8VX+CWMjg==}
engines: {node: '>= 22'}
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -782,6 +798,9 @@ packages:
'@vue/compiler-ssr@3.5.33':
resolution: {integrity: sha512-IErjYdnj1qIupG5xxiVIYiiRvDhGWV4zuh/RCrwfYpuL+HWQzeU6lCk/nF9r7olWMnjKxCAkOctT2qFWFkzb1A==}
'@vue/devtools-api@6.6.4':
resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==}
'@vue/devtools-api@7.7.9':
resolution: {integrity: sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==}
@@ -879,12 +898,6 @@ packages:
resolution: {integrity: sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==}
engines: {node: '>=20.19.0'}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
axios@1.14.0:
resolution: {integrity: sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==}
balanced-match@4.0.4:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
@@ -920,10 +933,6 @@ packages:
resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
engines: {node: '>=18'}
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
caniuse-lite@1.0.30001791:
resolution: {integrity: sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==}
@@ -935,10 +944,6 @@ packages:
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
engines: {node: '>= 20.19.0'}
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
confbox@0.1.8:
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
@@ -991,18 +996,10 @@ packages:
defu@6.1.7:
resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==}
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
electron-to-chromium@1.5.344:
resolution: {integrity: sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==}
@@ -1013,22 +1010,6 @@ packages:
error-stack-parser-es@1.0.5:
resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==}
es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'}
es-errors@1.3.0:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
es-object-atoms@1.1.1:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'}
es-set-tostringtag@2.1.0:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'}
escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
@@ -1148,39 +1129,15 @@ packages:
flatted@3.4.2:
resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
follow-redirects@1.16.0:
resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
form-data@4.0.5:
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
engines: {node: '>= 6'}
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
gensync@1.0.0-beta.2:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'}
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
get-proto@1.0.1:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
@@ -1189,22 +1146,6 @@ packages:
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
engines: {node: '>=10.13.0'}
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
has-symbols@1.1.0:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
has-tostringtag@1.0.2:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
hasown@2.0.3:
resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==}
engines: {node: '>= 0.4'}
hookable@5.5.3:
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
@@ -1395,10 +1336,6 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
memorystream@0.3.1:
resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==}
engines: {node: '>= 0.10.0'}
@@ -1411,14 +1348,6 @@ packages:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
minimatch@10.2.5:
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
engines: {node: 18 || 20 || >=22}
@@ -1571,10 +1500,6 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
proxy-from-env@2.1.0:
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
engines: {node: '>=10'}
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@@ -1807,6 +1732,12 @@ packages:
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
vue-i18n@11.4.7:
resolution: {integrity: sha512-j6RyshdPPzqLiMAUpnpvZGFPM+rRoWi14Sl5yTsquvoW0/56DWyvhAj2o9TO2YXGvb6teg8T0xrYO9jR3urvdw==}
engines: {node: '>= 22'}
peerDependencies:
vue: ^3.0.0
vue-router@5.0.6:
resolution: {integrity: sha512-9+kmUTGbKMyW9Asoy98IXXYIzrTMT7JDAdpDDeEkorHvybpUvBI2wsrSM5jFOXrFydpzRFJ9vAh+80DN2PGu9w==}
peerDependencies:
@@ -2143,6 +2074,24 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {}
'@intlify/core-base@11.4.7':
dependencies:
'@intlify/devtools-types': 11.4.7
'@intlify/message-compiler': 11.4.7
'@intlify/shared': 11.4.7
'@intlify/devtools-types@11.4.7':
dependencies:
'@intlify/core-base': 11.4.7
'@intlify/shared': 11.4.7
'@intlify/message-compiler@11.4.7':
dependencies:
'@intlify/shared': 11.4.7
source-map-js: 1.2.1
'@intlify/shared@11.4.7': {}
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -2551,6 +2500,8 @@ snapshots:
'@vue/compiler-dom': 3.5.33
'@vue/shared': 3.5.33
'@vue/devtools-api@6.6.4': {}
'@vue/devtools-api@7.7.9':
dependencies:
'@vue/devtools-kit': 7.7.9
@@ -2669,16 +2620,6 @@ snapshots:
'@babel/parser': 7.29.2
ast-kit: 2.2.0
asynckit@0.4.0: {}
axios@1.14.0:
dependencies:
follow-redirects: 1.16.0
form-data: 4.0.5
proxy-from-env: 2.1.0
transitivePeerDependencies:
- debug
balanced-match@4.0.4: {}
baseline-browser-mapping@2.10.23: {}
@@ -2709,11 +2650,6 @@ snapshots:
dependencies:
run-applescript: 7.1.0
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
function-bind: 1.1.2
caniuse-lite@1.0.30001791: {}
chokidar@4.0.3:
@@ -2725,10 +2661,6 @@ snapshots:
dependencies:
readdirp: 5.0.0
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
confbox@0.1.8: {}
confbox@0.2.4: {}
@@ -2766,37 +2698,14 @@ snapshots:
defu@6.1.7: {}
delayed-stream@1.0.0: {}
detect-libc@2.1.2: {}
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
es-errors: 1.3.0
gopd: 1.2.0
electron-to-chromium@1.5.344: {}
entities@7.0.1: {}
error-stack-parser-es@1.0.5: {}
es-define-property@1.0.1: {}
es-errors@1.3.0: {}
es-object-atoms@1.1.1:
dependencies:
es-errors: 1.3.0
es-set-tostringtag@2.1.0:
dependencies:
es-errors: 1.3.0
get-intrinsic: 1.3.0
has-tostringtag: 1.0.2
hasown: 2.0.3
escalade@3.2.0: {}
escape-string-regexp@4.0.0: {}
@@ -2931,41 +2840,11 @@ snapshots:
flatted@3.4.2: {}
follow-redirects@1.16.0: {}
form-data@4.0.5:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.3
mime-types: 2.1.35
fsevents@2.3.3:
optional: true
function-bind@1.1.2: {}
gensync@1.0.0-beta.2: {}
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
es-define-property: 1.0.1
es-errors: 1.3.0
es-object-atoms: 1.1.1
function-bind: 1.1.2
get-proto: 1.0.1
gopd: 1.2.0
has-symbols: 1.1.0
hasown: 2.0.3
math-intrinsics: 1.1.0
get-proto@1.0.1:
dependencies:
dunder-proto: 1.0.1
es-object-atoms: 1.1.1
glob-parent@5.1.2:
dependencies:
is-glob: 4.0.3
@@ -2974,18 +2853,6 @@ snapshots:
dependencies:
is-glob: 4.0.3
gopd@1.2.0: {}
has-symbols@1.1.0: {}
has-tostringtag@1.0.2:
dependencies:
has-symbols: 1.1.0
hasown@2.0.3:
dependencies:
function-bind: 1.1.2
hookable@5.5.3: {}
ignore@5.3.2: {}
@@ -3121,8 +2988,6 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
math-intrinsics@1.1.0: {}
memorystream@0.3.1: {}
merge2@1.4.1: {}
@@ -3132,12 +2997,6 @@ snapshots:
braces: 3.0.3
picomatch: 2.3.2
mime-db@1.52.0: {}
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
minimatch@10.2.5:
dependencies:
brace-expansion: 5.0.5
@@ -3289,8 +3148,6 @@ snapshots:
prelude-ls@1.2.1: {}
proxy-from-env@2.1.0: {}
punycode@2.3.1: {}
quansync@0.2.11: {}
@@ -3519,6 +3376,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
vue-i18n@11.4.7(vue@3.5.33(typescript@6.0.3)):
dependencies:
'@intlify/core-base': 11.4.7
'@intlify/devtools-types': 11.4.7
'@intlify/shared': 11.4.7
'@vue/devtools-api': 6.6.4
vue: 3.5.33(typescript@6.0.3)
vue-router@5.0.6(@vue/compiler-sfc@3.5.33)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.33(typescript@6.0.3)))(vue@3.5.33(typescript@6.0.3)):
dependencies:
'@babel/generator': 7.29.1
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 133 KiB

+12
View File
@@ -21,6 +21,18 @@ div.paperbox-item-words {
word-break: break-all;
}
// 多行卡片样式作用于管理员用户列表项会话令牌项等内部需要纵向堆叠多个字段的卡片
div.paperbox-item-multiline-words {
display: flex;
flex-flow: column;
align-items: flex-start;
flex-grow: 1;
flex-basis: 0;
word-break: break-all;
}
div.paperbox-item-icon {
margin-left: 0.75rem;
}
+17 -11
View File
@@ -1,10 +1,11 @@
<script setup lang="ts">
import { ref } from 'vue';
import { ref, watch } 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';
import { i18n, Language } from './utils/i18n';
const language = useLanguageStore();
const token = useTokenStore();
@@ -13,6 +14,12 @@ const isBurgerActive = ref<boolean>(false);
const messagebox = ref<InstanceType<typeof MessageBox>>();
// Sync the vue-i18n locale with the persisted language store (immediately, so
// the very first render uses the saved language; and on every later change).
watch(() => language.language, (lang) => {
i18n.global.locale.value = lang === Language.English ? 'en-US' : 'zh-CN';
}, { immediate: true });
const logout = async () => {
const tokenStore = useTokenStore();
const rv = await apiCommonLogout(tokenStore.currentToken);
@@ -24,7 +31,7 @@ const logout = async () => {
goToHome();
} else {
// Show logout error.
messagebox.value?.show("Fail to logout due to unknow reason. Consider refreshing page to solve problem.");
messagebox.value?.show(i18n.global.t('js-fail-logout'));
}
}
@@ -55,24 +62,23 @@ const toggleBurger = () => {
<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>
<router-link class="navbar-item" to="/home">{{ $t('header-nav-home') }}</router-link>
<router-link v-if="token.isLoggedIn" class="navbar-item" to="/collection">{{ $t('header-nav-collection') }}</router-link>
<router-link v-if="token.isLoggedIn" class="navbar-item" to="/calendar">{{ $t('header-nav-calendar') }}</router-link>
<router-link v-if="token.isLoggedIn" class="navbar-item" to="/todo">{{ $t('header-nav-todo') }}</router-link>
<router-link v-if="token.isLoggedIn" class="navbar-item" to="/admin">{{ $t('header-nav-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>
<router-link v-if="!token.isLoggedIn" class="button is-primary" to="/login">{{ $t('header-user-login') }}</router-link>
</p>
<p class="navbar-item">
<a v-if="token.isLoggedIn" class="button is-primary" @click="logout">Logout</a>
<a v-if="token.isLoggedIn" class="button is-primary" @click="logout">{{ $t('header-user-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>
<a class="navbar-link">{{ $t('header-language') }}</a>
<div class="navbar-dropdown">
<a @click="language.changeToEnglish()" class="navbar-item">English</a>
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref } from 'vue';
import { i18n } from '@/utils/i18n';
const isVisible = ref(false);
const title = ref<string>("");
@@ -10,7 +11,7 @@ const emit = defineEmits<{
}>()
const show = (_content: string, _title?: string) => {
title.value = _title ?? "Notification";
title.value = _title ?? i18n.global.t('messagebox-title');
content.value = _content;
isVisible.value = true;
}
@@ -42,7 +43,7 @@ defineExpose({
<p>{{ content }}</p>
</div>
<footer class="modal-card-foot">
<button class="button is-success" @click="ok">OK</button>
<button class="button is-success" @click="ok">{{ $t('messagebox-confirm') }}</button>
</footer>
</div>
</div>
@@ -0,0 +1,64 @@
<script setup lang="ts">
const props = defineProps({
uuid: {
type: String,
required: true
},
ua: {
type: String,
required: true
},
ip: {
type: String,
required: true
},
expireOn: {
type: String,
required: true
},
isMe: {
type: Boolean,
required: true
}
})
const emit = defineEmits<{
(e: 'delete', uuid: string): void
}>()
const deleteItem = () => {
emit('delete', props.uuid);
}
</script>
<template>
<div class="paperbox-item card">
<div class="paperbox-item-multiline-words">
<b>{{ uuid }}</b>
<p>
<span>{{ $t('tokenItem-ua') }}</span>
<span>{{ ua }}</span>
</p>
<p>
<span>{{ $t('tokenItem-ip') }}</span>
<span>{{ ip }}</span>
</p>
<p>
<span>{{ $t('tokenItem-expireOn') }}</span>
<span>{{ expireOn }}</span>
</p>
<p v-if="isMe">
<span class="icon is-small"><font-awesome-icon
icon="fas fa-exclamation-triangle"></font-awesome-icon></span>
<span>{{ $t('tokenItem-isMe') }}</span>
</p>
</div>
<div class="paperbox-item-icon control" @click="deleteItem">
<a class="button"><span class="icon is-small"><font-awesome-icon
icon="fas fa-sign-out-alt"></font-awesome-icon></span></a>
</div>
</div>
</template>
<style scoped></style>
@@ -0,0 +1,95 @@
<script setup lang="ts">
import { ref } from 'vue';
const props = defineProps({
username: {
type: String,
required: true
},
isAdmin: {
type: Boolean,
required: true
}
})
const emit = defineEmits<{
(e: 'delete', username: string): void
(e: 'update', username: string, newPassword: string, newIsAdmin: boolean): void
}>()
const isEditing = ref<boolean>(false);
const editingPassword = ref<string>("");
const editingIsAdmin = ref<boolean>(false);
const editItem = () => {
// copy isAdmin to checkbox and clean password box
editingIsAdmin.value = props.isAdmin;
editingPassword.value = "";
// switch to edit mode
isEditing.value = true;
}
const deleteItem = () => {
emit('delete', props.username);
}
const updateItem = () => {
const new_password = editingPassword.value;
const new_isAdmin = editingIsAdmin.value;
// clean editing state
editingPassword.value = "";
// switch to normal mode
isEditing.value = false;
emit('update', props.username, new_password, new_isAdmin);
}
const cancelUpdateItem = () => {
// clean editing state
editingPassword.value = "";
// switch to normal mode
isEditing.value = false;
}
</script>
<template>
<div class="paperbox-item card">
<div class="paperbox-item-multiline-words">
<div class="control" style="display: flex; flex-flow: row; align-items: center;">
<div v-show="isAdmin" class="icon is-small" style="margin-right: 1rem;">
<font-awesome-icon icon="fas fa-wrench"></font-awesome-icon>
</div>
<div>{{ username }}</div>
</div>
<div v-show="isEditing" class="field">
<label class="label">{{ $t('userItem-newPassword') }}</label>
<div class="control">
<input v-model="editingPassword" class="input" type="password">
</div>
</div>
<div v-show="isEditing" class="field">
<label class="checkbox">
<input v-model="editingIsAdmin" type="checkbox"><span>{{ $t('userItem-isAdmin') }}</span>
</label>
</div>
</div>
<div class="paperbox-item-icon control" v-show="!isEditing" @click="editItem">
<a class="button"><span class="icon is-small"><font-awesome-icon icon="fas fa-pen"></font-awesome-icon></span></a>
</div>
<div class="paperbox-item-icon control" v-show="!isEditing" @click="deleteItem">
<a class="button"><span class="icon is-small"><font-awesome-icon
icon="fas fa-trash"></font-awesome-icon></span></a>
</div>
<div class="paperbox-item-icon control" v-show="isEditing" @click="updateItem">
<a class="button"><span class="icon is-small"><font-awesome-icon
icon="fas fa-check"></font-awesome-icon></span></a>
</div>
<div class="paperbox-item-icon control" v-show="isEditing" @click="cancelUpdateItem">
<a class="button"><span class="icon is-small"><font-awesome-icon
icon="fas fa-times"></font-awesome-icon></span></a>
</div>
</div>
</template>
<style scoped></style>
@@ -1,310 +0,0 @@
<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>
@@ -1,67 +1,47 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { DisplayDay } from './types';
import { format } from '@/utils/utils';
import type { CalendarCell } from './types';
import CalendarItem from './CalendarItem.vue';
const props = defineProps<{
cells: DisplayDay[];
weekNames: string[];
}>();
const props = defineProps({
cells: {
type: Array as () => CalendarCell[],
required: true,
},
})
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());
};
// split the flat 42-cell array into 6 rows of 7
const rows = computed<CalendarCell[][]>(() => {
const r: CalendarCell[][] = []
for (let i = 0; i < 6; i++) r.push(props.cells.slice(i * 7, i * 7 + 7))
return r
})
</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 class="calendarGrid card" style="padding: 1.25rem; display: flex; flex-flow: column;">
<div style="margin: 0 0 0.75em 0;">
<div v-for="i in 7" :key="i">
<b :style="i >= 6 ? { color: 'red' } : null">{{ $t('universal-week-' + i) }}</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 v-for="(row, ri) in rows" :key="ri">
<div
v-for="(cell, ci) in row"
:key="ci"
:isCurrentMonth="cell.isCurrentMonth ? 'true' : 'false'"
>
<CalendarItem :cell="cell" />
</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;
.calendarGrid > div:nth-child(n + 2) > div {
border-top: 0 solid black;
border-left: 0 solid black;
border-right: 1px solid black;
border-bottom: 1px solid black;
@@ -74,37 +54,28 @@ const overflowText = (count: number): string => {
overflow: hidden;
}
.calendar-grid-cell.not-current-month {
.calendarGrid > div:nth-child(n + 2) > div:nth-child(1) {
border-left: 1px solid black;
}
.calendarGrid > div:nth-child(2) > div {
border-top: 1px solid black;
}
.calendarGrid > div > div {
flex-grow: 1;
flex-basis: 0;
flex-shrink: 0;
overflow: hidden;
}
.calendarGrid > div > div[isCurrentMonth=false] {
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;
.calendarGrid > div {
display: flex;
flex-flow: row;
}
</style>
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { CalendarCell } from './types';
import { i18n } from '@/utils/i18n';
const props = defineProps({
cell: {
type: Object as () => CalendarCell,
required: true,
},
})
interface EventBoxSlot {
filled: boolean
color: string
}
// The grid counts ALL events in the cell (visibility is ignored here, matching
// legacy behaviour). Render the (up to) 4 coloured event-box slots.
const slots = computed<EventBoxSlot[]>(() => {
const events = props.cell.events
const arr: EventBoxSlot[] = []
for (let i = 0; i < 4; i++) {
if (i < events.length) {
arr.push({ filled: true, color: events[i]!.color })
} else {
arr.push({ filled: false, color: '' })
}
}
return arr
})
const moreLabel = computed<string | null>(() => {
const len = props.cell.events.length
return len > 4 ? i18n.global.t('calendar-calendar-stripedEvents', [len]) : null
})
</script>
<template>
<p><b>{{ cell.day }}</b><span></span></p>
<div
v-for="(slot, i) in slots"
:key="i"
class="calendarItem-eventBox"
:enableDisplay="slot.filled ? 'true' : 'false'"
:style="slot.filled ? { background: slot.color } : null"
></div>
<p v-if="moreLabel !== null">{{ moreLabel }}</p>
<p v-else>&nbsp;</p>
</template>
<style scoped>
div.calendarItem-eventBox {
border: 1px solid black;
border-radius: 2px;
margin-top: 0.2rem;
height: 0.75rem;
width: 100%;
}
div.calendarItem-eventBox[enableDisplay=true] {
visibility: visible;
}
div.calendarItem-eventBox[enableDisplay=false] {
visibility: hidden;
}
</style>
@@ -1,36 +0,0 @@
<script setup lang="ts">
defineProps<{
name: string;
subtitle?: string;
isShow: boolean;
}>();
const emit = defineEmits<{
(e: 'toggle'): void
}>();
</script>
<template>
<div class="paperbox-item card">
<div class="paperbox-item-words">
<p v-if="subtitle === undefined"><b>{{ name }}</b></p>
<div v-else>
<b>{{ name }}</b>
<p>
<span>Shared by: </span>
<span>{{ subtitle }}</span>
</p>
</div>
</div>
<div class="paperbox-item-icon control" @click="emit('toggle')">
<a class="button">
<span class="icon is-small">
<font-awesome-icon :icon="isShow ? 'fas fa-eye' : 'fas fa-eye-slash'"></font-awesome-icon>
</span>
</a>
</div>
</div>
</template>
<style scoped></style>
@@ -0,0 +1,38 @@
<script setup lang="ts">
defineProps({
name: {
type: String,
required: true,
},
isVisible: {
type: Boolean,
required: true,
},
})
const emit = defineEmits<{
(e: 'toggle'): void
}>()
const toggle = () => {
emit('toggle');
}
</script>
<template>
<div class="paperbox-item card">
<div class="paperbox-item-words">
<p>{{ name }}</p>
</div>
<div class="paperbox-item-icon control" v-show="isVisible" @click="toggle">
<a class="button"><span class="icon is-small"><font-awesome-icon icon="fas fa-eye"></font-awesome-icon></span></a>
</div>
<div class="paperbox-item-icon control" v-show="!isVisible" @click="toggle">
<a class="button"><span class="icon is-small"><font-awesome-icon
icon="fas fa-eye-slash"></font-awesome-icon></span></a>
</div>
</div>
</template>
<style scoped></style>
@@ -0,0 +1,81 @@
<script setup lang="ts">
import type { ScheduleEventItem } from './types';
defineProps({
event: {
type: Object as () => ScheduleEventItem,
required: true,
},
})
const emit = defineEmits<{
(e: 'edit'): void
}>()
const onClick = () => {
emit('edit');
}
</script>
<template>
<div class="schedule-event-outter card" @click="onClick">
<div class="schedule-event-color" :style="{ background: event.color }">&nbsp;</div>
<div class="schedule-event-inner">
<div class="schedule-event-words">
<p class="level-item"><b>{{ event.title }}</b></p>
<p class="level-item">{{ event.description }}</p>
<p class="level-item"><span>{{ event.start }}</span>-<span>{{ event.end }}</span></p>
<p v-if="event.loopText !== ''">
<span class="icon is-small"><font-awesome-icon icon="fas fa-retweet"></font-awesome-icon></span>
<span>{{ event.loopText }}</span>
</p>
</div>
<div class="schedule-event-icon">
<span v-if="event.isLocked" class="icon is-small"><font-awesome-icon
icon="fas fa-lock"></font-awesome-icon></span>
<span v-if="event.timezoneWarning" class="icon is-small"><font-awesome-icon
icon="fas fa-globe"></font-awesome-icon></span>
</div>
</div>
</div>
</template>
<style scoped>
.schedule-event-outter {
display: flex;
flex-flow: row;
align-items: flex-start;
margin-bottom: 1.25rem;
}
.schedule-event-inner {
display: flex;
flex-flow: row;
align-items: flex-start;
flex-grow: 1;
padding: 1.25rem;
}
.schedule-event-words {
display: flex;
flex-flow: column;
align-items: flex-start;
flex-grow: 1;
flex-basis: 0;
word-break: break-all;
}
.schedule-event-icon {
margin-left: 0.75rem;
}
.schedule-event-color {
width: 0.75rem;
align-self: stretch;
}
</style>
@@ -1,48 +1,34 @@
<script setup lang="ts">
import type { DisplayDay } from './types';
import { MONTH_NAMES, WEEK_NAMES } from '@/utils/calendar-names';
import { universalGetMonth, universalGetDayOfWeek } from '@/utils/i18n';
import type { CalendarCell } from './types';
import ScheduleItem from './ScheduleItem.vue';
defineProps<{
days: DisplayDay[];
}>();
defineProps({
cells: {
type: Array as () => CalendarCell[],
required: true,
},
})
const emit = defineEmits<{
(e: 'event-click', uuid: string): void
}>();
(e: 'edit', uuid: string): void
}>()
const onEdit = (uuid: string) => {
emit('edit', uuid);
}
</script>
<template>
<div class="schedule-list">
<div v-for="(day, di) in days" :key="di" class="schedule-day container">
<div>
<div v-for="(cell, i) in cells" :key="i" class="schedule-day container">
<div class="schedule-day-words">
<b>{{ MONTH_NAMES[day.month - 1] }}</b>
<b>{{ day.day }}</b>
<b>{{ WEEK_NAMES[day.dayOfWeek - 1] }}</b>
<b>{{ universalGetMonth(cell.month - 1) }}</b>&nbsp;<b>{{ cell.day }}</b>&nbsp;<b>{{
universalGetDayOfWeek(cell.dayOfWeek - 1) }}</b>
</div>
<div class="schedule-event-list">
<template v-for="(ev, ei) in day.events" :key="ei">
<div v-if="ev.isVisible" class="schedule-event-outter card" @click="emit('event-click', ev.uuid)">
<div class="schedule-event-color" :style="{ background: ev.color }"></div>
<div class="schedule-event-inner">
<div class="schedule-event-words">
<p class="level-item"><b>{{ ev.title }}</b></p>
<p class="level-item">{{ ev.description }}</p>
<p class="level-item"><span>{{ ev.start }}</span>-<span>{{ ev.end }}</span></p>
<p v-if="ev.loopText !== ''">
<span class="icon is-small"><font-awesome-icon icon="fas fa-retweet"></font-awesome-icon></span>
<span>{{ ev.loopText }}</span>
</p>
</div>
<div class="schedule-event-icon">
<span v-if="ev.isLocked" class="icon is-small">
<font-awesome-icon icon="fas fa-lock"></font-awesome-icon>
</span>
<span v-if="ev.timezoneWarning" class="icon is-small">
<font-awesome-icon icon="fas fa-globe"></font-awesome-icon>
</span>
</div>
</div>
</div>
<template v-for="(ev, j) in cell.events" :key="j">
<ScheduleItem v-if="ev.isVisible" :event="ev" @edit="onEdit(ev.uuid)" />
</template>
</div>
</div>
@@ -60,53 +46,12 @@ const emit = defineEmits<{
margin-bottom: 0.75rem;
}
.schedule-day-words b {
margin-right: 0.5rem;
}
.schedule-event-list {
display: flex;
flex-flow: column;
}
.schedule-event-outter {
display: flex;
flex-flow: row;
align-items: flex-start;
margin-bottom: 1.25rem;
cursor: pointer;
}
.schedule-event-inner {
display: flex;
flex-flow: row;
align-items: flex-start;
padding: 1.25rem;
}
.schedule-event-words {
display: flex;
flex-flow: column;
align-items: flex-start;
flex-grow: 1;
flex-basis: 0;
word-break: break-all;
}
.schedule-event-icon {
margin-left: 0.75rem;
}
.schedule-event-color {
width: 0.75rem;
height: 100%;
min-height: 2rem;
}
.schedule-list .schedule-day:nth-child(n+2) {
border-top: 1px solid rgba(219, 219, 219, .5);
.schedule-day:nth-child(n + 2) {
border-top: 1px solid rgba(219, 219, 219, 0.5);
}
</style>
@@ -0,0 +1,46 @@
<script setup lang="ts">
defineProps({
name: {
type: String,
required: true,
},
username: {
type: String,
required: true,
},
isVisible: {
type: Boolean,
required: true,
},
})
const emit = defineEmits<{
(e: 'toggle'): void
}>()
const toggle = () => {
emit('toggle');
}
</script>
<template>
<div class="paperbox-item card">
<div class="paperbox-item-words">
<b>{{ name }}</b>
<p>
<span>{{ $t('sharedItem-sharedBy') }}</span>
<span>{{ username }}</span>
</p>
</div>
<div class="paperbox-item-icon control" v-show="isVisible" @click="toggle">
<a class="button"><span class="icon is-small"><font-awesome-icon icon="fas fa-eye"></font-awesome-icon></span></a>
</div>
<div class="paperbox-item-icon control" v-show="!isVisible" @click="toggle">
<a class="button"><span class="icon is-small"><font-awesome-icon
icon="fas fa-eye-slash"></font-awesome-icon></span></a>
</div>
</div>
</template>
<style scoped></style>
+22 -23
View File
@@ -1,26 +1,25 @@
/** A single analyzed event occurrence placed into a calendar day cell. */
export interface DisplayEvent {
uuid: string;
belongTo: string;
title: string;
description: string;
color: string;
isVisible: boolean;
isLocked: boolean;
loopText: string;
timezoneWarning: boolean;
start: string;
end: string;
/** A single expanded event occurrence placed into a calendar cell / schedule row. */
export interface ScheduleEventItem {
uuid: string
belongTo: string
title: string
description: string
color: string
isVisible: boolean
isLocked: boolean
loopText: string
timezoneWarning: boolean
start: string
end: string
}
/** A single day cell in the 6x7 calendar grid, carrying its events. */
export interface DisplayDay {
/** 1-12 */
month: number;
day: number;
/** 1-7, Monday = 1 */
dayOfWeek: number;
isCurrentMonth: boolean;
subcalendar: string;
events: DisplayEvent[];
/** One cell of the 6x7 month grid (also one day group in the schedule list). */
export interface CalendarCell {
/** 1-based month. */
month: number
day: number
/** 1-based day of week, Monday = 1 ... Sunday = 7. */
dayOfWeek: number
isCurrentMonth: boolean
events: ScheduleEventItem[]
}

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