├── sql ├── update │ ├── 20200815.sql │ ├── 20200920.sql │ ├── 20200603.sql │ └── 20200712.sql ├── sqlite │ ├── t_context.sql │ ├── t_remind_log.sql │ ├── task_queue.sql │ ├── t_task.sql │ └── t_remind.sql ├── cuckoo-sqlite.sql └── cuckoo-mysql.sql ├── contrib ├── alfred │ ├── config.js │ ├── package.json │ ├── cuckoo-search.js │ ├── show-remind-log.js │ ├── delay-task-remind.js │ ├── following.js │ ├── create-task.js │ ├── pre-delay-task-remind.js │ ├── callback.js │ └── lib │ │ └── index.js └── emacs │ └── org-cuckoo.el ├── lib └── plugin │ └── egg-sqlite │ ├── package.json │ └── app.js ├── docs ├── alerterNotifyExample.jpg ├── AlfredWorkflowExample.gif ├── EmacsExtensionExample.gif └── noun_Wall_Clock_1481965.png ├── .eslintignore ├── script ├── get_current_context.scpt └── pre-push ├── typings ├── app │ ├── index.d.ts │ ├── controller │ │ └── index.d.ts │ └── service │ │ └── index.d.ts └── config │ ├── index.d.ts │ └── plugin.d.ts ├── app ├── lib │ ├── reminder.js │ ├── contextDetector │ │ └── controlPlane.js │ ├── reminder │ │ ├── applescript.js │ │ ├── node-notifier.js │ │ └── alerter.js │ ├── task.js │ ├── repeat.js │ └── remind.js ├── service │ ├── remind-log.js │ ├── gateway │ │ └── shell.js │ ├── server-chan.js │ ├── icon.js │ ├── context.js │ ├── remind-log-repository.js │ ├── context-repository.js │ ├── queue-redis.js │ ├── remind.js │ ├── queue.js │ ├── remind-repository.js │ ├── task-repository.js │ └── task.js ├── schedule │ ├── sync.js │ └── poll.js ├── controller │ ├── remind-log.js │ ├── icon.js │ ├── context.js │ ├── shortcut │ │ └── task.js │ ├── queue.js │ ├── page │ │ └── task.js │ ├── remind.js │ └── task.js ├── public │ ├── index.css │ ├── detail.html │ ├── page-task.js │ ├── index.html │ ├── index.js │ └── css │ │ └── main.css ├── router.js └── view │ └── page-task.nj ├── .gitignore ├── appveyor.yml ├── .travis.yml ├── jsconfig.json ├── config ├── plugin.js └── config.default.js ├── test ├── app │ ├── lib │ │ ├── repeat.test.js │ │ └── reminder │ │ │ └── alerter.test.js │ ├── controller │ │ ├── task.test.js │ │ ├── queue.test.js │ │ ├── shortcut │ │ │ └── task.test.js │ │ └── remind.test.js │ └── service │ │ ├── context-repository.test.js │ │ ├── remind.test.js │ │ ├── queue.test.js │ │ └── task.test.js └── contrib │ └── alfred │ └── callback.test.js ├── .autod.conf.js ├── .eslintrc ├── package.json ├── app.js ├── README.md ├── README.zh-CN.md └── CHANGELOG.md /sql/update/20200815.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE t_remind ADD COLUMN repeat_type TEXT; -------------------------------------------------------------------------------- /sql/update/20200920.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE t_remind ADD COLUMN context_id INTEGER; -------------------------------------------------------------------------------- /sql/update/20200603.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE t_remind ADD COLUMN restricted_wdays INTEGER; 2 | -------------------------------------------------------------------------------- /contrib/alfred/config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | origin: 'http://localhost:7001' 3 | }; 4 | -------------------------------------------------------------------------------- /lib/plugin/egg-sqlite/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "eggPlugin": { 3 | "name": "sqlite" 4 | } 5 | } -------------------------------------------------------------------------------- /docs/alerterNotifyExample.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Liutos/cuckoo/HEAD/docs/alerterNotifyExample.jpg -------------------------------------------------------------------------------- /docs/AlfredWorkflowExample.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Liutos/cuckoo/HEAD/docs/AlfredWorkflowExample.gif -------------------------------------------------------------------------------- /docs/EmacsExtensionExample.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Liutos/cuckoo/HEAD/docs/EmacsExtensionExample.gif -------------------------------------------------------------------------------- /docs/noun_Wall_Clock_1481965.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Liutos/cuckoo/HEAD/docs/noun_Wall_Clock_1481965.png -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | coverage 2 | app/public/fullcalendar-5.3.0/ 3 | app/public/js/ 4 | app/public/moment-with-locales.min.js -------------------------------------------------------------------------------- /sql/update/20200712.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE t_remind ADD COLUMN task_id INTEGER; 2 | ALTER TABLE task_queue ADD COLUMN remind_id INTEGER; -------------------------------------------------------------------------------- /sql/sqlite/t_context.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE t_context ( 2 | id INTEGER PRIMARY KEY, 3 | name TEXT, 4 | create_at TEXT, 5 | update_at TEXT 6 | ); 7 | -------------------------------------------------------------------------------- /script/get_current_context.scpt: -------------------------------------------------------------------------------- 1 | tell application "ControlPlane" 2 | set context to (get current context) 3 | do shell script "echo " & quoted form of context 4 | end tell -------------------------------------------------------------------------------- /typings/app/index.d.ts: -------------------------------------------------------------------------------- 1 | // This file is created by egg-ts-helper@1.25.8 2 | // Do not modify this file!!!!!!!!! 3 | 4 | import 'egg'; 5 | export * from 'egg'; 6 | export as namespace Egg; 7 | -------------------------------------------------------------------------------- /sql/sqlite/t_remind_log.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE t_remind_log ( 2 | id INTEGER PRIMARY KEY, 3 | plan_alarm_at INTEGER, 4 | real_alarm_at INTEGER, 5 | task_id INTEGER, 6 | create_at TEXT, 7 | update_at TEXT 8 | ); 9 | -------------------------------------------------------------------------------- /sql/sqlite/task_queue.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE task_queue ( 2 | create_at INTEGER, 3 | id INTEGER PRIMARY KEY, 4 | next_trigger_time INTEGER, 5 | remind_id INTEGER, 6 | task_id INTEGER, 7 | update_at INTEGER 8 | ); 9 | -------------------------------------------------------------------------------- /app/lib/reminder.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | class Reminder { 4 | static create(type, shellGateway) { 5 | const clz = require(`./reminder/${type}`); 6 | return new clz(shellGateway); 7 | } 8 | } 9 | 10 | module.exports = Reminder; 11 | -------------------------------------------------------------------------------- /sql/sqlite/t_task.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE t_task ( 2 | id INTEGER PRIMARY KEY, 3 | brief TEXT, 4 | context_id INTEGER, 5 | detail TEXT, 6 | device TEXT, 7 | icon TEXT, 8 | icon_file TEXT, 9 | state TEXT, 10 | create_at TEXT, 11 | update_at TEXT 12 | ); 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | logs/ 2 | npm-debug.log 3 | yarn-error.log 4 | node_modules/ 5 | package-lock.json 6 | yarn.lock 7 | coverage/ 8 | .idea/ 9 | run/ 10 | .DS_Store 11 | *.sw* 12 | *.un~ 13 | config.local.js 14 | app/public/icon/ 15 | .nyc_output/ 16 | .github/ 17 | fullcalendar-5.3.0/ -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | environment: 2 | matrix: 3 | - nodejs_version: '8' 4 | 5 | install: 6 | - ps: Install-Product node $env:nodejs_version 7 | - npm i npminstall && node_modules\.bin\npminstall 8 | 9 | test_script: 10 | - node --version 11 | - npm --version 12 | - npm run test 13 | 14 | build: off 15 | -------------------------------------------------------------------------------- /sql/sqlite/t_remind.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE t_remind ( 2 | id INTEGER PRIMARY KEY, 3 | context_id INTEGER, 4 | duration INTEGER, 5 | repeat_type TEXT, 6 | restricted_hours INTEGER, 7 | restricted_wdays INTEGER, 8 | task_id INTEGER, 9 | timestamp INTEGER, 10 | create_at TEXT, 11 | update_at TEXT 12 | ); 13 | -------------------------------------------------------------------------------- /script/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 在推送代码前运行单元测试 3 | # 使用方法:将这个文件复制到项目根目录的 .git/hooks/ 目录下即可,示例代码: cp ./script/pre-push .git/hooks/pre-push 4 | echo 'in pre-push script' 5 | npm run test-local 6 | if [ "${?}" = '0' ]; 7 | then 8 | echo '单元测试通过' 9 | else 10 | echo '单元测试运行失败' 11 | exit 1 12 | fi 13 | echo 'after pre-push script' -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '8' 4 | before_install: 5 | - export TZ=Asia/Shanghai 6 | - date 7 | install: 8 | - npm i 9 | before_script: 10 | - echo "Asia/Shanghai" | sudo tee /etc/timezone 11 | - sudo dpkg-reconfigure -f noninteractive tzdata 12 | script: 13 | - npm run ci 14 | after_script: 15 | - npm i codecov && codecov 16 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=759670 3 | // for the documentation about the jsconfig.json format 4 | "compilerOptions": { 5 | "allowJs": true, 6 | "target": "es6" 7 | }, 8 | "exclude": [ 9 | "node_modules", 10 | "bower_components", 11 | "jspm_packages", 12 | "tmp", 13 | "temp" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /contrib/alfred/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cuckoo", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "cuckoo-search.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "co-request": "1.0.0", 13 | "dateformat": "3.0.3" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /config/plugin.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | 5 | // had enabled by egg 6 | // exports.static = true; 7 | exports.nunjucks = { 8 | enable: true, 9 | package: 'egg-view-nunjucks', 10 | }; 11 | 12 | exports.sqlite = { 13 | enable: true, 14 | path: path.join(__dirname, '../lib/plugin/egg-sqlite'), 15 | }; 16 | 17 | exports.validate = { 18 | enable: true, 19 | package: 'egg-validate', 20 | }; 21 | -------------------------------------------------------------------------------- /typings/config/index.d.ts: -------------------------------------------------------------------------------- 1 | // This file is created by egg-ts-helper@1.25.8 2 | // Do not modify this file!!!!!!!!! 3 | 4 | import 'egg'; 5 | import { EggAppConfig } from 'egg'; 6 | import ExportConfigDefault = require('../../config/config.default'); 7 | type ConfigDefault = ReturnType; 8 | type NewEggAppConfig = ConfigDefault; 9 | declare module 'egg' { 10 | interface EggAppConfig extends NewEggAppConfig { } 11 | } -------------------------------------------------------------------------------- /app/lib/contextDetector/controlPlane.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const shell = require('shelljs'); 4 | 5 | const path = require('path'); 6 | 7 | class ControlPlaneDetector { 8 | static getCurrent() { 9 | const script = path.resolve(__dirname, '../../../script/get_current_context.scpt'); 10 | const command = `osascript ${script}`; 11 | return shell.exec(command, { silent: true }).stdout.trim(); 12 | } 13 | } 14 | 15 | module.exports = ControlPlaneDetector; 16 | -------------------------------------------------------------------------------- /app/service/remind-log.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Service = require('egg').Service; 4 | 5 | class RemindLogService extends Service { 6 | async create({ plan_alarm_at, real_alarm_at, task_id }) { 7 | await this.ctx.service.remindLogRepository.create({ plan_alarm_at, real_alarm_at, task_id }); 8 | } 9 | 10 | async search(query) { 11 | return await this.ctx.service.remindLogRepository.search(query); 12 | } 13 | } 14 | 15 | module.exports = RemindLogService; 16 | -------------------------------------------------------------------------------- /app/service/gateway/shell.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Service = require('egg').Service; 4 | const shell = require('shelljs'); 5 | 6 | class ShellGateway extends Service { 7 | async exec(command, options) { 8 | return await new Promise(resolve => { 9 | shell.exec(command, options, (code, stdout, stderr) => { 10 | resolve({ 11 | code, 12 | stderr, 13 | stdout 14 | }); 15 | }); 16 | }); 17 | } 18 | } 19 | 20 | module.exports = ShellGateway; 21 | -------------------------------------------------------------------------------- /test/app/lib/repeat.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Repeat = require('../../../app/lib/repeat'); 4 | 5 | const { assert } = require('egg-mock/bootstrap'); 6 | 7 | describe('test/app/lib/repeat.test.js', () => { 8 | it('计算monthly重复模式下的下一个触发时刻', async () => { 9 | const repeat = new Repeat({ 10 | type: 'monthly' 11 | }); 12 | // FIXME: 如果可以控制nextTimestamp内部的Date.now()的结果的话,这个测试用例就可以写得更好了。 13 | assert(repeat.nextTimestamp(1596805980 * 1000) >= (new Date('2020-10-07 21:13:00')).getTime()); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /.autod.conf.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | write: true, 5 | prefix: '^', 6 | plugin: 'autod-egg', 7 | test: [ 8 | 'test', 9 | 'benchmark', 10 | ], 11 | dep: [ 12 | 'egg', 13 | 'egg-scripts', 14 | ], 15 | devdep: [ 16 | 'egg-ci', 17 | 'egg-bin', 18 | 'egg-mock', 19 | 'autod', 20 | 'autod-egg', 21 | 'eslint', 22 | 'eslint-config-egg', 23 | 'webstorm-disable-index', 24 | ], 25 | exclude: [ 26 | './test/fixtures', 27 | './dist', 28 | ], 29 | }; 30 | 31 | -------------------------------------------------------------------------------- /app/service/server-chan.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Service = require('egg').Service; 4 | 5 | class ServerChanService extends Service { 6 | async send(options) { 7 | const { app } = this; 8 | const { config } = app; 9 | const { desp, text } = options; 10 | 11 | const { sckey } = config.mobilePhone.push.serverChan; 12 | await app.curl(`https://sc.ftqq.com/${sckey}.send`, { 13 | data: { 14 | desp, 15 | text, 16 | }, 17 | }); 18 | } 19 | } 20 | 21 | module.exports = ServerChanService; 22 | -------------------------------------------------------------------------------- /app/schedule/sync.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 调用/task/sync接口,在数据库和队列间同步待提醒的任务。 3 | */ 4 | 'use strict'; 5 | 6 | module.exports = app => { 7 | return { 8 | schedule: { 9 | cron: app.config.schedule.sync.cron, 10 | type: 'worker', 11 | }, 12 | async task(ctx) { 13 | const { logger } = ctx; 14 | const { config: { cluster: { listen: { port } } } } = app; 15 | await ctx.curl(`http://localhost:${port}/task/sync`, { 16 | method: 'POST' 17 | }); 18 | logger.info('任务同步完毕。'); 19 | } 20 | }; 21 | }; 22 | -------------------------------------------------------------------------------- /app/controller/remind-log.js: -------------------------------------------------------------------------------- 1 | const Controller = require('egg').Controller; 2 | 3 | class RemindLogController extends Controller { 4 | async search() { 5 | const { ctx, service } = this; 6 | const { query } = ctx; 7 | 8 | const { limit = 10, sort = 'create_at:desc', task_id } = query; 9 | const remindLogs = await service.remindLog.search({ 10 | limit, 11 | sort, 12 | task_id 13 | }); 14 | 15 | ctx.body = { 16 | remindLogs, 17 | }; 18 | } 19 | } 20 | 21 | module.exports = RemindLogController; 22 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true 4 | }, 5 | "extends": "eslint-config-egg", 6 | "globals": { 7 | "FullCalendar": "readonly", 8 | "Handlebars": "readonly", 9 | "axios": "readonly" 10 | }, 11 | "rules": { 12 | "array-bracket-spacing": ["off"], 13 | "arrow-parens": ["off"], 14 | "comma-dangle": ["off"], 15 | "default-case": ["off"], 16 | "dot-notation": ["off"], 17 | "newline-per-chained-call": ["off"], 18 | "no-alert": ["off"], 19 | "no-unused-vars": ["error"], 20 | "prefer-const": ["off"], 21 | "space-before-function-paren": ["off"], 22 | "strict": ["off"], 23 | "valid-jsdoc": ["off"] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/schedule/poll.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = app => { 4 | return { 5 | schedule: { 6 | interval: app.config.schedule.poll.interval, 7 | type: 'worker', 8 | }, 9 | async task(ctx) { 10 | const { service } = ctx; 11 | 12 | let message = await service.queue.poll(); 13 | while (message) { 14 | const { 15 | remind_id: remindId, 16 | score: alarmAt, 17 | } = message; 18 | const remind = await service.remind.get(remindId); 19 | setImmediate(async () => { 20 | await service.task.remind(remind.taskId, alarmAt, remind); 21 | }); 22 | message = await service.queue.poll(); 23 | } 24 | } 25 | }; 26 | }; 27 | -------------------------------------------------------------------------------- /test/app/controller/task.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { app, assert } = require('egg-mock/bootstrap'); 4 | 5 | let taskId = null; 6 | 7 | describe('test/app/controller/task.test.js', () => { 8 | it('创建任务', async function () { 9 | app.mockCsrf(); 10 | const response = await app.httpRequest() 11 | .post('/task') 12 | .send({ 13 | brief: 'test', 14 | detail: '', 15 | device: null 16 | }) 17 | .expect(201); 18 | 19 | const { body } = response; 20 | assert(body); 21 | assert(body.task); 22 | taskId = body.task.id; 23 | }); 24 | 25 | it('更新任务', async function () { 26 | app.mockCsrf(); 27 | await app.httpRequest() 28 | .patch(`/task/${taskId}`) 29 | .send({ 30 | detail: '' 31 | }) 32 | .expect(204); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /app/controller/icon.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 与图标有关的功能 3 | */ 4 | 'use strict'; 5 | 6 | const Controller = require('egg').Controller; 7 | 8 | const fs = require('fs'); 9 | const path = require('path'); 10 | 11 | class ContextController extends Controller { 12 | async uploadFile() { 13 | const { ctx } = this; 14 | 15 | const stream = await ctx.getFileStream(); 16 | const dir = path.resolve(__dirname, '../public/icon/'); 17 | if (!fs.existsSync(dir)) { 18 | fs.mkdirSync(dir); 19 | } 20 | const target = path.resolve(dir, stream.filename); 21 | stream.pipe(fs.createWriteStream(target)); 22 | await new Promise(resolve => { 23 | stream.on('end', () => { 24 | resolve(); 25 | }); 26 | }); 27 | 28 | ctx.body = { 29 | target 30 | }; 31 | } 32 | } 33 | 34 | module.exports = ContextController; 35 | -------------------------------------------------------------------------------- /app/public/index.css: -------------------------------------------------------------------------------- 1 | #followingArea { 2 | display: flex; 3 | flex-direction: column; 4 | } 5 | 6 | #outmostContainer { 7 | display: flex; 8 | flex-direction: row; 9 | } 10 | 11 | #query { 12 | border: 3px solid #00B4CC; 13 | border-radius: 5px 0 0 5px; 14 | border-right: none; 15 | /* height: 20px; */ 16 | outline: none; 17 | padding: 5px; 18 | width: 100%; 19 | } 20 | 21 | #searchButton { 22 | background: #00B4CC; 23 | border: 1px solid #00B4CC; 24 | border-radius: 0 5px 5px 0; 25 | cursor: pointer; 26 | } 27 | 28 | #searchButton:hover { 29 | background-color: #497fdf; 30 | } 31 | 32 | #taskArea { 33 | display: flex; 34 | flex-direction: column; 35 | } 36 | 37 | #wholeArea { 38 | display: flex; 39 | flex-direction: column; 40 | } 41 | 42 | .search { 43 | display: flex; 44 | } 45 | 46 | body { 47 | display: flex; 48 | } -------------------------------------------------------------------------------- /contrib/alfred/cuckoo-search.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 搜索cuckoo中存储的任务并以Alfred Workflow要求的格式返回 3 | */ 4 | const config = require('./config'); 5 | 6 | const request = require('co-request'); 7 | 8 | async function main() { 9 | const url = `${config.origin}/task`; 10 | const response = await request({ 11 | json: true, 12 | qs: { 13 | state: 'active' 14 | }, 15 | url 16 | }); 17 | const { tasks } = response.body; 18 | const items = []; 19 | for (const task of tasks) { 20 | items.push({ 21 | arg: `${task.id}`, 22 | icon: { 23 | path: task.icon_file 24 | }, 25 | subtitle: task.detail, 26 | title: `#${task.id} ${task.brief} ${task.context ? '@' + task.context.name : ''}`, 27 | uid: `${task.id}`, 28 | valid: true 29 | }); 30 | } 31 | console.log(JSON.stringify({ items }, null, 2)); 32 | } 33 | 34 | main(); 35 | -------------------------------------------------------------------------------- /app/lib/reminder/applescript.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const dateFormat = require('dateformat'); 4 | 5 | class AppleScriptReminder { 6 | constructor(shellGateway) { 7 | this.shellGateway = shellGateway; 8 | } 9 | 10 | async notify(options) { 11 | const { 12 | alarmAt, 13 | brief, 14 | detail, 15 | } = options; 16 | let command = `/usr/bin/osascript -e 'display notification "预定弹出时间为${dateFormat(alarmAt * 1000, 'yyyy-mm-dd HH:MM:ss')}" with title "${brief}" subtitle "${detail}"'`; 17 | console.log('command', command); 18 | let { 19 | code, 20 | stderr 21 | } = await this.shellGateway.exec(command, { silent: true }); 22 | return { 23 | code, 24 | stderr, 25 | stdout: JSON.stringify({ 26 | activationValue: '好的' 27 | }) 28 | }; 29 | } 30 | } 31 | 32 | module.exports = AppleScriptReminder; 33 | -------------------------------------------------------------------------------- /app/service/icon.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Service = require('egg').Service; 4 | 5 | const fs = require('fs'); 6 | const path = require('path'); 7 | 8 | class IconService extends Service { 9 | /** 10 | * 将文件流写入到项目的public/icon目录下的文件中 11 | * @param {*} stream - 以multipart/form-data方式上传的文件流 12 | * @return {string} icon文件的磁盘路径 13 | */ 14 | async writeIconFile(stream) { 15 | const { ctx: { request } } = this; 16 | 17 | const dir = path.resolve(__dirname, '../public/icon/'); 18 | const target = path.resolve(dir, stream.filename); 19 | stream.pipe(fs.createWriteStream(target)); 20 | return await new Promise(resolve => { 21 | stream.on('end', () => { 22 | resolve({ 23 | icon: `${request.origin}/public/icon/${stream.filename}`, 24 | iconFile: target, 25 | }); 26 | }); 27 | }); 28 | } 29 | } 30 | 31 | module.exports = IconService; 32 | -------------------------------------------------------------------------------- /test/app/lib/reminder/alerter.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Reminder = require('../../../../app/lib/reminder'); 4 | 5 | const { app, assert } = require('egg-mock/bootstrap'); 6 | 7 | describe('test/app/service/queue.test.js', () => { 8 | it('使用alerter弹出提醒', async () => { 9 | app.mockService('gateway.shell', 'exec', command => { 10 | assert(typeof command === 'string'); 11 | assert(command.includes('-appIcon jkl')); 12 | assert(command.includes('持续展示210秒')); 13 | assert(command.includes('-timeout 210')); 14 | assert(command.includes('-title \'abc\'')); 15 | }); 16 | const ctx = app.mockContext(); 17 | await Reminder.create('alerter', ctx.service.gateway.shell).notify({ 18 | actions: [], 19 | alarmAt: Date.now(), 20 | brief: 'abc', 21 | detail: 'def', 22 | device: 'ghi', 23 | duration: 210, 24 | icon: 'jkl', 25 | taskId: 543 26 | }); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /app/service/context.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Service = require('egg').Service; 4 | 5 | class ContextService extends Service { 6 | async create({ name }) { 7 | return await this.ctx.service.contextRepository.create({ name }); 8 | } 9 | 10 | async delete(id) { 11 | return await this.ctx.service.contextRepository.delete(id); 12 | } 13 | 14 | async get(id) { 15 | return await this.ctx.service.contextRepository.get(id); 16 | } 17 | 18 | getCurrent() { 19 | // 根据配置加载不同的场景检测器来获取当前的场景 20 | const { config } = this.app; 21 | const contextDetectorName = config.context.detector; 22 | if (!contextDetectorName) { 23 | return ''; 24 | } 25 | 26 | const clz = require(`../lib/contextDetector/${contextDetectorName}`); 27 | return clz.getCurrent(); 28 | } 29 | 30 | async search(query) { 31 | return await this.ctx.service.contextRepository.search(query); 32 | } 33 | } 34 | 35 | module.exports = ContextService; 36 | -------------------------------------------------------------------------------- /sql/cuckoo-sqlite.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE t_context ( 2 | id INTEGER PRIMARY KEY, 3 | name TEXT, 4 | create_at TEXT, 5 | update_at TEXT 6 | ); 7 | 8 | CREATE TABLE t_remind_log ( 9 | id INTEGER PRIMARY KEY, 10 | plan_alarm_at INTEGER, 11 | real_alarm_at INTEGER, 12 | task_id INTEGER, 13 | create_at TEXT, 14 | update_at TEXT 15 | ); 16 | 17 | CREATE TABLE t_remind ( 18 | id INTEGER PRIMARY KEY, 19 | duration INTEGER, 20 | repeat_type TEXT, 21 | restricted_hours INTEGER, 22 | timestamp INTEGER, 23 | create_at TEXT, 24 | update_at TEXT 25 | ); 26 | 27 | CREATE TABLE t_task ( 28 | id INTEGER PRIMARY KEY, 29 | brief TEXT, 30 | context_id INTEGER, 31 | detail TEXT, 32 | device TEXT, 33 | icon TEXT, 34 | icon_file TEXT, 35 | state TEXT, 36 | create_at TEXT, 37 | update_at TEXT 38 | ); 39 | 40 | CREATE TABLE task_queue ( 41 | create_at INTEGER, 42 | id INTEGER PRIMARY KEY, 43 | next_trigger_time INTEGER, 44 | task_id INTEGER, 45 | update_at INTEGER 46 | ); 47 | -------------------------------------------------------------------------------- /app/lib/reminder/node-notifier.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const notifier = require('node-notifier'); 4 | 5 | class NodeNotifierReminder { 6 | notify(options) { 7 | const { 8 | brief, 9 | detail, 10 | duration, 11 | icon, 12 | } = options; 13 | let message = null; 14 | if (typeof duration === 'number') { 15 | message = `持续展示${duration}秒`; 16 | } else { 17 | message = '需要手动关闭'; 18 | } 19 | return new Promise(resolve => { 20 | notifier.notify({ 21 | actions: '5分钟后再提醒,10分钟后再提醒,30分钟后再提醒,1小时后再提醒,8点时再提醒', 22 | closeLabel: '好的', 23 | dropdownLabel: '或者', 24 | icon, 25 | message, 26 | sound: 'default', 27 | subtitle: detail, 28 | timeout: duration, 29 | title: brief, 30 | wait: true 31 | }, function (error, response, metadata) { 32 | resolve({ 33 | stdout: JSON.stringify(metadata) 34 | }); 35 | }); 36 | }); 37 | } 38 | } 39 | 40 | module.exports = NodeNotifierReminder; 41 | -------------------------------------------------------------------------------- /app/controller/context.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Controller = require('egg').Controller; 4 | 5 | class ContextController extends Controller { 6 | async create() { 7 | const { ctx, service } = this; 8 | const { request: { body } } = ctx; 9 | 10 | const { name } = body; 11 | 12 | const context = await service.context.create({ 13 | name, 14 | }); 15 | 16 | ctx.body = { 17 | context, 18 | }; 19 | ctx.status = 201; 20 | } 21 | 22 | async getCurrent() { 23 | const { ctx, service } = this; 24 | 25 | const context = service.context.getCurrent(); 26 | 27 | ctx.body = { 28 | context, 29 | }; 30 | } 31 | 32 | async search() { 33 | const { ctx, service } = this; 34 | const { query } = ctx; 35 | 36 | const { name } = query; 37 | const { sort = 'create_at:desc' } = query; 38 | const contexts = await service.context.search({ 39 | name, 40 | sort, 41 | }); 42 | 43 | ctx.body = { 44 | contexts, 45 | }; 46 | } 47 | } 48 | 49 | module.exports = ContextController; 50 | -------------------------------------------------------------------------------- /typings/config/plugin.d.ts: -------------------------------------------------------------------------------- 1 | // This file is created by egg-ts-helper@1.25.8 2 | // Do not modify this file!!!!!!!!! 3 | 4 | import 'egg'; 5 | import 'egg-onerror'; 6 | import 'egg-session'; 7 | import 'egg-i18n'; 8 | import 'egg-watcher'; 9 | import 'egg-multipart'; 10 | import 'egg-security'; 11 | import 'egg-development'; 12 | import 'egg-logrotator'; 13 | import 'egg-schedule'; 14 | import 'egg-static'; 15 | import 'egg-jsonp'; 16 | import 'egg-view'; 17 | import 'egg-view-nunjucks'; 18 | import 'egg-validate'; 19 | import { EggPluginItem } from 'egg'; 20 | declare module 'egg' { 21 | interface EggPlugin { 22 | onerror?: EggPluginItem; 23 | session?: EggPluginItem; 24 | i18n?: EggPluginItem; 25 | watcher?: EggPluginItem; 26 | multipart?: EggPluginItem; 27 | security?: EggPluginItem; 28 | development?: EggPluginItem; 29 | logrotator?: EggPluginItem; 30 | schedule?: EggPluginItem; 31 | static?: EggPluginItem; 32 | jsonp?: EggPluginItem; 33 | view?: EggPluginItem; 34 | nunjucks?: EggPluginItem; 35 | validate?: EggPluginItem; 36 | } 37 | } -------------------------------------------------------------------------------- /typings/app/controller/index.d.ts: -------------------------------------------------------------------------------- 1 | // This file is created by egg-ts-helper@1.25.8 2 | // Do not modify this file!!!!!!!!! 3 | 4 | import 'egg'; 5 | import ExportContext = require('../../../app/controller/context'); 6 | import ExportIcon = require('../../../app/controller/icon'); 7 | import ExportQueue = require('../../../app/controller/queue'); 8 | import ExportRemindLog = require('../../../app/controller/remind-log'); 9 | import ExportRemind = require('../../../app/controller/remind'); 10 | import ExportTask = require('../../../app/controller/task'); 11 | import ExportPageTask = require('../../../app/controller/page/task'); 12 | import ExportShortcutTask = require('../../../app/controller/shortcut/task'); 13 | 14 | declare module 'egg' { 15 | interface IController { 16 | context: ExportContext; 17 | icon: ExportIcon; 18 | queue: ExportQueue; 19 | remindLog: ExportRemindLog; 20 | remind: ExportRemind; 21 | task: ExportTask; 22 | page: { 23 | task: ExportPageTask; 24 | } 25 | shortcut: { 26 | task: ExportShortcutTask; 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/lib/reminder/alerter.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | class AlerterReminder { 4 | constructor(shellGateway) { 5 | this.shellGateway = shellGateway; 6 | } 7 | 8 | async notify(options) { 9 | const { 10 | actions, 11 | brief, 12 | detail, 13 | duration, 14 | icon, 15 | } = options; 16 | let command = `/usr/local/bin/alerter -actions \'${actions.join(',')}\'`; 17 | if (typeof icon === 'string' && icon !== '') { 18 | command += ` -appIcon ${icon}`; 19 | } 20 | command += ' -closeLabel \'好的\' -dropdownLabel \'或者\' -json'; 21 | let message = detail; 22 | if (typeof duration === 'number') { 23 | message += `\n持续展示${duration}秒`; 24 | } else { 25 | message += '\n需要手动关闭'; 26 | } 27 | command += ` -message '${message}' -sound 'default'`; 28 | if (typeof duration === 'number') { 29 | command += ` -timeout ${duration}`; 30 | } 31 | command += ` -title '${brief}'`; 32 | console.log('command', command); 33 | return await this.shellGateway.exec(command, { silent: true }); 34 | } 35 | } 36 | 37 | module.exports = AlerterReminder; 38 | -------------------------------------------------------------------------------- /test/app/controller/queue.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Remind = require('../../../app/lib/remind'); 4 | 5 | const { app, assert } = require('egg-mock/bootstrap'); 6 | 7 | describe('test/app/controller/queue.test.js', () => { 8 | it('补充提醒和任务ID', async function () { 9 | app.mockCsrf(); 10 | app.mockService('queue', 'list', () => { 11 | return [{ 12 | member: 1, 13 | remind_id: null, 14 | score: 3 15 | }]; 16 | }); 17 | app.mockService('queue', 'send', (taskId, consumeUntil, remindId) => { 18 | assert(taskId === 1); 19 | assert(consumeUntil === 3); 20 | assert(remindId === 2); 21 | }); 22 | app.mockService('remind', 'get', id => { 23 | return new Remind({ 24 | id, 25 | taskId: null 26 | }); 27 | }); 28 | app.mockService('remind', 'put', remind => { 29 | assert(remind.taskId === 1); 30 | }); 31 | app.mockService('task', 'get', id => { 32 | return { 33 | id, 34 | remind: { 35 | id: 2 36 | } 37 | }; 38 | }); 39 | await app.httpRequest() 40 | .put('/queue/fill-remind-id') 41 | .expect(204); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /test/app/service/context-repository.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { app, assert } = require('egg-mock/bootstrap'); 4 | 5 | let context = null; 6 | let contextId = null; 7 | 8 | describe('test/app/service/context-repository.test.js', () => { 9 | 10 | it('创建场景', async function () { 11 | const ctx = app.mockContext(); 12 | context = await ctx.service.contextRepository.create({ name: '测试' }); 13 | console.log('context', context); 14 | contextId = context.id; 15 | }); 16 | 17 | it('获取场景', async function () { 18 | const ctx = app.mockContext(); 19 | const context = await ctx.service.contextRepository.get(contextId); 20 | assert(context.id === contextId); 21 | assert(context.name === '测试'); 22 | }); 23 | 24 | it('搜索场景', async function () { 25 | const ctx = app.mockContext(); 26 | const contexts = await ctx.service.contextRepository.search({}); 27 | assert(Array.isArray(contexts)); 28 | assert(contexts.length >= 1); 29 | assert(contexts.find(({ id }) => id === contextId)); 30 | }); 31 | 32 | it('删除任务', async function () { 33 | const ctx = app.mockContext(); 34 | await ctx.service.contextRepository.delete(context.id); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /contrib/alfred/show-remind-log.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 打印提醒日志 3 | */ 4 | const config = require('./config'); 5 | 6 | const dateFormat = require('dateformat'); 7 | const request = require('co-request'); 8 | 9 | /** 10 | * 查询提醒日志 11 | */ 12 | async function fetchRemindLog() { 13 | const response = await request({ 14 | json: true, 15 | url: `${config.origin}/remind/log` 16 | }); 17 | return response.body.remindLogs; 18 | } 19 | 20 | /** 21 | * 根据ID获取指定任务 22 | */ 23 | async function fetchTask(id) { 24 | const response = await request({ 25 | json: true, 26 | url: `${config.origin}/task/${id}` 27 | }); 28 | return response.body.task; 29 | } 30 | 31 | async function main() { 32 | // 调用接口获取提醒日志 33 | const remindLogs = await fetchRemindLog(); 34 | // 获取任务的详情来作为待展示的内容 35 | const items = []; 36 | for (const remindLog of remindLogs) { 37 | const task = await fetchTask(remindLog.task_id); 38 | items.push({ 39 | arg: '', 40 | icon: { 41 | path: task.icon_file 42 | }, 43 | subtitle: dateFormat(remindLog.real_alarm_at * 1000, 'yyyy-mm-dd HH:MM:ss'), 44 | title: task.brief 45 | }); 46 | } 47 | console.log(JSON.stringify({ 48 | items 49 | }, null, 2)); 50 | } 51 | 52 | main(); 53 | 54 | -------------------------------------------------------------------------------- /app/public/detail.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 标题 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 |

查看单条任务

12 |

当前任务的ID为{{ id }}

13 |
14 | 你看到我说明任务获取成功了 15 |

任务简述:{{ task.brief }}

16 |

任务详述:{{ task.detail }}

17 |
18 |
19 | 45 | 46 | -------------------------------------------------------------------------------- /app/service/remind-log-repository.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Service = require('egg').Service; 4 | const dateFormat = require('dateformat'); 5 | 6 | class RemindLogService extends Service { 7 | async create({ plan_alarm_at, real_alarm_at, task_id }) { 8 | const { sqlite } = this.app; 9 | 10 | await sqlite.run('INSERT INTO t_remind_log(create_at, plan_alarm_at, real_alarm_at, task_id, update_at) VALUES(?, ?, ?, ?, ?)', [ 11 | dateFormat(Date.now(), 'yyyy-mm-dd HH:MM:ss'), 12 | plan_alarm_at, 13 | real_alarm_at, 14 | task_id, 15 | dateFormat(Date.now(), 'yyyy-mm-dd HH:MM:ss'), 16 | ]); 17 | } 18 | 19 | async search(query) { 20 | const { sqlite } = this.app; 21 | 22 | if (typeof query.sort !== 'string') { 23 | query.sort = 'id:desc'; 24 | } 25 | 26 | const columns = []; 27 | const values = []; 28 | if (typeof query.task_id === 'string') { 29 | columns.push('task_id'); 30 | values.push(query.task_id); 31 | } 32 | let sql = 'SELECT * FROM t_remind_log'; 33 | if (columns.length > 0) { 34 | sql += ' WHERE ' + columns.map(column => `${column} = ?`).join(' AND '); 35 | } 36 | sql += ` ORDER BY ${query.sort.split(':')[0]} ${query.sort.split(':')[1]}`; 37 | sql += ` LIMIT ${query.limit || 10}`; 38 | 39 | return await sqlite.all(sql, values); 40 | } 41 | } 42 | 43 | module.exports = RemindLogService; 44 | -------------------------------------------------------------------------------- /contrib/alfred/delay-task-remind.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 推迟一个任务的提醒指定的一段时间 3 | */ 4 | const config = require('./config'); 5 | 6 | const request = require('co-request'); 7 | 8 | async function fetchRemind(id) { 9 | const response = await request({ 10 | json: true, 11 | url: `${config.origin}/remind/${id}` 12 | }); 13 | return response.body.remind; 14 | } 15 | 16 | /** 17 | * 修改指定ID的提醒的触发时间 18 | */ 19 | async function updateRemindTimestamp(id, timestamp) { 20 | await request({ 21 | body: { 22 | timestamp 23 | }, 24 | json: true, 25 | method: 'patch', 26 | url: `${config.origin}/remind/${id}` 27 | }); 28 | } 29 | 30 | async function main() { 31 | // 解析命令行参数 32 | // 参数的顺序依次为:提醒ID、推迟的时间。比如123 1d表示将ID为123的任务的提醒时间延迟1天。 33 | // 时间的单位支持:m(分钟)、h(小时)、d(天)。 34 | const remindId = parseInt(process.argv[2], 10); 35 | const interval = process.argv[3]; 36 | const n = parseInt(interval.match(/[0-9]+/)[0]); 37 | const unit = interval[interval.length - 1]; 38 | let ns = null; 39 | if (unit === 'd') { 40 | ns = n * 24 * 60 * 60; 41 | } else if (unit === 'h') { 42 | ns = n * 60 * 60; 43 | } else if (unit === 'm') { 44 | ns = n * 60; 45 | } 46 | // 查询这个任务 47 | const remind = await fetchRemind(remindId); 48 | // 提取出提醒的ID和预订的提醒时间 49 | const { timestamp } = remind; 50 | // 修改提醒的触发时间 51 | await updateRemindTimestamp(remindId, timestamp + ns); 52 | } 53 | 54 | main(); 55 | 56 | -------------------------------------------------------------------------------- /test/app/service/remind.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { app, assert } = require('egg-mock/bootstrap'); 4 | 5 | describe('test/app/service/remind.test.js', () => { 6 | let remind1 = null; 7 | let remind2 = null; 8 | 9 | after(async () => { 10 | const ctx = app.mockContext(); 11 | await ctx.service.remind.delete(remind1.id); 12 | await ctx.service.remind.delete(remind2.id); 13 | }); 14 | 15 | before(async () => { 16 | const ctx = app.mockContext(); 17 | // 创建一个指定日期和一个指定小时的提醒 18 | const restricted_hours = new Array(24); 19 | restricted_hours.fill(1, 0, 24); 20 | restricted_hours[new Date().getHours()] = 0; 21 | remind1 = await ctx.service.remind.create({ 22 | restricted_hours, 23 | timestamp: Date.now() 24 | }); 25 | const restrictedWdays = new Array(7); 26 | restrictedWdays.fill(1, 0, 7); 27 | restrictedWdays[new Date().getDay()] = 0; 28 | remind2 = await ctx.service.remind.create({ 29 | restrictedWdays, 30 | timestamp: Date.now() 31 | }); 32 | }); 33 | 34 | it('不在指定小时内不弹出', async function () { 35 | const ctx = app.mockContext(); 36 | const result = await ctx.service.remind.notify(remind1, {}); 37 | assert(!result); 38 | }); 39 | 40 | it('不在指定星期X内不弹出', async function () { 41 | const ctx = app.mockContext(); 42 | const result = await ctx.service.remind.notify(remind2, {}); 43 | assert(!result); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /test/app/controller/shortcut/task.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { app, assert } = require('egg-mock/bootstrap'); 4 | 5 | describe('test/app/controller/shortcut/task.test.js', () => { 6 | let context = null; 7 | let taskId = null; 8 | 9 | after(async () => { 10 | const ctx = app.mockContext(); 11 | await ctx.service.context.delete(context.id); 12 | await ctx.service.task.delete(taskId); 13 | }); 14 | 15 | before(async () => { 16 | const ctx = app.mockContext(); 17 | context = await ctx.service.context.create({ name: 'test' }); 18 | }); 19 | 20 | it('便捷地创建任务', async function () { 21 | app.mockCsrf(); 22 | const response = await app.httpRequest() 23 | .post('/shortcut/task') 24 | .send({ 25 | brief: 'test', 26 | contextName: 'test', 27 | dateTime: '2020-08-13 08:06:00', 28 | detail: '', 29 | repeatType: 'daily' 30 | }) 31 | .expect(201); 32 | 33 | const { body: { data: { task } } } = response; 34 | assert(task); 35 | taskId = task.id; 36 | assert(task.brief === 'test'); 37 | assert(task.reminds[0].context); 38 | assert(task.reminds[0].context.name === 'test'); 39 | assert(task.reminds[0]); 40 | assert(task.reminds[0].timestamp === Math.trunc(new Date('2020-08-13 08:06:00').getTime() / 1000)); 41 | assert(task.reminds[0].repeat); 42 | assert(task.reminds[0].repeat.type === 'daily'); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /lib/plugin/egg-sqlite/app.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const sqlite3 = require('sqlite3').verbose(); 4 | 5 | class MySQLite { 6 | constructor(db) { 7 | this.db = db; 8 | } 9 | 10 | async all(sql, values) { 11 | return new Promise((resolve, reject) => { 12 | this.db.all(sql, values, (err, rows) => { 13 | if (err) { 14 | reject(err); 15 | } else { 16 | resolve(rows); 17 | } 18 | }); 19 | }); 20 | } 21 | 22 | async get(sql, values) { 23 | return new Promise((resolve, reject) => { 24 | this.db.get(sql, values, (err, row) => { 25 | if (err) { 26 | reject(err); 27 | } else { 28 | resolve(row); 29 | } 30 | }); 31 | }); 32 | } 33 | 34 | async run(sql, values) { 35 | return new Promise((resolve, reject) => { 36 | this.db.run(sql, values, function (err) { 37 | if (err) { 38 | reject(err); 39 | } else { 40 | resolve(this); 41 | } 42 | }); 43 | }); 44 | } 45 | } 46 | 47 | module.exports = app => { 48 | const { logger } = app; 49 | app.beforeStart(async () => { 50 | const fileName = app.config.sqlite.db.path; 51 | const db = new sqlite3.Database(fileName); 52 | logger.info(`Database file \`${fileName}\` loaded.`); 53 | const sqlite = new MySQLite(db); 54 | app.sqlite = sqlite; 55 | logger.info('`app.sqlite` mounted.'); 56 | }); 57 | }; 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cuckoo", 3 | "version": "1.14.0", 4 | "description": "定时提醒工具", 5 | "private": true, 6 | "dependencies": { 7 | "@hapi/joi": "17.1.1", 8 | "date-arithmetic": "3.1.0", 9 | "dateformat": "3.0.3", 10 | "egg": "^2.2.1", 11 | "egg-multipart": "2.10.3", 12 | "egg-scripts": "^2.5.0", 13 | "egg-validate": "2.0.2", 14 | "egg-view-nunjucks": "2.2.0", 15 | "node-notifier": "7.0.0", 16 | "shelljs": "0.8.3", 17 | "sqlite3": "4.2.0" 18 | }, 19 | "devDependencies": { 20 | "autod": "^3.0.1", 21 | "autod-egg": "^1.0.0", 22 | "egg-bin": "^4.3.5", 23 | "egg-ci": "^1.8.0", 24 | "egg-mock": "^3.14.0", 25 | "egg-ts-helper": "1.25.8", 26 | "eslint": "^4.11.0", 27 | "eslint-config-egg": "^6.0.0", 28 | "webstorm-disable-index": "^1.2.0" 29 | }, 30 | "engines": { 31 | "node": ">=8.9.0" 32 | }, 33 | "scripts": { 34 | "start": "egg-scripts start --title=egg-server-cuckoo --workers=1", 35 | "stop": "egg-scripts stop --title=egg-server-cuckoo", 36 | "dev": "egg-bin dev --dts --port 7002", 37 | "debug": "egg-bin debug", 38 | "test": "npm run lint -- --fix && npm run test-local", 39 | "test-local": "egg-bin test test/app", 40 | "cov": "egg-bin cov", 41 | "lint": "eslint .", 42 | "ci": "npm run lint && npm run cov", 43 | "autod": "autod" 44 | }, 45 | "ci": { 46 | "version": "8" 47 | }, 48 | "repository": { 49 | "type": "git", 50 | "url": "" 51 | }, 52 | "author": "Liutos", 53 | "license": "MIT" 54 | } 55 | -------------------------------------------------------------------------------- /contrib/alfred/following.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const config = require('./config'); 4 | 5 | const dateFormat = require('dateformat'); 6 | const request = require('co-request'); 7 | 8 | async function main() { 9 | const contextId = process.argv[2]; 10 | 11 | const qs = { 12 | contextId, 13 | }; 14 | const url = `${config.origin}/task/following`; 15 | const response = await request({ 16 | json: true, 17 | qs, 18 | url 19 | }); 20 | const { reminds } = response.body; 21 | const items = []; 22 | let lastDate = null; 23 | for (const remind of reminds) { 24 | const { contextName, iconFile, planAlarmAt, repeatType, taskBrief, taskId } = remind; 25 | 26 | const currentDate = dateFormat(planAlarmAt * 1000, 'yyyy-mm-dd'); 27 | if (!lastDate || lastDate !== currentDate) { 28 | // 更新lastDate并写入items中,以作为下拉列表的一行显示。 29 | lastDate = currentDate; 30 | items.push({ 31 | icon: { path: '' }, 32 | title: `------------------${lastDate}的提醒------------------` 33 | }); 34 | } 35 | // subtitle展示的是任务下一次提醒的时刻,以及它的重复模式 36 | let subtitle = dateFormat(planAlarmAt * 1000, 'yyyy-mm-dd HH:MM:ss'); 37 | if (repeatType) { 38 | subtitle += ` *${repeatType}`; 39 | } 40 | 41 | items.push({ 42 | arg: `${taskId}`, 43 | icon: { 44 | path: iconFile || '' 45 | }, 46 | subtitle, 47 | title: `#${taskId} ${taskBrief} ${contextName ? '@' + contextName : ''}` 48 | }); 49 | } 50 | console.log(JSON.stringify({ items }, null, 2)); 51 | } 52 | 53 | main(); 54 | -------------------------------------------------------------------------------- /test/app/service/queue.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { app, assert } = require('egg-mock/bootstrap'); 4 | 5 | describe('test/app/service/queue.test.js', () => { 6 | before(async () => { 7 | await app.sqlite.run('DELETE FROM task_queue'); 8 | console.log('Clear table task_queue.'); 9 | }); 10 | 11 | it('往队列发送任务', async function () { 12 | const ctx = app.mockContext(); 13 | await ctx.service.queue.send(1, 1577808000, 1); 14 | }); 15 | 16 | it('获取任务触发时刻', async function () { 17 | const ctx = app.mockContext(); 18 | const score = await ctx.service.queue.getScore(1); 19 | assert(score === 1577808000); 20 | }); 21 | 22 | it('列出任务', async function () { 23 | const ctx = app.mockContext(); 24 | const memberScores = await ctx.service.queue.list(); 25 | assert(Array.isArray(memberScores)); 26 | assert(memberScores.length === 1); 27 | assert(memberScores[0].member === 1); 28 | assert(memberScores[0].score === 1577808000); 29 | }); 30 | 31 | it('拉取任务', async function () { 32 | const ctx = app.mockContext(); 33 | const memberScore = await ctx.service.queue.poll(); 34 | assert(memberScore.member === 1); 35 | assert(memberScore.score === 1577808000); 36 | }); 37 | 38 | it('再次拉取任务为空', async function () { 39 | const ctx = app.mockContext(); 40 | const memberScore = await ctx.service.queue.poll(); 41 | assert(!memberScore); 42 | }); 43 | 44 | it('删除任务', async function () { 45 | const ctx = app.mockContext(); 46 | await ctx.service.queue.remove(1); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /contrib/alfred/create-task.js: -------------------------------------------------------------------------------- 1 | const config = require('./config'); 2 | 3 | const request = require('co-request'); 4 | 5 | const querystring = require('querystring'); 6 | 7 | async function main() { 8 | // 解析命令行参数 9 | const query = querystring.parse(process.argv[2], ';'); 10 | let { device, duration = null, type } = query; 11 | if (typeof duration === 'string') { 12 | duration = parseInt(duration, 10); 13 | } 14 | if (isNaN(duration)) { 15 | duration = null; 16 | } 17 | type = typeof type === 'string' && type.trim(); 18 | let repeat_type = null; 19 | if (type !== '') { 20 | repeat_type = type; 21 | } 22 | // 先创建一个任务 23 | const brief = query.message; 24 | let response = await request({ 25 | body: { 26 | brief, 27 | device: device || null 28 | }, 29 | json: true, 30 | method: 'post', 31 | url: `${config.origin}/task` 32 | }); 33 | const { task } = response.body; 34 | console.log(`创建了任务${task.id}`); 35 | // 再创建一个提醒 36 | let timestamp = parseInt(query.timestamp); 37 | if (Number.isNaN(timestamp)) { 38 | const nMinutes = parseInt(query.delayMinutes); 39 | timestamp = Math.round(new Date(Date.now() + nMinutes * 60 * 1000) / 1000); 40 | } else { 41 | timestamp = Math.round(timestamp / 1000); 42 | } 43 | response = await request({ 44 | body: { 45 | duration, 46 | repeat_type, 47 | taskId: task.id, 48 | timestamp 49 | }, 50 | json: true, 51 | method: 'post', 52 | url: `${config.origin}/remind` 53 | }); 54 | const { remind } = response.body; 55 | console.log(`创建了提醒${remind.id}`); 56 | } 57 | 58 | main(); 59 | -------------------------------------------------------------------------------- /app/service/context-repository.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Service = require('egg').Service; 4 | const dateFormat = require('dateformat'); 5 | 6 | class ContextRepositoryService extends Service { 7 | async create({ name }) { 8 | const { sqlite } = this.app; 9 | 10 | const result = await sqlite.run('INSERT INTO t_context(create_at, name, update_at) VALUES(?, ?, ?)', [ 11 | dateFormat(Date.now(), 'yyyy-mm-dd HH:MM:ss'), 12 | name, 13 | dateFormat(Date.now(), 'yyyy-mm-dd HH:MM:ss') 14 | ]); 15 | return await this.get(result.lastID); 16 | } 17 | 18 | async delete(id) { 19 | const { logger, sqlite } = this.app; 20 | 21 | await sqlite.run('DELETE FROM t_context WHERE id = ?', [id]); 22 | logger.info(`删除t_context表中id列为${id}的行`); 23 | } 24 | 25 | async get(id) { 26 | const { sqlite } = this.app; 27 | 28 | return await sqlite.get('SELECT * FROM t_context WHERE id = ?', [id]); 29 | } 30 | 31 | async search(query) { 32 | const { sqlite } = this.app; 33 | 34 | if (typeof query.sort !== 'string') { 35 | query.sort = 'id:desc'; 36 | } 37 | 38 | const columns = []; 39 | const values = []; 40 | if (typeof query.name === 'string') { 41 | columns.push('name'); 42 | values.push(query.name); 43 | } 44 | let sql = 'SELECT * FROM t_context'; 45 | if (columns.length > 0) { 46 | sql += ' WHERE ' + columns.map(column => `${column} = ?`).join(' AND '); 47 | } 48 | sql += ` ORDER BY ${query.sort.split(':')[0]} ${query.sort.split(':')[1]}`; 49 | 50 | return await sqlite.all(sql, values); 51 | } 52 | } 53 | 54 | module.exports = ContextRepositoryService; 55 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | 6 | class AppBootHook { 7 | constructor(app) { 8 | this.app = app; 9 | /** 10 | * 获取应用所监听的端口 11 | * @see {@link https://github.com/eggjs/egg/issues/2652} 12 | */ 13 | app.messenger.on('egg-ready', info => { 14 | app.port = info.port; 15 | }); 16 | } 17 | 18 | async configDidLoad() { 19 | // 确保用于存放图标的目录存在 20 | const dir = path.resolve(__dirname, './app/public/icon/'); 21 | if (!fs.existsSync(dir)) { 22 | fs.mkdirSync(dir); 23 | } 24 | } 25 | 26 | /** 27 | * 只有在willReady,即插件启动后,才可以使用app.sqlite。 28 | * @see {@link https://eggjs.org/zh-cn/advanced/loader.html#life-cycles} 29 | */ 30 | async willReady() { 31 | const { logger, sqlite } = this.app; 32 | 33 | // 检测表是否存在并创建 34 | const sqlFiles = fs.readdirSync(path.resolve(__dirname, './sql/sqlite/')); 35 | for (const sqlFile of sqlFiles) { 36 | const tableName = path.basename(sqlFile, '.sql'); 37 | const isTableExist = await this._checkIsTableExist(tableName); 38 | if (!isTableExist) { 39 | const createStatement = fs.readFileSync(path.resolve(__dirname, './sql/sqlite/', sqlFile), 'utf-8'); 40 | logger.info(`表${tableName}不存在,将会自动创建。`); 41 | await sqlite.run(createStatement, []); 42 | } 43 | } 44 | } 45 | 46 | async _checkIsTableExist(tableName) { 47 | const { sqlite } = this.app; 48 | const names = await sqlite.all('SELECT name FROM sqlite_master WHERE type = \'table\'', []); 49 | return names.find(({ name }) => name === tableName); 50 | } 51 | } 52 | 53 | module.exports = AppBootHook; 54 | -------------------------------------------------------------------------------- /app/router.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * @param {Egg.Application} app - egg application 5 | */ 6 | module.exports = app => { 7 | const { router, controller } = app; 8 | router.get('/context', controller.context.search); 9 | router.get('/context/current', controller.context.getCurrent); 10 | router.post('/context', controller.context.create); 11 | router.post('/icon/file', controller.icon.uploadFile); 12 | // Web pages 13 | router.get('/page/task/:id', controller.page.task.get); 14 | // Shortcuts 15 | router.post('/shortcut/task', controller.shortcut.task.create); 16 | 17 | router.post('/queue/poll', controller.queue.poll); 18 | router.post('/queue/send', controller.queue.send); 19 | router.put('/queue/fill-remind-id', controller.queue.fillRemindId); 20 | 21 | router.get('/remind/log', controller.remindLog.search); 22 | router.get('/remind/:id', controller.remind.get); 23 | router.patch('/remind/:id', controller.remind.update); 24 | router.post('/remind/:id/close', controller.remind.close); 25 | router.post('/remind', controller.remind.create); 26 | router.put('/remind/fill-context-id', controller.remind.fillContextId); 27 | 28 | router.del('/task/:id', controller.task.delete); 29 | router.get('/task', controller.task.search); 30 | router.get('/task/following', controller.task.getFollowing); 31 | router.get('/task/:id', controller.task.get); 32 | router.patch('/task/:id', controller.task.update); 33 | router.patch('/task/:id/icon', controller.task.updateIcon); 34 | router.post('/task/sync', controller.task.sync); 35 | router.post('/task/:id/duplicate', controller.task.duplicate); 36 | router.post('/task', controller.task.create); 37 | }; 38 | -------------------------------------------------------------------------------- /app/controller/shortcut/task.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Controller = require('egg').Controller; 4 | const Joi = require('@hapi/joi'); 5 | 6 | class TaskController extends Controller { 7 | /** 8 | * 快捷创建任务的API 9 | */ 10 | async create() { 11 | const { ctx, service } = this; 12 | const { request: { body } } = ctx; 13 | 14 | const schema = Joi.object({ 15 | brief: Joi.string().required(), 16 | contextName: Joi.string(), 17 | dateTime: Joi.string().required().pattern(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/), 18 | detail: Joi.string().allow(''), 19 | repeatType: Joi.string() 20 | }); 21 | await schema.validateAsync(body); 22 | 23 | // 检查contextName是否存在 24 | let context = null; 25 | if (body.contextName) { 26 | context = (await service.context.search({ 27 | name: body.contextName 28 | }))[0]; 29 | if (!context) { 30 | throw new Error(`无效的场景名称:${body.contextName}`); 31 | } 32 | } 33 | // 再创建任务 34 | const taskMaterial = { 35 | brief: body.brief, 36 | detail: body.detail 37 | }; 38 | const task = await service.task.create(taskMaterial); 39 | // 最后创建提醒 40 | const remindMaterial = { 41 | repeatType: body.repeatType, 42 | taskId: task.id, 43 | timestamp: Math.trunc(new Date(body.dateTime).getTime() / 1000) 44 | }; 45 | if (context) { 46 | remindMaterial.contextId = context.id; 47 | } 48 | await service.remind.create(remindMaterial); 49 | 50 | ctx.body = { 51 | data: { 52 | task: await service.task.get(task.id) 53 | } 54 | }; 55 | ctx.status = 201; 56 | } 57 | } 58 | 59 | module.exports = TaskController; 60 | -------------------------------------------------------------------------------- /config/config.default.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | 5 | module.exports = appInfo => { 6 | const config = exports = {}; 7 | 8 | config.cluster = { 9 | listen: { 10 | port: 7001 11 | } 12 | }; 13 | 14 | config.context = { 15 | // 检测当前场景的工具,有效值为app/lib/contextDetector/目录下的文件名。 16 | detector: '', 17 | }; 18 | 19 | // use for cookie sign key, should change to your own and keep security 20 | config.keys = appInfo.name + '_1543899096465_7258'; 21 | 22 | config.logger = { 23 | // 避免在生产模式中运行时,日志输出到了用户的主目录下。 24 | dir: path.resolve(appInfo.baseDir, 'logs/cuckoo/') 25 | }; 26 | 27 | // add your config here 28 | config.middleware = []; 29 | 30 | config.mobilePhone = { 31 | push: { 32 | serverChan: { 33 | // 使用方糖推送所必须的参数 34 | sckey: '' 35 | } 36 | } 37 | }; 38 | 39 | config.onerror = { 40 | all(err, ctx) { 41 | ctx.body = JSON.stringify({ 42 | message: err.message, 43 | }); 44 | ctx.set('Content-Type', 'application/json'); 45 | ctx.status = 400; 46 | }, 47 | }; 48 | 49 | config.reminder = { 50 | alerter: { 51 | actions: [ 52 | // Menu to show at dropdown list 53 | ] 54 | }, 55 | type: 'applescript' 56 | }; 57 | 58 | config.schedule = { 59 | poll: { 60 | // The running interval of app/schedule/poll.js 61 | interval: '30s' 62 | }, 63 | sync: { 64 | cron: '0 45 */6 * * *' 65 | } 66 | }; 67 | 68 | config.security = { 69 | csrf: { 70 | enable: false, 71 | }, 72 | }; 73 | 74 | config.sqlite = { 75 | db: { 76 | path: path.resolve(__dirname, '../run/cuckoo.db') 77 | } 78 | }; 79 | 80 | config.view = { 81 | defaultViewEngine: 'nunjucks', 82 | }; 83 | 84 | return config; 85 | }; 86 | -------------------------------------------------------------------------------- /app/lib/task.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * @typedef {Object} Task 5 | * @property {string} brief - 任务简述 6 | * @property {string} create_at - 任务的创建时刻 7 | * @property {string} detail - 任务详情 8 | * @property {string} device - 任务依赖的设备 9 | * @property {string} icon - 弹出提醒时的图标 10 | * @property {string} icon_file - 用于Alfred Workflow展示的图片路径 11 | * @property {id} id - 任务主键 12 | * @property {Remind[]} reminds 13 | * @property {string} state - 任务的状态 14 | * @property {string} update_at - 任务的最后一次修改的时刻 15 | */ 16 | 17 | class Task { 18 | /** 19 | * @returns {Task} 20 | */ 21 | constructor(row) { 22 | const { 23 | brief, 24 | create_at, 25 | detail, 26 | device, 27 | icon, 28 | icon_file, 29 | id, 30 | state, 31 | update_at, 32 | } = row; 33 | this.brief = brief; 34 | this.create_at = create_at; 35 | this.detail = detail; 36 | this.device = device; 37 | this.icon = icon; 38 | this.icon_file = icon_file; 39 | this.id = id; 40 | this.state = state; 41 | this.update_at = update_at; 42 | } 43 | 44 | activate() { 45 | this.state = 'active'; 46 | this.update_at = new Date(); 47 | } 48 | 49 | close() { 50 | this.state = 'done'; 51 | this.update_at = new Date(); 52 | } 53 | 54 | patch(changes) { 55 | if (typeof changes['state'] === 'string' && !['active', 'done', 'inactive'].includes(changes['state'])) { 56 | throw new Error(`${changes['state']}不是state字段的一个有效值`); 57 | } 58 | const FIELDS = [ 59 | 'brief', 60 | 'detail', 61 | 'device', 62 | 'icon', 63 | 'icon_file', 64 | 'state', 65 | ]; 66 | for (const field of FIELDS) { 67 | if (field in changes) { 68 | this[field] = changes[field]; 69 | } 70 | } 71 | this.update_at = new Date(); 72 | } 73 | } 74 | 75 | module.exports = Task; 76 | -------------------------------------------------------------------------------- /contrib/alfred/pre-delay-task-remind.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 说明将会如何推迟一个任务的提醒 3 | */ 4 | const config = require('./config'); 5 | 6 | const request = require('co-request'); 7 | 8 | async function fetchRemind(id) { 9 | const response = await request({ 10 | json: true, 11 | url: `${config.origin}/remind/${id}` 12 | }); 13 | return response.body.remind; 14 | } 15 | 16 | /** 17 | * 根据ID获取指定任务 18 | */ 19 | async function fetchTask(id) { 20 | const response = await request({ 21 | json: true, 22 | url: `${config.origin}/task/${id}` 23 | }); 24 | return response.body.task; 25 | } 26 | 27 | async function main() { 28 | // 解析命令行参数 29 | // 参数的顺序依次为:提醒ID、推迟的时间。比如123 1d表示将ID为123的任务的提醒时间延迟1天。 30 | // 时间的单位支持:m(分钟)、h(小时)、d(天)。 31 | if (process.argv.length < 4) { 32 | console.log(JSON.stringify({ 33 | items: [ 34 | { 35 | arg: '', 36 | icon: { 37 | path: '' 38 | }, 39 | subtitle: '', 40 | title: '请继续输入' 41 | }, 42 | ] 43 | }, null, 2)); 44 | } 45 | 46 | const remindId = parseInt(process.argv[2], 10); 47 | const interval = process.argv[3]; 48 | const n = parseInt(interval.match(/[0-9]+/)[0]); 49 | const unit = interval[interval.length - 1]; 50 | let label = null; 51 | if (unit === 'd') { 52 | label = '天'; 53 | } else if (unit === 'h') { 54 | label = '小时'; 55 | } else if (unit === 'm') { 56 | label = '分钟'; 57 | } 58 | // 修改提醒的触发时间 59 | const remind = await fetchRemind(remindId); 60 | const task = await fetchTask(remind.taskId); 61 | console.log(JSON.stringify({ 62 | items: [ 63 | { 64 | arg: `${remindId} ${interval}`, 65 | icon: { 66 | path: task.icon_file 67 | }, 68 | subtitle: `任务简述:${task.brief}`, 69 | title: `推迟任务${remind.taskId} ${n}${label}` 70 | }, 71 | ] 72 | }, null, 2)); 73 | } 74 | 75 | main(); 76 | 77 | -------------------------------------------------------------------------------- /app/service/queue-redis.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Service = require('egg').Service; 4 | 5 | class QueueService extends Service { 6 | async getScore(member) { 7 | const { redis } = this.app; 8 | 9 | const key = [ 'cuckoo', 'task', 'queue' ].join(':'); 10 | const score = await redis.zscore(key, member); 11 | return score ? parseInt(score) : score; 12 | } 13 | 14 | async list() { 15 | const { app } = this; 16 | const { redis } = app; 17 | 18 | const key = [ 'cuckoo', 'task', 'queue' ].join(':'); 19 | const stop = await redis.zcard(key); 20 | const memberScores = await redis.zrange(key, 0, stop, 'WITHSCORES'); 21 | const messages = []; 22 | for (let i = 0; i < memberScores.length; i += 2) { 23 | messages.push({ 24 | member: parseInt(memberScores[i]), 25 | score: parseInt(memberScores[i + 1]), 26 | }); 27 | } 28 | return messages; 29 | } 30 | 31 | async poll() { 32 | const { app } = this; 33 | const { redis } = app; 34 | 35 | const key = [ 'cuckoo', 'task', 'queue' ].join(':'); 36 | const max = Math.round(Date.now() / 1000); 37 | const messages = await redis.zrangebyscore(key, 0, max, 'LIMIT', 0, 1); 38 | if (messages.length === 0) { 39 | return null; 40 | } 41 | const member = messages[0]; 42 | const score = await redis.zscore(key, member); 43 | await redis.zrem(key, member); 44 | return { 45 | member, 46 | score, 47 | }; 48 | } 49 | 50 | async remove(message) { 51 | const { logger, redis } = this.app; 52 | 53 | const key = [ 'cuckoo', 'task', 'queue' ].join(':'); 54 | await redis.zrem(key, message); 55 | logger.info(`删除Redis中有序集合${key}中的member ${message}`); 56 | } 57 | 58 | async send(message, consumeUntil) { 59 | const { app } = this; 60 | const { redis } = app; 61 | 62 | const key = [ 'cuckoo', 'task', 'queue' ].join(':'); 63 | const member = message; 64 | const score = consumeUntil; 65 | await redis.zadd(key, score, member); 66 | } 67 | } 68 | 69 | module.exports = QueueService; 70 | -------------------------------------------------------------------------------- /app/controller/queue.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Controller = require('egg').Controller; 4 | 5 | class QueueController extends Controller { 6 | /** 7 | * 遍历task_queue中的记录,为每一行补充remind_id,并且为每一个remind补充task_id。 8 | */ 9 | async fillRemindId() { 10 | const { ctx, service } = this; 11 | const { logger } = ctx; 12 | 13 | const reservations = await service.queue.list(); 14 | for (const reservation of reservations) { 15 | let { 16 | member: taskId, 17 | remind_id: remindId, 18 | score: consumeUntil 19 | } = reservation; 20 | // 如果这一行没有remind_id,就先补充remind_id。 21 | // remind_id来自于任务的remind_id列。 22 | if (!remindId) { 23 | const task = await service.task.get(taskId); 24 | remindId = task.remind.id; 25 | await service.queue.send(taskId, consumeUntil, remindId); 26 | logger.info(`往队列中的任务${taskId}中写入了提醒的ID ${remindId}`); 27 | } else { 28 | logger.info(`任务${taskId}中已存在提醒ID ${remindId}`); 29 | } 30 | // 如果remind中也没有任务的ID,就补充进去。 31 | const remind = await service.remind.get(remindId); 32 | if (!remind.taskId) { 33 | remind.patch({ 34 | taskId 35 | }); 36 | await service.remind.put(remind); 37 | logger.info(`往提醒${remindId}中写入了任务的ID ${taskId}`); 38 | } else { 39 | logger.info(`提醒${remindId}中已存在任务ID ${taskId}`); 40 | } 41 | } 42 | 43 | ctx.body = ''; 44 | ctx.status = 204; 45 | } 46 | 47 | async poll() { 48 | const { ctx, service } = this; 49 | 50 | const message = await service.queue.poll(); 51 | 52 | ctx.body = { 53 | message, 54 | }; 55 | } 56 | 57 | async send() { 58 | const { ctx, service } = this; 59 | const { request: { body } } = ctx; 60 | 61 | ctx.validate({ 62 | consumeUntil: { type: 'int' }, 63 | message: { type: 'string' }, 64 | }); 65 | const { consumeUntil, message } = body; 66 | 67 | await service.queue.send(message, consumeUntil); 68 | 69 | ctx.body = ''; 70 | ctx.status = 204; 71 | } 72 | } 73 | 74 | module.exports = QueueController; 75 | -------------------------------------------------------------------------------- /app/controller/page/task.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Controller = require('egg').Controller; 4 | const dateFormat = require('dateformat'); 5 | 6 | class TaskPageController extends Controller { 7 | async get() { 8 | const { ctx, service } = this; 9 | 10 | const id = parseInt(ctx.params.id); 11 | 12 | const task = await service.task.get(id); 13 | 14 | const contexts = await service.context.search({}); 15 | // TODO: 这里应当有一个类专门负责生成render方法的第二个参数要求的对象。这里的对象就是一个view model,而这个类就是一个model->view model的转换工厂。 16 | await ctx.render('page-task.nj', { 17 | brief: task.brief, 18 | detail: task.detail, 19 | icon: task.icon, 20 | id: task.id, 21 | reminds: task.reminds.map(remind => { 22 | let { restricted_hours } = remind; 23 | if (!restricted_hours || restricted_hours.length === 0) { 24 | restricted_hours = []; 25 | for (let i = 0; i < 24; i++) { 26 | restricted_hours.push(0); 27 | } 28 | } 29 | const restrictedHours = restricted_hours.map((v, i) => { 30 | return { 31 | checked: v === 1, 32 | index: i, 33 | label: i < 10 ? `0${i}:00` : `${i}:00` 34 | }; 35 | }); 36 | return { 37 | contextName: remind.context && remind.context.name, 38 | contexts: contexts.map(context => { 39 | return { 40 | id: context.id, 41 | name: context.name, 42 | selected: remind.context && remind.context.id === context.id 43 | }; 44 | }), 45 | duration: typeof remind.duration === 'number' ? remind.duration : null, 46 | id: remind.id, 47 | readableTimestamp: dateFormat(remind.timestamp * 1000, 'yyyy-mm-dd\'T\'HH:MM:ss'), 48 | repeatType: remind.repeat && remind.repeat.type, 49 | restrictedHours 50 | }; 51 | }), 52 | states: [ 53 | { name: '启用', selected: task.state === 'active', value: 'active' }, 54 | { name: '已结束', selected: task.state === 'done', value: 'done' }, 55 | { name: '未启用', selected: task.state === 'inactive', value: 'inactive' }, 56 | ] 57 | }); 58 | } 59 | } 60 | 61 | module.exports = TaskPageController; 62 | -------------------------------------------------------------------------------- /typings/app/service/index.d.ts: -------------------------------------------------------------------------------- 1 | // This file is created by egg-ts-helper@1.25.8 2 | // Do not modify this file!!!!!!!!! 3 | 4 | import 'egg'; 5 | type AnyClass = new (...args: any[]) => any; 6 | type AnyFunc = (...args: any[]) => T; 7 | type CanExportFunc = AnyFunc> | AnyFunc>; 8 | type AutoInstanceType : T> = U extends AnyClass ? InstanceType : U; 9 | import ExportContextRepository = require('../../../app/service/context-repository'); 10 | import ExportContext = require('../../../app/service/context'); 11 | import ExportIcon = require('../../../app/service/icon'); 12 | import ExportQueueRedis = require('../../../app/service/queue-redis'); 13 | import ExportQueue = require('../../../app/service/queue'); 14 | import ExportRemindLogRepository = require('../../../app/service/remind-log-repository'); 15 | import ExportRemindLog = require('../../../app/service/remind-log'); 16 | import ExportRemindRepository = require('../../../app/service/remind-repository'); 17 | import ExportRemind = require('../../../app/service/remind'); 18 | import ExportServerChan = require('../../../app/service/server-chan'); 19 | import ExportTaskRepository = require('../../../app/service/task-repository'); 20 | import ExportTask = require('../../../app/service/task'); 21 | import ExportGatewayShell = require('../../../app/service/gateway/shell'); 22 | 23 | declare module 'egg' { 24 | interface IService { 25 | contextRepository: AutoInstanceType; 26 | context: AutoInstanceType; 27 | icon: AutoInstanceType; 28 | queueRedis: AutoInstanceType; 29 | queue: AutoInstanceType; 30 | remindLogRepository: AutoInstanceType; 31 | remindLog: AutoInstanceType; 32 | remindRepository: AutoInstanceType; 33 | remind: AutoInstanceType; 34 | serverChan: AutoInstanceType; 35 | taskRepository: AutoInstanceType; 36 | task: AutoInstanceType; 37 | gateway: { 38 | shell: AutoInstanceType; 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /app/service/remind.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Reminder = require('../lib/reminder'); 4 | 5 | const Service = require('egg').Service; 6 | 7 | class RemindService extends Service { 8 | async close(id) { 9 | const remind = await this.get(id); 10 | remind.close(); 11 | await this.put(remind); 12 | } 13 | 14 | async create({ contextId, duration, repeatType, restricted_hours, restrictedWdays, taskId, timestamp }) { 15 | const { service } = this; 16 | 17 | const remind = await this.ctx.service.remindRepository.create({ contextId, duration, repeatType, restricted_hours, restrictedWdays, taskId, timestamp }); 18 | await service.queue.send(taskId, timestamp, remind.id); 19 | return remind; 20 | } 21 | 22 | async delete(id) { 23 | await this.ctx.service.remindRepository.delete(id); 24 | } 25 | 26 | async duplicate(remind, taskId) { 27 | return await this.create(Object.assign({}, remind, { 28 | taskId 29 | })); 30 | } 31 | 32 | async get(id) { 33 | return await this.ctx.service.remindRepository.get(id); 34 | } 35 | 36 | /** 37 | * @param {Object} options 38 | * @param {string} [options.device] - 即将提醒的任务使用的设备 39 | * @param {number} options.taskId - 被触发提醒的任务的ID 40 | */ 41 | async notify(remind, options) { 42 | const { app, ctx: { logger }, service } = this; 43 | 44 | if (!remind.isExecutable(Date.now())) { 45 | return null; 46 | } 47 | // 先发微信消息,起码不会卡住 48 | if (options.device === 'mobilePhone') { 49 | try { 50 | await service.serverChan.send({ 51 | desp: options.detail, 52 | text: options.brief, 53 | }); 54 | } catch (e) { 55 | logger.warn(`向微信推送任务的消息失败:${e.message}`); 56 | } 57 | } 58 | const { type = 'applescript' } = app.config.reminder || {}; 59 | const extraOptions = app.config.reminder[type] || {}; 60 | return await Reminder.create(type, service.gateway.shell).notify(Object.assign({}, options, { 61 | duration: remind.duration, 62 | }, extraOptions)); 63 | } 64 | 65 | async put(remind) { 66 | await this.ctx.service.remindRepository.put(remind); 67 | } 68 | 69 | /** 70 | * @param {Object} query 71 | * @param {number} [query.taskId] 72 | */ 73 | async search(query) { 74 | const { service } = this; 75 | 76 | return await service.remindRepository.search(query); 77 | } 78 | } 79 | 80 | module.exports = RemindService; 81 | -------------------------------------------------------------------------------- /contrib/alfred/callback.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 根据要求往cuckoo中创建任务及其提醒 3 | */ 4 | 'use strict'; 5 | 6 | const { parseDateTime } = require('./lib'); 7 | 8 | // 先将输入的参数拼成一个字符串 9 | // 逐个匹配输入的字符串中的日期时间的格式 10 | // 计算出要延迟的分钟数 11 | // 输出Workflow所要求的JSON格式内容 12 | function main() { 13 | let brief; 14 | let delayMinutes; 15 | let subtitle; 16 | let timestamp; 17 | const totalInput = process.argv.slice(2).join(' '); 18 | const parseResult = parseDateTime(totalInput); 19 | brief = parseResult.brief; 20 | delayMinutes = parseResult.delayMinutes; 21 | subtitle = parseResult.subtitle; 22 | const dateTime = subtitle; 23 | timestamp = parseResult.timestamp; 24 | 25 | // 从brief中提炼出重复模式 26 | brief = brief.trim(); 27 | const repeatTypePattern = /\*([^\s]+)/; 28 | let repeatType = ''; 29 | if (brief.match(repeatTypePattern)) { 30 | repeatType = brief.match(repeatTypePattern)[1]; 31 | brief = brief.replace(repeatTypePattern, '').trim(); 32 | } 33 | 34 | // 从brief中提炼出设备 35 | const devicePattern = /\\([^\s]+)/; 36 | let device = ''; 37 | if (brief.match(devicePattern)) { 38 | device = brief.match(devicePattern)[1]; 39 | brief = brief.replace(devicePattern, '').trim(); 40 | if (device === 'mobilePhone') { 41 | subtitle += ',将会发往微信帐号'; 42 | } 43 | } 44 | 45 | const durationPattern = /~([0-9]+)/; 46 | let duration = ''; 47 | if (brief.match(durationPattern)) { 48 | duration = brief.match(durationPattern)[1]; 49 | brief = brief.replace(durationPattern, '').trim(); 50 | subtitle += ` 持续展示${duration}秒`; 51 | } 52 | 53 | const arg = `delayMinutes=${delayMinutes};device=${device};duration=${duration};message=${brief};timestamp=${timestamp};type=${repeatType}`; 54 | const items = [{ 55 | arg, 56 | icon: { 57 | path: '' 58 | }, 59 | subtitle, 60 | title: brief 61 | }]; 62 | items.push({ 63 | arg, 64 | icon: { path: '' }, 65 | subtitle: '', 66 | title: dateTime 67 | }); 68 | if (repeatType) { 69 | items.push({ 70 | arg, 71 | icon: { path: '' }, 72 | subtitle: '', 73 | title: `重复模式为${repeatType}` 74 | }); 75 | } 76 | if (device === 'mobilePhone') { 77 | items.push({ 78 | arg, 79 | icon: { path: '' }, 80 | subtitle: '', 81 | title: '将会发往微信帐号' 82 | }); 83 | } 84 | if (duration) { 85 | items.push({ 86 | arg, 87 | icon: { path: '' }, 88 | subtitle: '', 89 | title: `持续展示${duration}秒` 90 | }); 91 | } 92 | console.log(JSON.stringify({ 93 | items 94 | }, null, 2)); 95 | } 96 | 97 | main(); 98 | -------------------------------------------------------------------------------- /app/service/queue.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Service = require('egg').Service; 4 | 5 | class SqliteQueueService extends Service { 6 | constructor(ctx) { 7 | super(ctx); 8 | this.db = null; 9 | this.hasInit = false; 10 | } 11 | 12 | /** 13 | * @param {number} remindId - 提醒ID 14 | */ 15 | async getScore(remindId) { 16 | const { sqlite } = this.app; 17 | const row = await sqlite.get('SELECT next_trigger_time FROM task_queue WHERE remind_id = ?', [remindId]); 18 | return row && row.next_trigger_time; 19 | } 20 | 21 | async list() { 22 | const { sqlite } = this.app; 23 | const rows = await sqlite.all('SELECT * FROM task_queue ORDER BY next_trigger_time ASC', []); 24 | return rows.map(({ next_trigger_time, remind_id, task_id }) => { 25 | return { 26 | member: task_id, 27 | remind_id, 28 | score: next_trigger_time 29 | }; 30 | }); 31 | } 32 | 33 | /** 34 | * 取出第一个待处理的任务 35 | */ 36 | async poll() { 37 | const { sqlite } = this.app; 38 | const max = Math.round(Date.now() / 1000); 39 | const row = await sqlite.get('SELECT * FROM task_queue WHERE next_trigger_time < ? ORDER BY next_trigger_time ASC LIMIT 1', [max]); 40 | if (!row) { 41 | return null; 42 | } 43 | await sqlite.run('DELETE FROM task_queue WHERE id = ?', [row.id]); 44 | return { 45 | member: row.task_id, 46 | remind_id: row.remind_id, 47 | score: row.next_trigger_time 48 | }; 49 | } 50 | 51 | /** 52 | * @param {number} remindId - 提醒ID 53 | */ 54 | async remove(remindId) { 55 | const { sqlite } = this.app; 56 | await sqlite.run('DELETE FROM task_queue WHERE remind_id = ?', [remindId]); 57 | } 58 | 59 | /** 60 | * @param {number} message - 任务ID 61 | * @param {number} consumeUntil - 下一次被触发的时刻 62 | * @param {number} remindId - 导致本次提醒的remind对象的ID 63 | */ 64 | async send(message, consumeUntil, remindId) { 65 | const { sqlite } = this.app; 66 | const oldRow = await this._getTask(message); 67 | if (oldRow) { 68 | await sqlite.run('UPDATE task_queue SET next_trigger_time = ?, remind_id = ? WHERE task_id = ?', [consumeUntil, remindId, message]); 69 | } else { 70 | await sqlite.run('INSERT INTO task_queue(create_at, next_trigger_time, remind_id, task_id, update_at) VALUES(?, ?, ?, ?, ?)', [Date.now(), consumeUntil, remindId, message, Date.now()]); 71 | } 72 | } 73 | 74 | /** 75 | * @param {number} taskId - 任务ID 76 | */ 77 | async _getTask(taskId) { 78 | const { sqlite } = this.app; 79 | return await sqlite.get('SELECT * FROM task_queue WHERE task_id = ?', [taskId]); 80 | } 81 | } 82 | 83 | module.exports = SqliteQueueService; 84 | -------------------------------------------------------------------------------- /app/public/page-task.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * @param {number} id - 要更新图标的任务的id 5 | */ 6 | window.uploadIcon = async function (id) { 7 | // 先取出待上传的图标的完整路径 8 | const iconFile = document.getElementById('iconFile').files[0]; 9 | // 执行文件上传 10 | const formData = new FormData(); 11 | formData.append('file', iconFile); 12 | const response = await axios({ 13 | data: formData, 14 | headers: { 15 | 'Content-Type': 'multipart/form-data; charset=utf-8', 16 | }, 17 | method: 'patch', 18 | url: `/task/${id}/icon`, 19 | }); 20 | console.log('response', response); 21 | alert('任务图标更新成功。'); 22 | location.reload(); 23 | }; 24 | 25 | /** 26 | * 更新这一行提醒的属性。 27 | * @param {number} id - 待更新的提醒的ID 28 | */ 29 | window.updateRemindContext = async function (id) { 30 | // 计算出对应的时间戳 31 | let timestamp = document.getElementById('remindDateTime').valueAsNumber; 32 | // 减去8个小时的时区修正 33 | timestamp -= 8 * 60 * 60 * 1000; 34 | timestamp = Math.trunc(timestamp / 1000); 35 | // 取出当前的场景的ID,并更新提醒的场景 36 | const selectElement = document.getElementById('remindContextName'); 37 | const contextId = parseInt(selectElement.value); 38 | // 取出当前的持续时长并更新 39 | const durationElement = document.getElementById('remindDuration'); 40 | let duration = parseInt(durationElement.value); 41 | // 取出重复模式 42 | const repeatTypeElement = document.getElementById('remindRepeatType'); 43 | const repeatType = repeatTypeElement.value; 44 | // 取出小时的限定条件 45 | // 因为数量是固定的,所以直接构造出id然后逐个获取即可。 46 | const restrictedHours = []; 47 | for (let i = 0; i < 24; i++) { 48 | const id = `rh${i}`; 49 | const element = document.getElementById(id); 50 | restrictedHours.push(element.checked ? 1 : 0); 51 | } 52 | 53 | const data = { 54 | contextId, 55 | duration, 56 | restricted_hours: restrictedHours, 57 | repeat_type: repeatType, 58 | timestamp 59 | }; 60 | console.log('data', data); 61 | const response = await axios({ 62 | data, 63 | headers: { 64 | 'Content-Type': 'application/json; charset=utf-8', 65 | }, 66 | method: 'patch', 67 | url: `/remind/${id}`, 68 | }); 69 | console.log('response', response); 70 | alert('提醒更新成功。'); 71 | location.reload(); 72 | }; 73 | 74 | window.updateTaskState = async function (id) { 75 | const stateElement = document.getElementById('taskState'); 76 | const state = stateElement.value; 77 | const response = await axios({ 78 | data: { 79 | state 80 | }, 81 | headers: { 82 | 'Content-Type': 'application/json; charset=utf-8', 83 | }, 84 | method: 'patch', 85 | url: `/task/${id}`, 86 | }); 87 | console.log('response', response); 88 | alert('任务状态更新成功。'); 89 | location.reload(); 90 | }; 91 | -------------------------------------------------------------------------------- /test/app/service/task.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { app, assert } = require('egg-mock/bootstrap'); 4 | 5 | // 先创建一个任务,再创建2个提醒,最后查出任务对象检查reminds字段。 6 | describe('test/app/service/task.test.js', () => { 7 | let remind1 = null; 8 | let remind2 = null; 9 | let task = null; 10 | 11 | after(async () => { 12 | const ctx = app.mockContext(); 13 | remind1 && await ctx.service.remind.delete(remind1.id); 14 | remind2 && await ctx.service.remind.delete(remind2.id); 15 | task && await ctx.service.task.delete(task.id); 16 | }); 17 | 18 | it('创建任务', async function () { 19 | const ctx = app.mockContext(); 20 | const _task = await ctx.service.task.create({ 21 | brief: 'abc', 22 | detail: 'def', 23 | device: 'mobilePhone', 24 | icon: 'http://example.com', 25 | icon_file: '/tmp' 26 | }); 27 | assert(_task); 28 | task = _task; 29 | }); 30 | 31 | it('创建两个提醒', async () => { 32 | const ctx = app.mockContext(); 33 | remind1 = await ctx.service.remind.create({ 34 | duration: 30, 35 | repeatType: 'daily', 36 | taskId: task.id, 37 | timestamp: Date.now() 38 | }); 39 | remind2 = await ctx.service.remind.create({ 40 | duration: 30, 41 | repeatType: 'daily', 42 | taskId: task.id, 43 | timestamp: Date.now() 44 | }); 45 | assert(remind1); 46 | assert(remind2); 47 | }); 48 | 49 | it('查看任务', async () => { 50 | const ctx = app.mockContext(); 51 | const _task = await ctx.service.task.get(task.id); 52 | assert(_task); 53 | assert(_task.reminds); 54 | assert(Array.isArray(_task.reminds)); 55 | assert(_task.reminds.length === 2); 56 | }); 57 | 58 | it('搜索任务', async () => { 59 | const ctx = app.mockContext(); 60 | const _tasks = await ctx.service.task.search({ 61 | id: task.id 62 | }); 63 | assert(_tasks); 64 | assert(Array.isArray(_tasks)); 65 | assert(_tasks.length === 1); 66 | assert(_tasks[0]); 67 | assert(Array.isArray(_tasks[0].reminds)); 68 | assert(_tasks[0].reminds.length === 2); 69 | }); 70 | 71 | it('修改任务', async () => { 72 | const ctx = app.mockContext(); 73 | const brief = '改一下任务简述'; 74 | task.patch({ 75 | brief 76 | }); 77 | await ctx.service.task.put(task); 78 | const _task = await ctx.service.task.get(task.id); 79 | assert(_task); 80 | assert(_task.brief === brief); 81 | }); 82 | 83 | it('删除任务', async () => { 84 | const ctx = app.mockContext(); 85 | await ctx.service.task.delete(task.id); 86 | const _task = await ctx.service.task.get(task.id); 87 | assert(!_task); 88 | }); 89 | 90 | it('提醒也被一并删除了', async () => { 91 | const ctx = app.mockContext(); 92 | const reminds = await ctx.service.remind.search({ 93 | taskId: task.id 94 | }); 95 | assert(Array.isArray(reminds)); 96 | assert(reminds.length === 0); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /app/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 标题 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 |
这里可以用来放创建任务的表单和按钮
21 |
22 | 26 |
27 | 这个是切换选项卡的区域 28 | 29 | 30 | 31 | 32 |
33 | 34 |
35 |
36 |

后续任务的列表

37 |
38 |
39 | 40 |
41 |

所有任务的列表

42 |
43 |
44 | 45 |
46 |
47 | 48 |
49 |
50 |
51 | 52 | 53 | 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![](https://raw.githubusercontent.com/Liutos/cuckoo/develop/docs/noun_Wall_Clock_1481965.png) 2 | 3 | > A reminder designed for programmers. 4 | 5 | [![Build Status](https://travis-ci.com/Shylock-Hg/ABI.svg?branch=master)](https://travis-ci.org/github/Liutos/cuckoo) 6 | 7 | Create a notification triggered after 1 minute by means of Alfred Workflow. 8 | 9 | ![](https://raw.githubusercontent.com/Liutos/cuckoo/master/docs/AlfredWorkflowExample.gif) 10 | 11 | Create a notification at 9 pm for entry in `org-mode` by Emacs extension. 12 | 13 | ![](https://raw.githubusercontent.com/Liutos/cuckoo/master/docs/EmacsExtensionExample.gif) 14 | 15 | The notification triggered by `alerter` 16 | 17 | ![](https://raw.githubusercontent.com/Liutos/cuckoo/master/docs/alerterNotifyExample.jpg) 18 | 19 | # Features 20 | 21 | - Repeated task notification; 22 | - Notify based on context; 23 | - Push notification to WeChat account; 24 | - Integration with Alfred and Emacs; 25 | 26 | # Installation 27 | 28 | ```shell 29 | git clone git@github.com:Liutos/cuckoo.git 30 | cd cuckoo 31 | npm i 32 | npm run start 33 | ``` 34 | 35 | # Example 36 | 37 | Create a task reminded at 2020-06-01 00:00:00. 38 | 39 | ```shell 40 | read -r -d '' data < 2 | 3 | 4 | 5 | 6 | 7 | 8 | {{ brief }} 9 | 10 | 11 | 12 | 13 |

任务简述:{{ brief }}

14 |

任务详情:{{ detail }}

15 |

任务图标: 16 | {% if icon %} 17 | 18 | {% else %} 19 | 没有图标 20 | {% endif %} 21 | 22 | 23 |

24 | 25 | 30 |

31 |

该任务的提醒

32 | {% for remind in reminds %} 33 |
34 | 35 | 36 | 37 | 43 | 44 | 45 | 46 | 47 | {% for restrictedHour in remind.restrictedHours %} 48 | 49 | 50 | {% endfor %} 51 | 52 |
53 | {% endfor %} 54 |
55 | 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /app/lib/repeat.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const dateMath = require('date-arithmetic'); 4 | 5 | /** 6 | * 重复模式 7 | * @typedef {Object} Repeat 8 | * @property {string} create_at - 该行的创建时刻 9 | * @property {number} id - 该行的主键 10 | * @property {string} type - 重复模式 11 | * @property {string} update_at - 该行的最后一次修改的时刻 12 | */ 13 | 14 | class Repeat { 15 | /** 16 | * Create a repeat. 17 | * @param {Object} row - 从数据库返回的一行 18 | * @param {string} row.type - 重复模式 19 | * @returns {Repeat} 20 | */ 21 | constructor(row) { 22 | const { 23 | type 24 | } = row; 25 | // TODO: 检查type是否为合法的字符串 26 | this.type = type; 27 | } 28 | 29 | /** 30 | * @param {number} current - 作为计算起点的当前时间戳,单位为毫秒 31 | * @return {number} 按照这个重复模式迭代的下一个时刻的时间戳 32 | */ 33 | nextTimestamp(current) { 34 | const now = Date.now(); 35 | const type = this.type; 36 | let nextTime = current; 37 | do { 38 | if (type === 'daily') { 39 | nextTime += 24 * 60 * 60 * 1000; 40 | } else if (type === 'end_of_month') { 41 | // 先算出2个月后的日期,再通过setDate回到上一个月的最后一天 42 | const twoMonthLater = dateMath.add(new Date(nextTime), 2, 'month'); 43 | nextTime = new Date(twoMonthLater.getTime()).setDate(0); 44 | } else if (type === 'hourly') { 45 | nextTime += 60 * 60 * 1000; 46 | } else if (type === 'minutely') { 47 | nextTime += 60 * 1000; 48 | } else if (type === 'monthly') { 49 | const nextDate = dateMath.add(new Date(nextTime), 1, 'month'); 50 | nextTime = nextDate.getTime(); 51 | } else if (type === 'weekly') { 52 | nextTime += 7 * 24 * 60 * 60 * 1000; 53 | } else if (type === 'yearly') { 54 | const nextDate = dateMath.add(new Date(nextTime), 1, 'year'); 55 | nextTime = nextDate.getTime(); 56 | } else if (type.match(/^every_[0-9]+_days$/)) { 57 | const nDays = parseInt(type.match(/^every_([0-9]+)_days$/)[1]); 58 | nextTime += nDays * 24 * 60 * 60 * 1000; 59 | } else if (type.match(/^every_[0-9]+_hours$/)) { 60 | const nHours = parseInt(type.match(/^every_([0-9]+)_hours$/)[1]); 61 | nextTime += nHours * 60 * 60 * 1000; 62 | } else if (type.match(/^every_[0-9]+_minutes$/)) { 63 | const nMinutes = parseInt(type.match(/^every_([0-9]+)_minutes$/)[1]); 64 | nextTime += nMinutes * 60 * 1000; 65 | } else { 66 | throw new Error(`${type}不是一个合法的重复模式`); 67 | } 68 | } while (nextTime < now); 69 | return nextTime; 70 | } 71 | 72 | patch(changes) { 73 | const FIELDS = [ 74 | 'type', 75 | ]; 76 | for (const field of FIELDS) { 77 | if (field in changes) { 78 | this[field] = changes[field]; 79 | } 80 | } 81 | this.update_at = new Date(); 82 | } 83 | 84 | /** 85 | * 检查重复模式是否合法 86 | */ 87 | static validateType(type) { 88 | if (['daily', 'end_of_month', 'hourly', 'minutely', 'monthly', 'weekly', 'yearly'].includes(type)) { 89 | return; 90 | } 91 | if (type.match(/^every_[0-9]+_days$/)) { 92 | return; 93 | } 94 | if (type.match(/^every_[0-9]+_hours$/)) { 95 | return; 96 | } 97 | if (type.match(/^every_[0-9]+_minutes$/)) { 98 | return; 99 | } 100 | throw new Error(`${type}不是一个合法的重复模式`); 101 | } 102 | } 103 | 104 | module.exports = Repeat; 105 | -------------------------------------------------------------------------------- /app/service/remind-repository.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Remind = require('../lib/remind'); 4 | 5 | const Service = require('egg').Service; 6 | const dateFormat = require('dateformat'); 7 | 8 | class RemindService extends Service { 9 | async create({ contextId, duration, repeatType, restricted_hours, restrictedWdays, taskId, timestamp }) { 10 | const { app } = this; 11 | const { sqlite } = app; 12 | 13 | const result = await sqlite.run('INSERT INTO t_remind(context_id, create_at, duration, repeat_type, restricted_hours, restricted_wdays, task_id, timestamp, update_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)', [ 14 | contextId, 15 | dateFormat(Date.now(), 'yyyy-mm-dd HH:MM:ss'), 16 | duration, 17 | repeatType, 18 | Array.isArray(restricted_hours) ? Remind.encodeHours(restricted_hours) : null, 19 | Array.isArray(restrictedWdays) ? Remind.encodeHours(restrictedWdays) : null, 20 | taskId, 21 | timestamp, 22 | dateFormat(Date.now(), 'yyyy-mm-dd HH:MM:ss'), 23 | ]); 24 | return await this.get(result.lastID); 25 | } 26 | 27 | async delete(id) { 28 | const { app, logger } = this; 29 | const { sqlite } = app; 30 | 31 | await sqlite.run('DELETE FROM t_remind WHERE id = ?', [id]); 32 | logger.info(`删除t_remind表中id列为${id}的行`); 33 | } 34 | 35 | async get(id) { 36 | const { app, service } = this; 37 | const { sqlite } = app; 38 | 39 | const row = await sqlite.get('SELECT * FROM t_remind WHERE id = ?', [id]); 40 | if (!row) { 41 | return null; 42 | } 43 | if (typeof row.context_id === 'number') { 44 | row.context = await service.context.get(row.context_id); 45 | } 46 | return new Remind(row); 47 | } 48 | 49 | async put(remind) { 50 | const { app } = this; 51 | const { sqlite } = app; 52 | 53 | await sqlite.run('UPDATE t_remind SET context_id = ?, duration = ?, repeat_type = ?, restricted_hours = ?, restricted_wdays = ?, task_id = ?, timestamp = ?, update_at = ? WHERE id = ?', [ 54 | remind.context && remind.context.id, 55 | remind.duration, 56 | remind.repeat && remind.repeat.type, 57 | remind.restricted_hours && Remind.encodeHours(remind.restricted_hours), 58 | remind.restrictedWdays && Remind.encodeHours(remind.restrictedWdays), 59 | remind.taskId, 60 | remind.timestamp, 61 | dateFormat(Date.now(), 'yyyy-mm-dd HH:MM:ss'), 62 | remind.id, 63 | ]); 64 | } 65 | 66 | /** 67 | * @param {Object} query 68 | * @param {string} [query.sort] - 搜索结果的排序规则 69 | * @param {number} [query.taskId] - 过滤出task_id列为该任务ID的结果 70 | */ 71 | async search(query) { 72 | const { app } = this; 73 | const { logger, sqlite } = app; 74 | 75 | if (typeof query.sort !== 'string') { 76 | query.sort = 'id:desc'; 77 | } 78 | 79 | const conditions = [ '1 = 1' ]; 80 | const values = []; 81 | if (typeof query.taskId === 'number') { 82 | conditions.push('task_id = ?'); 83 | values.push(query.taskId); 84 | } 85 | 86 | let sql = 'SELECT `id` FROM `t_remind` WHERE ' + conditions.join(' AND '); 87 | const { limit = 20, offset = 0, sort } = query; 88 | sql += ` ORDER BY ${sort.split(':')[0]} ${sort.split(':')[1].toUpperCase()}`; 89 | sql += ` LIMIT ${limit} OFFSET ${offset}`; 90 | logger.info(`即将被执行的SQL语句为:${sql}`); 91 | logger.info('用于填充到SQL中的值为:', values); 92 | const ids = await sqlite.all(sql, values); 93 | return await Promise.all(ids.map(async ({ id }) => { 94 | return await this.get(id); 95 | })); 96 | } 97 | } 98 | 99 | module.exports = RemindService; 100 | -------------------------------------------------------------------------------- /app/service/task-repository.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Task = require('../lib/task'); 4 | 5 | const Service = require('egg').Service; 6 | const dateFormat = require('dateformat'); 7 | 8 | class TaskService extends Service { 9 | async create({ brief, detail, device, icon, icon_file }) { 10 | const { app } = this; 11 | const { sqlite } = app; 12 | 13 | const result = await sqlite.run('INSERT INTO t_task(brief, create_at, detail, device, icon, icon_file, state, update_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?)', [ 14 | brief, 15 | dateFormat(Date.now(), 'yyyy-mm-dd HH:MM:ss'), 16 | detail, 17 | device, 18 | icon, 19 | icon_file, 20 | 'active', 21 | dateFormat(Date.now(), 'yyyy-mm-dd HH:MM:ss'), 22 | ]); 23 | 24 | const task = await this.get(result.lastID); 25 | 26 | return task; 27 | } 28 | 29 | async delete(id) { 30 | const { app, logger, service } = this; 31 | const { sqlite } = app; 32 | 33 | const reminds = await service.remind.search({ taskId: id }); 34 | for (const { id: remindId } of reminds) { 35 | await service.queue.remove(remindId); 36 | await service.remind.delete(remindId); 37 | } 38 | await sqlite.run('DELETE FROM t_task WHERE id = ?', [id]); 39 | logger.info(`删除t_task表中id列为${id}的行`); 40 | } 41 | 42 | async get(id) { 43 | const { app, service } = this; 44 | const { sqlite } = app; 45 | 46 | const row = await sqlite.get('SELECT * FROM t_task WHERE id = ?', [id]); 47 | if (!row) { 48 | return null; 49 | } 50 | if (row.context_id) { 51 | row.context = await service.context.get(row.context_id); 52 | } 53 | return new Task(row); 54 | } 55 | 56 | async put(task) { 57 | const { app } = this; 58 | const { sqlite } = app; 59 | 60 | await sqlite.run('UPDATE t_task SET brief = ?, detail = ?, device = ?, icon = ?, icon_file = ?, state = ?, update_at = ? WHERE id = ?', [ 61 | task.brief, 62 | task.detail, 63 | task.device, 64 | task.icon, 65 | task.icon_file, 66 | task.state, 67 | task.update_at, 68 | task.id, 69 | ]); 70 | } 71 | 72 | async search(query) { 73 | const { app } = this; 74 | const { logger, sqlite } = app; 75 | 76 | if (typeof query.sort !== 'string') { 77 | query.sort = 'id:desc'; 78 | } 79 | 80 | const conditions = [ '1 = 1' ]; 81 | const values = []; 82 | if (typeof query.brief === 'string') { 83 | conditions.push('brief LIKE ?'); 84 | values.push(`%${query.brief}%`); 85 | } 86 | if (typeof query.context_id === 'string') { 87 | conditions.push('context_id = ?'); 88 | values.push(query.context_id); 89 | } 90 | if (typeof query.detail === 'string') { 91 | conditions.push('detail LIKE ?'); 92 | values.push(`%${query.detail}%`); 93 | } 94 | if (typeof query.id === 'number') { 95 | conditions.push('id = ?'); 96 | values.push(query.id); 97 | } 98 | if (typeof query.state === 'string') { 99 | conditions.push('state = ?'); 100 | values.push(query.state); 101 | } 102 | 103 | /** 104 | * SQLite的SELECT语句的文档 105 | * @see {@link https://www.sqlitetutorial.net/sqlite-select/} 106 | */ 107 | let sql = 'SELECT `id` FROM `t_task` WHERE ' + conditions.join(' AND '); 108 | const { limit = 20, offset = 0, sort } = query; 109 | sql += ` ORDER BY ${sort.split(':')[0]} ${sort.split(':')[1].toUpperCase()}`; 110 | sql += ` LIMIT ${limit} OFFSET ${offset}`; 111 | logger.info(`即将被执行的SQL语句为:${sql}`); 112 | logger.info('用于填充到SQL中的值为:', values); 113 | return await sqlite.all(sql, values); 114 | } 115 | } 116 | 117 | module.exports = TaskService; 118 | -------------------------------------------------------------------------------- /app/lib/remind.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Repeat = require('./repeat'); 4 | 5 | /** 6 | * @typedef {Object} Remind 7 | * @property {string} create_at - 该行的创建时刻 8 | * @property {number} duration - 提醒的持续展示时间,单位为秒 9 | * @property {number} id - 该行的主键 10 | * @property {Repeat} repeat - 提醒的重复模式,类型参见 {@link Repeat} 11 | * @property {number[]} restricted_hours - 允许该提醒弹出的小时(24小时制) 12 | * @property {number} timestamp - 弹出该提醒的具体时刻 13 | * @property {string} update_at - 该行的最后一次修改的时刻 14 | */ 15 | 16 | class Remind { 17 | /** 18 | * Create a remind. 19 | * @param {Object} row - 从数据库返回的一行 20 | * @param {number} row.context - 触发该提醒的场景对象 21 | * @param {string} row.create_at - 该行的创建时刻 22 | * @param {number} row.duration - 提醒的持续展示时间,单位为秒 23 | * @param {number} row.id - 该行的主键 24 | * @param {Repeat} row.repeat - 提醒的重复模式,类型参见 {@link Repeat} 25 | * @param {number[]} row.restricted_hours - 允许该提醒弹出的小时(24小时制) 26 | * @param {number|null} row.task_id - 该提醒所属的任务的ID 27 | * @param {number} row.timestamp - 弹出该提醒的具体时刻 28 | * @param {string} row.update_at - 该行的最后一次修改的时刻 29 | * @returns {Remind} 30 | */ 31 | constructor(row) { 32 | const { 33 | context, 34 | create_at, 35 | duration, 36 | id, 37 | repeat, 38 | repeat_type, 39 | restricted_hours, 40 | restricted_wdays, 41 | task_id, 42 | timestamp, 43 | update_at, 44 | } = row; 45 | this.context = context; 46 | this.create_at = create_at; 47 | this.duration = duration; 48 | this.id = id; 49 | this.repeat = repeat; 50 | this.repeatType = repeat_type; 51 | if (typeof repeat_type === 'string') { 52 | this.repeat = new Repeat({ type: repeat_type }); 53 | } 54 | this.restricted_hours = typeof restricted_hours === 'number' ? Remind.decodeHours(restricted_hours) : null; 55 | this.restrictedWdays = typeof restricted_wdays === 'number' ? Remind.decodeHours(restricted_wdays) : null; 56 | this.taskId = task_id; 57 | this.timestamp = timestamp; 58 | this.update_at = update_at; 59 | } 60 | 61 | close() { 62 | if (!this.repeat) { 63 | return; 64 | } 65 | const nextTimestamp = this.repeat.nextTimestamp(this.timestamp * 1000); 66 | this.timestamp = Math.round(nextTimestamp / 1000); 67 | } 68 | 69 | /** 70 | * @param {number[]} hours - 允许弹出通知的小时的数组表示 71 | * @return {number} - 将表示不同小时的数组转换后的正整数 72 | */ 73 | static encodeHours(hours) { 74 | return parseInt([...hours].reverse().join(''), 2); 75 | } 76 | 77 | /** 78 | * encodeHours的“反函数” 79 | * @param {number} hours - 以bitmap方式表示允许弹出通知的小时的数字 80 | * @return {number[]} - 以数组形式表达的允许弹出通知的小时 81 | */ 82 | static decodeHours(hours) { 83 | return hours.toString(2).padStart(24, 0).split('').reverse() 84 | .map(c => { 85 | return c === '0' ? 0 : 1; 86 | }); 87 | } 88 | 89 | /** 90 | * 判断该提醒在指定的时刻是否需要触发。 91 | * @param {number} timestamp - 秒级单位的时间戳 92 | */ 93 | isExecutable(timestamp) { 94 | const { restricted_hours, restrictedWdays } = this; 95 | const alarmHour = new Date(timestamp).getHours(); 96 | if (Array.isArray(restricted_hours) && restricted_hours[alarmHour] === 0) { 97 | return false; 98 | } 99 | const alarmDay = new Date(timestamp).getDay(); 100 | if (Array.isArray(restrictedWdays) && restrictedWdays[alarmDay] === 0) { 101 | return false; 102 | } 103 | return true; 104 | } 105 | 106 | isRepeated() { 107 | return !!this.repeat; 108 | } 109 | 110 | patch(changes) { 111 | const FIELDS = [ 112 | 'context', 113 | 'duration', 114 | 'repeat', 115 | 'repeatType', 116 | 'restricted_hours', 117 | 'restrictedWdays', 118 | 'taskId', 119 | 'timestamp', 120 | ]; 121 | for (const field of FIELDS) { 122 | if (field in changes) { 123 | this[field] = changes[field]; 124 | } 125 | } 126 | this.update_at = new Date(); 127 | } 128 | } 129 | 130 | module.exports = Remind; 131 | -------------------------------------------------------------------------------- /test/app/controller/remind.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { app, assert } = require('egg-mock/bootstrap'); 4 | 5 | describe('test/app/controller/remind.test.js', () => { 6 | let context1 = null; 7 | let context2 = null; 8 | let remind = null; 9 | 10 | after(async () => { 11 | const ctx = app.mockContext(); 12 | await ctx.service.context.delete(context1.id); 13 | await ctx.service.context.delete(context2.id); 14 | await ctx.service.remind.delete(remind.id); 15 | }); 16 | 17 | before(async () => { 18 | const ctx = app.mockContext(); 19 | context1 = await ctx.service.context.create({ name: 'TEST1' }); 20 | context2 = await ctx.service.context.create({ name: 'TEST2' }); 21 | }); 22 | 23 | it('创建提醒', async function () { 24 | app.mockCsrf(); 25 | const response = await app.httpRequest() 26 | .post('/remind') 27 | .send({ 28 | contextId: context1.id, 29 | duration: null, 30 | repeat_type: 'daily', 31 | taskId: 123, 32 | timestamp: 1596631200, 33 | }) 34 | .expect(201); 35 | 36 | const { body } = response; 37 | assert(body); 38 | assert(body.remind); 39 | remind = body.remind; 40 | assert(body.remind.context); 41 | assert(body.remind.context.id === context1.id); 42 | assert(body.remind.context.name === context1.name); 43 | assert(body.remind.repeat); 44 | assert(!body.remind.repeat.id); 45 | assert(!body.remind.repeatId); 46 | assert(body.remind.repeat.type === 'daily'); 47 | assert(body.remind.repeatType === 'daily'); 48 | assert(body.remind.taskId === 123); 49 | assert(body.remind.timestamp === 1596631200); 50 | }); 51 | 52 | it('修改提醒的重复模式和场景', async () => { 53 | app.mockCsrf(); 54 | await app.httpRequest() 55 | .patch(`/remind/${remind.id}`) 56 | .send({ 57 | contextId: context2.id, 58 | repeat_type: 'weekly' 59 | }) 60 | .expect(204); 61 | }); 62 | 63 | it('查看提醒', async () => { 64 | app.mockCsrf(); 65 | const response = await app.httpRequest() 66 | .get(`/remind/${remind.id}`) 67 | .expect(200); 68 | 69 | const { body: { remind: _remind } } = response; 70 | assert(_remind); 71 | assert(_remind.context); 72 | assert(_remind.context.id === context2.id); 73 | assert(_remind.context.name === context2.name); 74 | assert(_remind.repeat); 75 | assert(!_remind.repeat.id); 76 | assert(!_remind.repeatId); 77 | assert(_remind.repeat.type === 'weekly'); 78 | assert(_remind.repeatType === 'weekly'); 79 | }); 80 | 81 | describe('测试一开始没有重复模式的情况', async () => { 82 | let remind = null; 83 | 84 | it('创建一个没有重复模式的提醒', async () => { 85 | app.mockCsrf(); 86 | const response = await app.httpRequest() 87 | .post('/remind') 88 | .send({ 89 | taskId: 123, 90 | timestamp: 1596631200, 91 | }) 92 | .expect(201); 93 | 94 | const { body: { remind: _remind } } = response; 95 | remind = _remind; 96 | assert(_remind); 97 | assert(_remind.taskId === 123); 98 | assert(_remind.timestamp === 1596631200); 99 | }); 100 | 101 | it('设置提醒的重复模式', async () => { 102 | app.mockCsrf(); 103 | await app.httpRequest() 104 | .patch(`/remind/${remind.id}`) 105 | .send({ 106 | repeat_type: 'daily' 107 | }) 108 | .expect(204); 109 | }); 110 | 111 | it('查看更新后的提醒', async () => { 112 | app.mockCsrf(); 113 | const response = await app.httpRequest() 114 | .get(`/remind/${remind.id}`) 115 | .expect(200); 116 | 117 | const { body: { remind: _remind } } = response; 118 | assert(_remind); 119 | assert(_remind.repeat); 120 | assert(_remind.repeat.type === 'daily'); 121 | }); 122 | 123 | it('设置提醒的重复模式为空', async () => { 124 | app.mockCsrf(); 125 | await app.httpRequest() 126 | .patch(`/remind/${remind.id}`) 127 | .send({ 128 | repeat_type: '' 129 | }) 130 | .expect(204); 131 | }); 132 | }); 133 | }); 134 | -------------------------------------------------------------------------------- /contrib/alfred/lib/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 计算出时间戳的当天是星期几 3 | * @param {number} timestamp - 时间戳 4 | */ 5 | function getDay(timestamp) { 6 | return Intl.DateTimeFormat('zh-CN', { weekday: 'long' }).format(new Date(timestamp)); 7 | } 8 | 9 | exports.parseDateTime = (totalInput) => { 10 | let brief; 11 | let delayMinutes; 12 | let subtitle; 13 | let timestamp; 14 | const MINUTE_PATTERN = /^(\d+)\s+(.+)$/; 15 | const HM_PATTERN = /^(\d+:\d+)\s+(.+)$/; 16 | const DHM_PATTERN = /^(\d+)\s+(\d+):(\d+)\s+(.+)$/; 17 | const MDHM_PATTERN = /^(\d+)-(\d+)\s+(\d+):(\d+)\s+(.+)$/; 18 | const YMDHM_PATTERN = /^(\d+)-(\d+)-(\d+)\s+(\d+):(\d+)\s+(.+)$/; 19 | const AFTER_PATTERN = /^\+(\d+)([dhmw])\s+(\d+):(\d+)\s+(.+)/; 20 | if (totalInput.match(AFTER_PATTERN)) { 21 | // 表示在指定的天/小时/月/周之后的时刻提醒 22 | const n = parseInt(totalInput.match(AFTER_PATTERN)[1]); 23 | const unit = totalInput.match(AFTER_PATTERN)[2]; 24 | const hour = parseInt(totalInput.match(AFTER_PATTERN)[3]); 25 | const minute = parseInt(totalInput.match(AFTER_PATTERN)[4]); 26 | brief = totalInput.match(AFTER_PATTERN)[5]; 27 | let hours; 28 | let unitName; 29 | switch (unit) { 30 | case 'd': 31 | hours = 24; 32 | unitName = '天'; 33 | break; 34 | case 'h': 35 | hours = 1; 36 | unitName = '小时'; 37 | break; 38 | case 'm': 39 | hours = 31 * 24; 40 | unitName = '月'; 41 | break; 42 | case 'w': 43 | hours = 7 * 24; 44 | unitName = '周'; 45 | break; 46 | } 47 | timestamp = new Date().setHours(hour, minute, 0, 0) + n * hours * 60 * 60 * 1000; 48 | subtitle = `在${n}${unitName}后(${getDay(timestamp)})的${hour}点${minute}分提醒`; 49 | } else if (totalInput.match(YMDHM_PATTERN)) { 50 | // 表示在指定的年份、月份、日期和时刻提醒 51 | brief = totalInput.match(YMDHM_PATTERN)[6]; 52 | const year = parseInt(totalInput.match(YMDHM_PATTERN)[1]); 53 | const month = parseInt(totalInput.match(YMDHM_PATTERN)[2]); 54 | const day = parseInt(totalInput.match(YMDHM_PATTERN)[3]); 55 | const hour = parseInt(totalInput.match(YMDHM_PATTERN)[4]); 56 | const minute = parseInt(totalInput.match(YMDHM_PATTERN)[5]); 57 | timestamp = new Date(new Date().setFullYear(year, month - 1, day)).setHours(hour, minute, 0, 0); 58 | subtitle = `在${year}年${month}月${day}号(${getDay(timestamp)})${hour}点${minute}分时提醒`; 59 | } else if (totalInput.match(MDHM_PATTERN)) { 60 | // 表示在指定的月份、日期和时刻提醒 61 | brief = totalInput.match(MDHM_PATTERN)[5]; 62 | const month = parseInt(totalInput.match(MDHM_PATTERN)[1]); 63 | const day = parseInt(totalInput.match(MDHM_PATTERN)[2]); 64 | const hour = parseInt(totalInput.match(MDHM_PATTERN)[3]); 65 | const minute = parseInt(totalInput.match(MDHM_PATTERN)[4]); 66 | timestamp = new Date(new Date().setMonth(month - 1, day)).setHours(hour, minute, 0, 0); 67 | subtitle = `在${month}月${day}号(${getDay(timestamp)})${hour}点${minute}分时提醒`; 68 | } else if (totalInput.match(DHM_PATTERN)) { 69 | // 表示在指定的日期和时刻提醒 70 | brief = totalInput.match(DHM_PATTERN)[4]; 71 | const day = parseInt(totalInput.match(DHM_PATTERN)[1]); 72 | const hour = parseInt(totalInput.match(DHM_PATTERN)[2]); 73 | const minute = parseInt(totalInput.match(DHM_PATTERN)[3]); 74 | timestamp = new Date(new Date().setDate(day)).setHours(hour, minute, 0, 0); 75 | subtitle = `在本月${day}号(${getDay(timestamp)})${hour}点${minute}分时提醒`; 76 | } else if (totalInput.match(MINUTE_PATTERN)) { 77 | // 表示在指定的分钟后提醒 78 | brief = totalInput.match(MINUTE_PATTERN)[2]; 79 | delayMinutes = parseInt(totalInput.match(MINUTE_PATTERN)[1]); 80 | subtitle = `${delayMinutes}分钟后提醒`; 81 | } else if (totalInput.match(HM_PATTERN)) { 82 | // 表示在指定的时刻提醒 83 | brief = totalInput.match(HM_PATTERN)[2]; 84 | const [hourText, minuteText] = totalInput.match(HM_PATTERN)[1].split(':'); 85 | const hour = parseInt(hourText); 86 | const minute = parseInt(minuteText); 87 | timestamp = new Date().setHours(hour, minute, 0, 0); 88 | subtitle = `在${hour}点${minute}分时提醒`; 89 | } else { 90 | // 不符合任何一种模式,要求用户重新输入 91 | console.log(JSON.stringify({ 92 | items: [{ 93 | icon: { 94 | path: '', 95 | }, 96 | title: '请输入正确的参数,如:1 test', 97 | }], 98 | }, null, 2)); 99 | process.exit(); 100 | } 101 | return { 102 | brief, 103 | delayMinutes, 104 | subtitle, 105 | timestamp 106 | }; 107 | }; 108 | -------------------------------------------------------------------------------- /test/contrib/alfred/callback.test.js: -------------------------------------------------------------------------------- 1 | 2 | 'use strict'; 3 | 4 | const { assert } = require('egg-mock/bootstrap'); 5 | const shell = require('shelljs'); 6 | 7 | const path = require('path'); 8 | const querystring = require('querystring'); 9 | 10 | // node解释器的路径可以从命令行参数中提取 11 | const program = process.argv[0]; 12 | 13 | async function runScript(name, args) { 14 | return await new Promise(resolve => { 15 | const script = path.resolve(__dirname, `../../../contrib/alfred/${name}.js`); 16 | const command = `${program} ${script} ${args}`; 17 | shell.exec(command, { silent: true }, (code, stdout) => { 18 | resolve(stdout); 19 | }); 20 | }); 21 | } 22 | 23 | describe('test/contrib/alfred/callback.test.js', () => { 24 | it('创建1分钟后的提醒', async () => { 25 | // 用命令行来运行callback.js文件,并捕获它的输出来进行校验 26 | const stdout = await runScript('callback', '1 test'); 27 | const data = JSON.parse(stdout); 28 | assert(Array.isArray(data.items)); 29 | const { items: [{ arg, title }, { title: dateTime }] } = data; 30 | assert(typeof arg === 'string'); 31 | const query = querystring.parse(arg, ';'); 32 | assert(query.delayMinutes === '1'); 33 | assert(title === 'test'); 34 | assert(dateTime === '1分钟后提醒'); 35 | }); 36 | 37 | it('创建今天12:34分的提醒', async () => { 38 | // 用命令行来运行callback.js文件,并捕获它的输出来进行校验 39 | const stdout = await runScript('callback', '12:34 test'); 40 | const data = JSON.parse(stdout); 41 | assert(Array.isArray(data.items)); 42 | const { items: [{ arg, title }] } = data; 43 | assert(typeof arg === 'string'); 44 | const query = querystring.parse(arg, ';'); 45 | const date = new Date(parseInt(query.timestamp)); 46 | assert(date.getHours() === 12); 47 | assert(date.getMinutes() === 34); 48 | assert(title === 'test'); 49 | }); 50 | 51 | it('创建明天12:34分的提醒', async () => { 52 | // 用命令行来运行callback.js文件,并捕获它的输出来进行校验 53 | const stdout = await runScript('callback', '+1d 12:34 test'); 54 | const data = JSON.parse(stdout); 55 | assert(Array.isArray(data.items)); 56 | const { items: [{ arg, title }] } = data; 57 | assert(typeof arg === 'string'); 58 | const query = querystring.parse(arg, ';'); 59 | const timestamp = parseInt(query.timestamp); 60 | const date = new Date(timestamp); 61 | assert(date.getDate() === new Date().getDate() + 1); 62 | assert(date.getHours() === 12); 63 | assert(date.getMinutes() === 34); 64 | assert(title === 'test'); 65 | }); 66 | 67 | it('创建本月22号23:45分的提醒', async () => { 68 | // 用命令行来运行callback.js文件,并捕获它的输出来进行校验 69 | const stdout = await runScript('callback', '22 23:45 test'); 70 | const data = JSON.parse(stdout); 71 | assert(Array.isArray(data.items)); 72 | const { items: [{ arg, title }] } = data; 73 | assert(typeof arg === 'string'); 74 | const query = querystring.parse(arg, ';'); 75 | const timestamp = parseInt(query.timestamp); 76 | const date = new Date(timestamp); 77 | assert(date.getDate() === 22); 78 | assert(date.getHours() === 23); 79 | assert(date.getMinutes() === 45); 80 | assert(title === 'test'); 81 | }); 82 | 83 | it('创建今年7月份22号23:45分的提醒', async () => { 84 | // 用命令行来运行callback.js文件,并捕获它的输出来进行校验 85 | const stdout = await runScript('callback', '7-22 23:45 test'); 86 | const data = JSON.parse(stdout); 87 | assert(Array.isArray(data.items)); 88 | const { items: [{ arg, title }] } = data; 89 | assert(typeof arg === 'string'); 90 | const query = querystring.parse(arg, ';'); 91 | const timestamp = parseInt(query.timestamp); 92 | const date = new Date(timestamp); 93 | assert(date.getMonth() === 6); 94 | assert(date.getDate() === 22); 95 | assert(date.getHours() === 23); 96 | assert(date.getMinutes() === 45); 97 | assert(title === 'test'); 98 | }); 99 | 100 | it('创建2019年7月份22号23:45分的提醒', async () => { 101 | // 用命令行来运行callback.js文件,并捕获它的输出来进行校验 102 | const stdout = await runScript('callback', '2019-7-22 23:45 test'); 103 | const data = JSON.parse(stdout); 104 | assert(Array.isArray(data.items)); 105 | const { items: [{ arg, title }] } = data; 106 | assert(typeof arg === 'string'); 107 | const query = querystring.parse(arg, ';'); 108 | const timestamp = parseInt(query.timestamp); 109 | const date = new Date(timestamp); 110 | assert(date.getFullYear() === 2019); 111 | assert(date.getMonth() === 6); 112 | assert(date.getDate() === 22); 113 | assert(date.getHours() === 23); 114 | assert(date.getMinutes() === 45); 115 | assert(title === 'test'); 116 | }); 117 | }); 118 | -------------------------------------------------------------------------------- /sql/cuckoo-mysql.sql: -------------------------------------------------------------------------------- 1 | -- MySQL dump 10.13 Distrib 8.0.12, for macos10.13 (x86_64) 2 | -- 3 | -- Host: localhost Database: cuckoo 4 | -- ------------------------------------------------------ 5 | -- Server version 8.0.12 6 | 7 | /*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; 8 | /*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; 9 | /*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; 10 | SET NAMES utf8mb4 ; 11 | /*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; 12 | /*!40103 SET TIME_ZONE='+00:00' */; 13 | /*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; 14 | /*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; 15 | /*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; 16 | /*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; 17 | 18 | -- 19 | -- Table structure for table `t_context` 20 | -- 21 | 22 | DROP TABLE IF EXISTS `t_context`; 23 | /*!40101 SET @saved_cs_client = @@character_set_client */; 24 | SET character_set_client = utf8mb4 ; 25 | CREATE TABLE `t_context` ( 26 | `id` int(11) NOT NULL AUTO_INCREMENT, 27 | `name` varchar(30) NOT NULL COMMENT '场景名称', 28 | `create_at` datetime NOT NULL, 29 | `update_at` datetime NOT NULL, 30 | PRIMARY KEY (`id`) 31 | ) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; 32 | /*!40101 SET character_set_client = @saved_cs_client */; 33 | 34 | -- 35 | -- Table structure for table `t_remind` 36 | -- 37 | 38 | DROP TABLE IF EXISTS `t_remind`; 39 | /*!40101 SET @saved_cs_client = @@character_set_client */; 40 | SET character_set_client = utf8mb4 ; 41 | CREATE TABLE `t_remind` ( 42 | `id` int(11) NOT NULL AUTO_INCREMENT, 43 | `duration` int(11) DEFAULT NULL COMMENT '提醒出现的持续时间', 44 | `repeat_id` int(11) DEFAULT NULL, 45 | `restricted_hours` int(11) DEFAULT NULL COMMENT '允许弹出通知的小时', 46 | `timestamp` int(11) NOT NULL, 47 | `create_at` datetime NOT NULL, 48 | `update_at` datetime NOT NULL, 49 | PRIMARY KEY (`id`) 50 | ) ENGINE=InnoDB AUTO_INCREMENT=1513 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; 51 | /*!40101 SET character_set_client = @saved_cs_client */; 52 | 53 | -- 54 | -- Table structure for table `t_remind_log` 55 | -- 56 | 57 | DROP TABLE IF EXISTS `t_remind_log`; 58 | /*!40101 SET @saved_cs_client = @@character_set_client */; 59 | SET character_set_client = utf8mb4 ; 60 | CREATE TABLE `t_remind_log` ( 61 | `id` int(11) NOT NULL AUTO_INCREMENT, 62 | `plan_alarm_at` int(11) NOT NULL, 63 | `real_alarm_at` int(11) NOT NULL, 64 | `task_id` int(11) NOT NULL, 65 | `create_at` datetime NOT NULL, 66 | `update_at` datetime NOT NULL, 67 | PRIMARY KEY (`id`) 68 | ) ENGINE=InnoDB AUTO_INCREMENT=15625 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; 69 | /*!40101 SET character_set_client = @saved_cs_client */; 70 | 71 | -- 72 | -- Table structure for table `t_repeat` 73 | -- 74 | 75 | DROP TABLE IF EXISTS `t_repeat`; 76 | /*!40101 SET @saved_cs_client = @@character_set_client */; 77 | SET character_set_client = utf8mb4 ; 78 | CREATE TABLE `t_repeat` ( 79 | `id` int(11) NOT NULL AUTO_INCREMENT, 80 | `type` varchar(20) NOT NULL, 81 | `create_at` datetime NOT NULL, 82 | `update_at` datetime NOT NULL, 83 | PRIMARY KEY (`id`) 84 | ) ENGINE=InnoDB AUTO_INCREMENT=95 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; 85 | /*!40101 SET character_set_client = @saved_cs_client */; 86 | 87 | -- 88 | -- Table structure for table `t_task` 89 | -- 90 | 91 | DROP TABLE IF EXISTS `t_task`; 92 | /*!40101 SET @saved_cs_client = @@character_set_client */; 93 | SET character_set_client = utf8mb4 ; 94 | CREATE TABLE `t_task` ( 95 | `id` int(11) NOT NULL AUTO_INCREMENT, 96 | `brief` varchar(100) NOT NULL, 97 | `context_id` int(11) DEFAULT NULL COMMENT '场景ID', 98 | `detail` varchar(1000) NOT NULL DEFAULT '', 99 | `device` varchar(20) DEFAULT NULL COMMENT '任务需要用到的设备', 100 | `icon` varchar(200) DEFAULT '' COMMENT '提醒时展示的icon', 101 | `icon_file` varchar(200) DEFAULT '' COMMENT '本地磁盘上存放的icon文件的路径', 102 | `remind_id` int(11) DEFAULT NULL, 103 | `state` varchar(10) NOT NULL, 104 | `create_at` datetime NOT NULL, 105 | `update_at` datetime NOT NULL, 106 | PRIMARY KEY (`id`) 107 | ) ENGINE=InnoDB AUTO_INCREMENT=1495 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; 108 | /*!40101 SET character_set_client = @saved_cs_client */; 109 | /*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; 110 | 111 | /*!40101 SET SQL_MODE=@OLD_SQL_MODE */; 112 | /*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; 113 | /*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; 114 | /*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; 115 | /*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; 116 | /*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; 117 | /*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; 118 | 119 | -- Dump completed on 2020-01-14 22:36:12 120 | -------------------------------------------------------------------------------- /README.zh-CN.md: -------------------------------------------------------------------------------- 1 | [English](https://github.com/Liutos/cuckoo/blob/master/README.md) 2 | 3 | # cuckoo 4 | 5 | 定时提醒工具 6 | 7 | # 预览 8 | 9 | ## 使用Alfred Workflow 10 | 11 | 创建一个任务和提醒 12 | 13 | ![](https://raw.githubusercontent.com/Liutos/cuckoo/master/docs/AlfredWorkflowExample.gif) 14 | 15 | ## 使用Emacs扩展`org-cuckoo` 16 | 17 | ![](https://raw.githubusercontent.com/Liutos/cuckoo/master/docs/EmacsExtensionExample.gif) 18 | 19 | ## 使用`alerter`提醒 20 | 21 | ![](https://raw.githubusercontent.com/Liutos/cuckoo/master/docs/alerterNotifyExample.jpg) 22 | 23 | # 开始使用 24 | 25 | ## 依赖 26 | 27 | - Node.js 28 | 29 | ## 安装 30 | 31 | 克隆本项目 32 | 33 | ```bash 34 | git clone git@github.com:Liutos/cuckoo.git 35 | ``` 36 | 37 | 克隆完成后进入`cuckoo`目录,安装所需要的依赖 38 | 39 | ```shell 40 | npm i 41 | ``` 42 | 43 | 在启动前,你可以通过修改`config/config.default.js`来定制一些东西: 44 | 45 | 1. 定制场景检测工具。目前`cuckoo`仅支持使用[ControlPlane](https://www.controlplaneapp.com/)来检测场景,只需要将配置文件中的`context.detector`设定为`'controlPlane'`即可; 46 | 2. 通过设定`reminder.type`来定制提醒方式。目前支持`'applescript'`、`'alerter'`,以及`'node-notifier'`。我个人喜欢用`'alerter'`。 47 | 48 | 终于可以启动了 49 | 50 | ```bash 51 | npm run start 52 | ``` 53 | 54 | 启动后`cuckoo`默认监听7001端口,并以守护进程的方式运行。 55 | 56 | ## 创建所需要的场景 57 | 58 | 为了可以实现基于场景的提醒,需要先创建出场景。例如,要创建一个名为“公司”的场景,示例代码如下 59 | 60 | ```bash 61 | curl -i -H Content-Type\:\ application/json -XPOST http\://localhost\:7001/context -d \{' 62 | '\ \ \"name\"\:\ \"\公\司\"' 63 | '\} 64 | ``` 65 | 66 | 之后便可以在需要指定场景的地方,使用“公司”这个场景对应的id了。 67 | 68 | ## 如何在开机时自动启动cuckoo? 69 | 70 | ### macOS 71 | 72 | 如果想要在开机后自动自动启动cuckoo,可以通过`launchd`来实现。首先创建一个cuckoo的启动脚本,示例代码如下 73 | 74 | ```shell 75 | #!/bin/bash 76 | # 启动cuckoo 77 | cd /path/to/cuckoo/ # 将此处修改为你本地的cuckoo项目的路径 78 | export PATH=/usr/local/bin:${PATH} # 将node命令加入到搜索路径中 79 | npm run start 80 | ``` 81 | 82 | 然后在`~/Library/LaunchAgents`中创建相应的配置文件,示例代码如下 83 | 84 | ```xml 85 | 86 | 87 | 88 | 89 | Label 90 | 这里填一个你随便取的名字 91 | Program 92 | 这里填刚才的启动脚本的绝对路径 93 | RunAtLoad 94 | 95 | StandardOutPath 96 | /tmp/cuckoo.log 97 | StandardErrorPath 98 | /tmp/cuckoo.err 99 | 100 | 101 | ``` 102 | 103 | 我把上面这段内容保存为文件com.liutos.tools.cuckoo.plist。这么一来,下次登录进系统后,macOS便会自行启动cuckoo。 104 | 105 | ## 如何使用接口创建提醒? 106 | 107 | 要创建一个cuckoo的提醒,分为两个步骤: 108 | 109 | 1. 创建一个remind记录; 110 | 2. 创建一个task记录。 111 | 112 | ### 如何创建remind? 113 | 114 | 以创建一个在2019年9月23日开始,每天22点整弹出的提醒为例,示例代码如下 115 | 116 | ```bash 117 | curl -H 'Content-Type: application/json' -X POST --data '{"repeat_type":"","timestamp":1569247200}' 'http://localhost:7001/remind' 118 | ``` 119 | 120 | 响应结果如下 121 | 122 | ```javascript 123 | { 124 | "remind": { 125 | "create_at": "2020-01-26T07:59:16.000Z", 126 | "duration": null, 127 | "id": 1549, 128 | "repeat": { 129 | "create_at": "2020-01-26T07:59:16.000Z", 130 | "id": 98, 131 | "type": "daily", 132 | "update_at": "2020-01-26T07:59:16.000Z" 133 | }, 134 | "restricted_hours": [ 135 | 0, 136 | 0, 137 | 0, 138 | 0, 139 | 0, 140 | 0, 141 | 0, 142 | 0, 143 | 0, 144 | 0, 145 | 0, 146 | 0, 147 | 0, 148 | 0, 149 | 0, 150 | 0, 151 | 0, 152 | 0, 153 | 0, 154 | 0, 155 | 0, 156 | 0, 157 | 1, 158 | 0 159 | ], 160 | "timestamp": 1569247200, 161 | "update_at": "2020-01-26T07:59:16.000Z" 162 | } 163 | } 164 | ``` 165 | 166 | 其中的id字段的值是稍后创建任务时所需要的参数。 167 | 168 | ### 如何创建任务? 169 | 170 | 以创建一个标题为"test"的提醒为例,示例代码如下 171 | 172 | ```bash 173 | curl -H 'Content-Type: application/json' -X POST --data '{"brief":"test","remind_id":1549}' 'http://localhost:7001/task' 174 | ``` 175 | 176 | 这个创建出来的任务将会在2019年9月23日开始的每一天的晚上10点弹出。如果当前的时间已经过了这个时间了,那么这个任务会在下一分钟立刻弹出。 177 | 178 | ## Emacs插件 179 | 180 | cuckoo自带了一个Emacs的插件——org-cuckoo次模式。安装方法如下 181 | 182 | ```elisp 183 | (add-to-list 'load-path "/path/to/cuckoo/contrib/emacs/") 184 | (require 'org-cuckoo) 185 | (add-hook 'org-mode-hook 186 | (lambda () 187 | (org-cuckoo-mode))) 188 | (add-hook 'org-after-todo-state-change-hook 'cuckoo-cancelled-state) 189 | ``` 190 | 191 | 这样一来,就可以使用如下的快捷键了: 192 | 193 | - `C-c r`用于为当前条目在cuckoo中创建任务和提醒。这要求条目是设置了SCHEDULED属性的; 194 | - `C-c C-s`用于设置条目的SCHEDULED属性。在使用`C-c C-s`时与org-mode原本的快捷键没有差异,当带有prefix number时,除了会取消当前条目的SCHEDULED属性之后,org-cuckoo还会根据条目的TASK_ID属性,相应地修改cuckoo中条目的状态。 195 | 196 | ## alerter的优势 197 | 198 | cuckoo默认使用AppleScript来弹出提醒,但[alerter](https://github.com/vjeantet/alerter)是一个更好的选择。alerter比起AppleScript的优势在于: 199 | 200 | - 支持自定义icon; 201 | - 支持自定义下拉菜单。基于这个cuckoo实现了推迟提醒的功能; 202 | - 支持超时自动消失。这个由cuckoo的任务的duration字段控制。 203 | 204 | 推荐大家使用,只需要在config/config.local.js中添加如下内容即可 205 | 206 | ```js 207 | config.reminder = { 208 | type: 'alerter' 209 | }; 210 | ``` 211 | -------------------------------------------------------------------------------- /app/controller/remind.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Repeat = require('../lib/repeat'); 4 | 5 | const Controller = require('egg').Controller; 6 | const Joi = require('@hapi/joi'); 7 | 8 | class RemindController extends Controller { 9 | async close() { 10 | const { ctx, service } = this; 11 | const { params } = ctx; 12 | 13 | const { id } = params; 14 | 15 | await service.remind.close(id); 16 | 17 | ctx.body = ''; 18 | ctx.status = 204; 19 | } 20 | 21 | async create() { 22 | const { ctx, service } = this; 23 | const { request: { body } } = ctx; 24 | 25 | const schema = Joi.object({ 26 | contextId: [Joi.number(), null], 27 | duration: [Joi.number(), null], 28 | repeat_type: [Joi.string(), null], 29 | restricted_hours: Joi.array().items(Joi.number()).length(24), 30 | restrictedWdays: Joi.array().items(Joi.number()).length(24), 31 | taskId: Joi.number().required(), 32 | timestamp: Joi.number().required(), 33 | }); 34 | await schema.validateAsync(body); 35 | 36 | const { contextId, duration, repeat_type, restricted_hours, restrictedWdays, taskId, timestamp } = body; 37 | 38 | const remind = await service.remind.create({ 39 | contextId, 40 | duration, 41 | repeatType: repeat_type, 42 | restricted_hours, 43 | restrictedWdays, 44 | taskId, 45 | timestamp, 46 | }); 47 | 48 | ctx.body = { 49 | remind, 50 | }; 51 | ctx.status = 201; 52 | } 53 | 54 | /** 55 | * 将任务的context_id填充到对应的提醒中 56 | */ 57 | async fillContextId() { 58 | const { ctx, service } = this; 59 | const { logger } = ctx; 60 | 61 | const reminds = await service.remind.search({ 62 | limit: Number.MAX_SAFE_INTEGER 63 | }); 64 | for (const remind of reminds) { 65 | const { context, taskId } = remind; 66 | if (context) { 67 | logger.info(`提醒${remind.id}已经有场景了,不需要填充。`); 68 | continue; 69 | } 70 | const task = await service.task.get(taskId); 71 | if (task && task.context) { 72 | remind.patch({ context: task.context }); 73 | await service.remind.put(remind); 74 | logger.info(`将任务${task.id}的场景ID ${task.context.id}填充到提醒${remind.id}中。`); 75 | } 76 | } 77 | 78 | ctx.body = ''; 79 | ctx.status = 204; 80 | } 81 | 82 | async get() { 83 | const { ctx, service } = this; 84 | const { params } = ctx; 85 | 86 | const { id } = params; 87 | 88 | const remind = await service.remind.get(id); 89 | 90 | ctx.body = { 91 | remind, 92 | }; 93 | } 94 | 95 | async update() { 96 | const { ctx, service } = this; 97 | const { logger, params, request: { body } } = ctx; 98 | 99 | const schema = Joi.object({ 100 | contextId: [Joi.number(), null], 101 | duration: [ 102 | Joi.number(), 103 | null, 104 | ], 105 | repeat_type: [ 106 | Joi.string().allow(''), 107 | null, 108 | ], 109 | restricted_hours: [ 110 | Joi.array().items(Joi.number()).length(24), 111 | null, 112 | ], 113 | restrictedWdays: [ 114 | Joi.array().items(Joi.number()).length(24), 115 | null, 116 | ], 117 | timestamp: Joi.number(), 118 | }); 119 | await schema.validateAsync(body); 120 | 121 | const { id } = params; 122 | const changes = {}; 123 | if (body.contextId === null) { 124 | changes.context = null; 125 | } else if (typeof body.contextId === 'number') { 126 | changes.context = await service.context.get(body.contextId); 127 | } 128 | if (body.duration === null || typeof body.duration === 'number') { 129 | changes.duration = body.duration; 130 | } 131 | if (body.restricted_hours === null) { 132 | changes.restricted_hours = null; 133 | } else if (Array.isArray(body.restricted_hours)) { 134 | changes.restricted_hours = body.restricted_hours; 135 | } 136 | if (body.restrictedWdays === null) { 137 | changes.restrictedWdays = null; 138 | } else if (Array.isArray(body.restrictedWdays)) { 139 | changes.restrictedWdays = body.restrictedWdays; 140 | } 141 | if (typeof body.timestamp === 'number') { 142 | changes.timestamp = body.timestamp; 143 | } 144 | 145 | const remind = await service.remind.get(id); 146 | if (body.repeat_type === null) { 147 | changes.repeat = null; 148 | } else if (typeof body.repeat_type === 'string') { 149 | if (remind.repeat) { 150 | remind.repeat.patch({ type: body.repeat_type }); 151 | } else { 152 | changes.repeat = new Repeat({ type: body.repeat_type }); 153 | } 154 | } 155 | remind.patch(changes); 156 | await service.remind.put(remind); 157 | 158 | if (typeof body.timestamp === 'number') { 159 | const task = await service.task.get(remind.taskId); 160 | const consumeUntil = body.timestamp; 161 | await service.queue.send(task.id, consumeUntil, id); 162 | logger.info(`设置延时队列中的任务${task.id}在${consumeUntil}后才被消费`); 163 | } 164 | 165 | ctx.body = ''; 166 | ctx.status = 204; 167 | } 168 | } 169 | 170 | module.exports = RemindController; 171 | -------------------------------------------------------------------------------- /app/service/task.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Service = require('egg').Service; 4 | 5 | class TaskService extends Service { 6 | async create({ brief, detail, device, icon, icon_file }) { 7 | return await this.ctx.service.taskRepository.create({ brief, detail, device, icon, icon_file }); 8 | } 9 | 10 | async delete(id) { 11 | await this.ctx.service.taskRepository.delete(id); 12 | } 13 | 14 | async duplicate(task) { 15 | const { service } = this; 16 | 17 | const copy = await this.create(Object.assign({}, task, { 18 | detail: `复制自${task.id} ` + task.detail 19 | })); 20 | 21 | const reminds = await service.remind.search({ 22 | taskId: task.id 23 | }); 24 | for (const remind of reminds) { 25 | await service.remind.duplicate(remind, copy.id); 26 | } 27 | 28 | return await this.get(copy.id); 29 | } 30 | 31 | async get(id) { 32 | const { service } = this; 33 | 34 | const task = await service.taskRepository.get(id); 35 | if (task) { 36 | const reminds = await service.remind.search({ 37 | taskId: id 38 | }); 39 | task.reminds = reminds; 40 | } 41 | return task; 42 | } 43 | 44 | /** 45 | * @param {Object} [context] - 场景 46 | */ 47 | async getFollowing(context = null) { 48 | const { logger, service } = this; 49 | 50 | const messages = await service.queue.list(); 51 | const reminds = []; 52 | for (const message of messages) { 53 | const { 54 | remind_id: remindId, 55 | score: plan_alarm_at, 56 | } = message; 57 | const remind = await service.remind.get(remindId); 58 | if (!remind) { 59 | continue; 60 | } 61 | const task = await this.get(remind.taskId); 62 | if (!task) { 63 | continue; 64 | } 65 | if (task.state !== 'active') { 66 | logger.info(`任务${task.id}的状态为${task.state},接下来不会弹出提醒`); 67 | continue; 68 | } 69 | if (context && remind.context && remind.context.id !== context.id) { 70 | logger.info(`任务${task.id}所要求的场景为${remind.context.name}(${remind.context.id}),与目标场景“${context.name}”(${context.id})不符,接下来不会弹出提醒`); 71 | continue; 72 | } 73 | const hour = new Date(plan_alarm_at * 1000).getHours(); 74 | if (remind && Array.isArray(remind.restricted_hours) && !remind.restricted_hours[hour]) { 75 | logger.info(`任务${task.id}在${hour}时不需要弹出提醒`); 76 | continue; 77 | } 78 | reminds.push({ 79 | contextName: remind.context && remind.context.name, 80 | iconFile: task.icon_file, 81 | id: remindId, 82 | planAlarmAt: plan_alarm_at, 83 | repeatType: remind.repeatType, 84 | taskId: remind.taskId, 85 | taskBrief: task.brief 86 | }); 87 | } 88 | return reminds; 89 | } 90 | 91 | async put(task) { 92 | await this.ctx.service.taskRepository.put(task); 93 | } 94 | 95 | /** 96 | * 触发一次指定任务的提醒 97 | * @param {number} id - 任务的ID 98 | * @param {number} alarmAt - 触发提醒的时刻,单位为秒 99 | * @param {Object} remind - 导致本次触发的提醒对象 100 | */ 101 | async remind(id, alarmAt, remind) { 102 | const { logger, service } = this; 103 | 104 | logger.info(`开始处理任务${id}的提醒流程`); 105 | const currentContext = service.context.getCurrent(); 106 | const task = await this.get(id); 107 | if (task.state !== 'active') { 108 | logger.info(`任务${id}没有被启用,不需要弹出提醒`); 109 | } else if (!remind.context || currentContext === remind.context.name) { 110 | await service.remindLog.create({ 111 | plan_alarm_at: alarmAt, 112 | real_alarm_at: Math.round(Date.now() / 1000), 113 | task_id: id, 114 | }); 115 | const result = await service.remind.notify(remind, { 116 | alarmAt, 117 | brief: `#${task.id} ${task.brief}`, 118 | detail: task.detail, 119 | device: task.device, 120 | icon: task.icon, 121 | taskId: task.id, 122 | }); 123 | const stdout = result && result.stdout; 124 | let rv; 125 | if (typeof stdout === 'string' && stdout.length > 0) { 126 | rv = JSON.parse(stdout); 127 | } 128 | // FIXME: 避免字符串常量重复出现在下一行以及remind.js中 129 | const pattern = /([0-9]+)分钟后再提醒/; 130 | if (rv && typeof rv.activationValue === 'string' && rv.activationValue.match(pattern)) { 131 | const matches = rv.activationValue.match(pattern); 132 | const minutes = parseInt(matches[0]); 133 | logger.info(`这里应当往Redis中写入一条${minutes}分钟后执行的任务`); 134 | await service.queue.send(task.id, Math.round(Date.now() / 1000) + minutes * 60, remind.id); 135 | } else if (rv && typeof rv.activationValue === 'string' && rv.activationValue.match(/8点时再提醒/)) { 136 | let consumeUntil = new Date().setHours(8, 0, 0, 0); 137 | while (consumeUntil < Date.now()) { 138 | consumeUntil += 12 * 60 * 60 * 1000; 139 | } 140 | await service.queue.send(task.id, Math.round(consumeUntil / 1000), remind.id); 141 | } else if (rv && typeof rv.activationValue === 'string' && rv.activationValue.match(/([0-9]+)小时后再提醒/)) { 142 | const matches = rv.activationValue.match(/([0-9]+)小时后再提醒/); 143 | const hours = parseInt(matches[0]); 144 | logger.info(`这里应当往Redis中写入一条${hours}小时后执行的任务`); 145 | await service.queue.send(task.id, Math.round(Date.now() / 1000) + hours * 60 * 60, remind.id); 146 | } else { 147 | await this._schedule(remind); 148 | } 149 | } else { 150 | logger.info(`当前场景(${currentContext})与任务要求的场景(${remind.context.name})不一致,不需要弹出提醒`); 151 | await this._schedule(remind); 152 | } 153 | logger.info(`任务${id}的提醒流程处理完毕`); 154 | } 155 | 156 | async search(query) { 157 | const ids = await this.ctx.service.taskRepository.search(query); 158 | return await Promise.all(ids.map(async ({ id }) => { 159 | return await this.get(id); 160 | })); 161 | } 162 | 163 | /** 164 | * 安排下一个提醒的时刻 165 | * @param {Object} remind - 提醒的实体对象 166 | */ 167 | async _schedule(remind) { 168 | const { service } = this; 169 | 170 | remind.close(); 171 | if (remind.isRepeated()) { 172 | await service.remind.put(remind); 173 | await service.queue.send(remind.taskId, remind.timestamp, remind.id); 174 | } 175 | } 176 | } 177 | 178 | module.exports = TaskService; 179 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## [1.14.0] - 2020-10-18 4 | 5 | ### Added 6 | 7 | - Can set `repeatType` of a remind when creating from within `org-cuckoo` for a TODO entry; 8 | - Can customize dropdown menu of `alerter` in configuration item `reminder.alerter.actions`; 9 | - Can check/uncheck a remind's restricted hours in Web UI; 10 | - Add a logo in README. 11 | 12 | ### Changed 13 | 14 | - Reserve the seconds part when creating reminds from Alfred Workflow; 15 | - Can create multiple reminds for a single task. 16 | 17 | ## [1.13.0] - 2020-09-01 18 | 19 | ### Added 20 | 21 | - Add a calendar view for displaying following tasks; 22 | - Provides 4 different views; 23 | - Add a Web UI for searching tasks; 24 | - Add a form for modifying a remind's properties; 25 | 26 | ### Changed 27 | 28 | - Remove column `remind_id` from table `t_task`; 29 | - Reminding will no longer change a task's state; 30 | - Can set a context to each reminds; 31 | - Rename Elisp function `cuckoo-org-schedule` to `org-cuckoo-schedule`; 32 | - Switching the order of creating `remind` and `task`; 33 | 34 | ### Fixed 35 | 36 | - Fix the problem of delaying a remind. 37 | 38 | ## [1.12.0] - 2020-08-29 39 | 40 | ### Added 41 | 42 | - A new API for filling legacy objects' missing remind_id and task_id fields; 43 | - A new API `PUT /queue/fill-remind-id` for filling `remind_id` and `task_id` in legacy rows; 44 | - Add pagination for task list in Web UI. 45 | 46 | ### Changed 47 | 48 | - More lines in `callback` Workflow's result list for describing the task and its remind to be created; 49 | - Display a task's remind's `timestamp` in format `yyyy-mm-dd HH:MM`; 50 | - Don't show a cracked picture when task has no icon; 51 | - Remove APIs about repeat objects. 52 | 53 | ### Fixed 54 | 55 | - Generate icon's URL correctly in `PATCH /task/:id/icon` API; 56 | - Get application's port properly in `sync.js`. 57 | 58 | ## [1.11.0] - 2020-08-20 59 | 60 | ### Added 61 | 62 | - A new column `repeat_type` inside table `t_remind`, for storing repeated pattern; 63 | - Print error messages when encountered errors in `org-cuckoo`. 64 | 65 | ### Deprecated 66 | 67 | - All API endpoints operating on `repeat` object. 68 | 69 | ### Fixed 70 | 71 | - Compute `timestamp` correctly in `POST /shortcut/task`. 72 | 73 | ## [1.10.0] - 2020-08-13 74 | 75 | ### Added 76 | 77 | - A new API for creating task and its remind easily. 78 | 79 | ### Fixed 80 | 81 | - `detail` is allowed to be empty when updating task. 82 | 83 | ## [1.9.3] - 2020-08-07 84 | 85 | ### Fixed 86 | 87 | - `detail` can be blank when creating task; 88 | - No need to pass `duration` when creating task from Emacs extension. 89 | 90 | ## [1.9.2] - 2020-08-05 91 | 92 | ### Fixed 93 | 94 | - Allow some fields in API nullable. 95 | 96 | ## [1.9.1] - 2020-08-04 97 | 98 | ### Fixed 99 | 100 | - timestamp in update API is not required. 101 | 102 | ## [1.9.0] - 2020-07-26 103 | 104 | ### Added 105 | 106 | - A new trivial Web page for viewing task's information and for uploading icon files. 107 | 108 | ### Changed 109 | 110 | - Ajust the error responding format; 111 | - Remove the hand-coding port number in server-side code. 112 | 113 | ## [1.8.0] - 2020-07-20 114 | 115 | ### Changed 116 | 117 | - A new and ugly Web UI for viewing tasks; 118 | - Extract the code about operating on SQLite to be a embedded egg-js plugin in `cuckoo`. 119 | 120 | ### Fixed 121 | 122 | - Correctly recognize the SCHEDULED property of an org-mode entry even there's Chinese words. 123 | 124 | ## [1.7.0] - 2020-07-13 125 | 126 | ### Added 127 | 128 | - A new HTTP API for setting a task's `icon` and `icon_file` simultaneously by uploading file; 129 | - A new column `task_id` in table `t_remind` for storing the id of the task a remind belongs to; 130 | - A new column `remind_id` in table `task_queue` for storing the id of the remind cause a notification. 131 | 132 | ## [1.6.0] - 2020-06-28 133 | 134 | ### Added 135 | 136 | - A new scheduled task `sync.js` for synchronizing tasks between database and queue; 137 | - Add icons for Alfred Workflow triggered by keywords `callback` and `following`; 138 | 139 | ### Changed 140 | 141 | - Highlight the date spliting line in result list of `following` Workflow; 142 | 143 | ### Fixed 144 | 145 | - Logs should be output to files under `logs/cuckoo/` directory, not under the `${HOME}`; 146 | 147 | ## [1.5.0] - 2020-06-19 148 | 149 | ### Added 150 | 151 | - Can customizing the running interval of polling tasks from queue; 152 | - Displaying the day of a remind when using the Workflow triggered by keyword `callback`; 153 | - A new API for uploading icon files to cuckoo. After uploading, they can be accessed under the `/public/icon/` path; 154 | - A new trivial Web UI for creating tasks and their reminds. 155 | 156 | ### Changed 157 | 158 | - Use a configuration item for setting the key of [ServerChan](http://sc.ftqq.com/3.version), instead of using environment variable; 159 | 160 | ## [1.4.0] - 2020-06-05 161 | 162 | ### Added 163 | 164 | - A new Alfred Workflow, triggered by keyword `delay`, can use for delaying a task's remind for specific interval; 165 | - By means of the parameter `restrictedWdays`, the user can specify which days the remind should show; 166 | - A new Alfred Workflow, triggered by keyword `rlog`, can show the recent 10 remind logs; 167 | 168 | ### Changed 169 | 170 | - In the Workflow triggered by keyword `following`, the dates inserted into the result list; 171 | - Indicates that the remind will be notify in WeChat account when using `callback`; 172 | - A new column named `restricted_wdays` appears in table `t_remind`. 173 | 174 | ## [1.3.2] - 2020-05-24 175 | 176 | ### Added 177 | 178 | - When starts, auto creates tables in SQLite database file if they're missing. 179 | 180 | ## [1.3.0] - 2020-05-20 181 | 182 | ### Added 183 | 184 | - Can customize the duration of a remind in Alfred Workflow; 185 | 186 | ### Changed 187 | 188 | - Completely migrate from MySQL to SQLite for storing repeats, reminds, tasks, and so on. Remove the dependencies on MySQL and Redis, easy to deploy. 189 | 190 | ## [1.2.0] - 2020-05-04 191 | 192 | ### Added 193 | 194 | - Support setting a default icon file when creating tasks from Emacs org-mode; 195 | 196 | ### Changed 197 | 198 | - Replace Redis by using SQLite for implementing the queue, made it much easier for deployment; 199 | 200 | ## [1.1.0] - 2020-04-19 201 | 202 | ### Added 203 | 204 | - 使用`setImmediate`运行唤起外部程序弹出通知的代码,避免同一时刻的提醒只能串行触发; 205 | - 支持了新的弹出通知的机制[`node-notifier`](https://github.com/mikaelbr/node-notifier); 206 | - 新增了复制任务及其提醒、重复模式的接口; 207 | - 在org-cuckoo.el中新增了查看任务的简要信息的Elisp命令; 208 | 209 | ### Changed 210 | 211 | - 调整了使用AppleScript弹出通知时的文案; 212 | -------------------------------------------------------------------------------- /app/controller/task.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Controller = require('egg').Controller; 4 | const Joi = require('@hapi/joi'); 5 | 6 | class TaskController extends Controller { 7 | async create() { 8 | const { ctx, service } = this; 9 | const { request: { body } } = ctx; 10 | 11 | const schema = Joi.object({ 12 | brief: Joi.string().required(), 13 | detail: Joi.string().allow(''), 14 | device: [Joi.string(), null], 15 | icon: Joi.string(), 16 | icon_file: Joi.string(), 17 | }); 18 | await schema.validateAsync(body); 19 | 20 | const { brief, detail = '', device, icon, icon_file } = body; 21 | 22 | const task = await service.task.create({ 23 | brief, 24 | detail, 25 | device, 26 | icon, 27 | icon_file, 28 | }); 29 | 30 | ctx.body = { 31 | task, 32 | }; 33 | ctx.status = 201; 34 | } 35 | 36 | async delete() { 37 | const { ctx, service } = this; 38 | const { params } = ctx; 39 | 40 | const { id } = params; 41 | 42 | await service.task.delete(id); 43 | 44 | ctx.body = ''; 45 | ctx.status = 204; 46 | } 47 | 48 | async duplicate() { 49 | const { ctx, service } = this; 50 | const { params } = ctx; 51 | 52 | const { id } = params; 53 | 54 | let task = await service.task.get(id); 55 | if (!task) { 56 | throw new Error(`任务${id}不存在`); 57 | } 58 | 59 | task = await service.task.duplicate(task); 60 | 61 | ctx.body = { 62 | task 63 | }; 64 | } 65 | 66 | async get() { 67 | const { ctx, service } = this; 68 | const { params } = ctx; 69 | 70 | const { id } = params; 71 | 72 | const task = await service.task.get(id); 73 | 74 | ctx.body = { 75 | task, 76 | }; 77 | } 78 | 79 | async getFollowing() { 80 | const { ctx, logger, service } = this; 81 | const { query } = ctx; 82 | 83 | const { contextId } = query; 84 | let context; 85 | if (contextId === 'all') { 86 | context = null; 87 | } else if (typeof contextId === 'string') { 88 | context = await service.context.get(parseInt(contextId)); 89 | } else { 90 | const currentContextName = await service.context.getCurrent(); 91 | logger.info(`使用当前场景名${currentContextName}搜索场景对象`); 92 | context = (await service.context.search({ 93 | name: currentContextName, 94 | }))[0]; 95 | } 96 | 97 | const reminds = await service.task.getFollowing(context); 98 | 99 | ctx.body = { 100 | reminds, 101 | }; 102 | } 103 | 104 | async search() { 105 | const { ctx, service } = this; 106 | const { query } = ctx; 107 | 108 | const schema = Joi.object({ 109 | brief: Joi.string(), 110 | context_id: Joi.string().regex(/[0-9]+/), 111 | detail: Joi.string(), 112 | limit: Joi.string().regex(/[0-9]+/), 113 | offset: Joi.string().regex(/[0-9]+/), 114 | sort: Joi.string().regex(/.*:[(asc)|(desc)]/), 115 | state: Joi.string() 116 | }); 117 | await schema.validateAsync(query); 118 | const { 119 | brief, 120 | context_id, 121 | detail, 122 | limit, 123 | offset, 124 | sort = 'create_at:desc', 125 | state 126 | } = query; 127 | const tasks = await service.task.search({ 128 | brief, 129 | context_id, 130 | detail, 131 | limit, 132 | offset, 133 | sort, 134 | state, 135 | }); 136 | 137 | ctx.body = { 138 | tasks, 139 | }; 140 | ctx.set('Access-Control-Allow-Origin', '*'); 141 | } 142 | 143 | async sync() { 144 | const { ctx, logger, service } = this; 145 | 146 | const messages = await service.queue.list(); 147 | logger.info(`当前的延迟消息数为${messages.length}`); 148 | for (const { member: id } of messages) { 149 | if (!id) { 150 | continue; 151 | } 152 | const task = await service.task.get(id); 153 | if (!task || task.state !== 'active') { 154 | logger.info(`任务${id}不是活跃状态,应当从延迟队列中移除`); 155 | await service.queue.remove(id); 156 | } 157 | } 158 | const tasks = await service.task.search({ 159 | limit: Number.MAX_SAFE_INTEGER, 160 | state: 'active', 161 | }); 162 | for (const task of tasks) { 163 | const { reminds } = task; 164 | if (!reminds || reminds.length === 0) { 165 | logger.info(`任务${task.id}处于活跃状态但没有设定提醒时间`); 166 | continue; 167 | } 168 | for (const remind of reminds) { 169 | let { id: remindId, repeat, timestamp } = remind; 170 | if (!repeat && timestamp * 1000 < Date.now()) { 171 | logger.info('提醒设定在过去并且不重复,不需要重新写入队列。'); 172 | continue; 173 | } 174 | if (repeat && timestamp * 1000 < Date.now()) { 175 | timestamp = Math.round(repeat.nextTimestamp(timestamp * 1000) / 1000); 176 | } 177 | const score = await service.queue.getScore(remindId); 178 | if (!score) { 179 | logger.info(`将任务${task.id}补充到延迟队列中`); 180 | await service.queue.send(task.id, timestamp, remindId); 181 | } else if (score !== timestamp) { 182 | logger.info(`调整任务${task.id}在延迟队列中的提醒时间,从${score}调整为${timestamp}`); 183 | await service.queue.send(task.id, timestamp, remindId); 184 | } 185 | } 186 | } 187 | 188 | ctx.body = ''; 189 | ctx.status = 204; 190 | } 191 | 192 | async update() { 193 | const { ctx, service } = this; 194 | const { params, request: { body } } = ctx; 195 | 196 | const schema = Joi.object({ 197 | brief: Joi.string(), 198 | detail: Joi.string().allow(''), 199 | device: Joi.string(), 200 | icon: [ 201 | Joi.string(), 202 | null, 203 | ], 204 | icon_file: [ 205 | Joi.string(), 206 | null, 207 | ], 208 | state: Joi.string(), 209 | }); 210 | await schema.validateAsync(body); 211 | 212 | const { id } = params; 213 | const changes = {}; 214 | if (typeof body.brief === 'string') { 215 | changes.brief = body.brief; 216 | } 217 | if (typeof body.detail === 'string') { 218 | changes.detail = body.detail; 219 | } 220 | if (typeof body.device === 'string') { 221 | changes.device = body.device; 222 | } 223 | if (body.icon === null || typeof body.icon === 'string') { 224 | changes.icon = body.icon; 225 | } 226 | if (body.icon_file === null || typeof body.icon_file === 'string') { 227 | changes.icon_file = body.icon_file; 228 | } 229 | if (typeof body.state === 'string') { 230 | changes.state = body.state; 231 | } 232 | 233 | const task = await service.task.get(id); 234 | try { 235 | task.patch(changes); 236 | } catch (e) { 237 | ctx.body = e.message; 238 | ctx.status = 400; 239 | return; 240 | } 241 | await service.task.put(task); 242 | 243 | if (body.state === 'done') { 244 | await service.queue.remove(id); 245 | } 246 | 247 | ctx.body = ''; 248 | ctx.status = 204; 249 | } 250 | 251 | async updateIcon() { 252 | const { ctx } = this; 253 | const { params, service } = ctx; 254 | 255 | const { id } = params; 256 | 257 | const stream = await ctx.getFileStream(); 258 | const { 259 | icon, 260 | iconFile, 261 | } = await service.icon.writeIconFile(stream); 262 | 263 | const task = await service.task.get(id); 264 | task.patch({ 265 | icon, 266 | icon_file: iconFile, 267 | }); 268 | await service.task.put(task); 269 | 270 | ctx.body = ''; 271 | ctx.status = 204; 272 | } 273 | } 274 | 275 | module.exports = TaskController; 276 | -------------------------------------------------------------------------------- /app/public/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // 全局变量定义 4 | let calendar = null; 5 | 6 | const allTaskTemplate = ` 7 | 8 | 9 | 10 | 11 | 12 | 13 | {{#each tasks}} 14 | 15 | 16 | 17 | 24 | 25 | {{/each}} 26 |
任务ID任务简述场景要求
{{this.id}}{{this.brief}} 18 | {{#if this.context}} 19 | {{this.context.name}} 20 | {{else}} 21 | 无要求 22 | {{/if}} 23 |
27 | `; 28 | 29 | function makeDateTimeString(timestamp) { 30 | const date = new Date(timestamp * 1000); 31 | let dateTime = date.getFullYear(); 32 | dateTime += '-' + (date.getMonth() >= 9 ? date.getMonth() + 1 : ('0' + (date.getMonth() + 1))); 33 | dateTime += '-' + (date.getDate() >= 10 ? date.getDate() : ('0' + date.getDate())); 34 | dateTime += ' ' + (date.getHours() >= 10 ? date.getHours() : ('0' + date.getHours())); 35 | dateTime += ':' + (date.getMinutes() >= 10 ? date.getMinutes() : ('0' + date.getMinutes())); 36 | dateTime += ':' + (date.getSeconds() >= 10 ? date.getSeconds() : ('0' + date.getSeconds())); 37 | return dateTime; 38 | } 39 | 40 | /** 41 | * 往任务的提醒中填充可读的日期时间字符串。 42 | * @param {Object} remind - 任务对象 43 | * @param {number} remind.planAlarmAt - 秒单位的UNIX时间戳 44 | */ 45 | function fillDateTimeString(remind) { 46 | remind.dateTime = makeDateTimeString(remind.planAlarmAt); 47 | } 48 | 49 | /** 50 | * 拉取所有任务并展示到页面上。 51 | * @param {number} pageNumber - 页码 52 | */ 53 | async function fetchAllTaskAndShow(pageNumber) { 54 | // 根据页码计算出limit和offset。页码约定从1开始递增。 55 | const limit = 20; 56 | const offset = (pageNumber - 1) * limit; 57 | // 请求接口获取任务列表 58 | const url = `/task?limit=${limit}&offset=${offset}`; 59 | const response = await fetch(url); 60 | const body = await response.json(); 61 | const { tasks } = body; 62 | // 构造HTML插入到id为taskListContainer的div中去 63 | // 需要呈现的内容有:任务ID、任务简述、下一次提醒的时刻、场景要求、重复模式。 64 | const makeTable = Handlebars.compile(allTaskTemplate); 65 | const tableHTML = makeTable({ tasks }); 66 | document.getElementById('wholeTaskContainer').innerHTML = tableHTML; 67 | 68 | setCurrentPageNumber(pageNumber); 69 | } 70 | 71 | function getCurrentPageNumber() { 72 | const pn = parseInt(document.getElementById('currentPageNumber').innerHTML); 73 | return Number.isNaN(pn) ? 1 : pn; 74 | } 75 | 76 | function setCurrentPageNumber(pageNumber) { 77 | document.getElementById('currentPageNumber').innerHTML = pageNumber; 78 | } 79 | 80 | async function backward() { 81 | // 获取上一个页码的任务的数据 82 | const pn = getCurrentPageNumber(); 83 | if (pn > 1) { 84 | await fetchAllTaskAndShow(pn - 1); 85 | } 86 | } 87 | window.backward = backward; 88 | 89 | async function forward() { 90 | // 获取上一个页码的任务的数据 91 | const pn = getCurrentPageNumber(); 92 | await fetchAllTaskAndShow(pn + 1); 93 | } 94 | window.forward = forward; 95 | 96 | async function main() { 97 | // 请求接口获取任务列表 98 | const url = '/task/following'; 99 | const response = await fetch(url); 100 | const body = await response.json(); 101 | const { reminds } = body; 102 | console.log(reminds); 103 | // 构造HTML插入到id为taskListContainer的div中去 104 | // 需要呈现的内容有:任务ID、任务简述、下一次提醒的时刻、场景要求、重复模式。 105 | const tableTemplate = ` 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | {{#each reminds}} 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | {{/each}} 125 |
提醒ID触发时刻重复模式任务ID任务简述场景要求
{{this.id}}{{this.dateTime}}{{this.repeatType}}{{this.taskId}}{{this.taskBrief}}{{this.contextName}}
126 | `; 127 | const makeTable = Handlebars.compile(tableTemplate); 128 | reminds.forEach(remind => { 129 | fillDateTimeString(remind); 130 | }); 131 | const tableHTML = makeTable({ reminds }); 132 | console.log('tableHTML', tableHTML); 133 | document.getElementById('taskListContainer').innerHTML = tableHTML; 134 | 135 | await fetchAllTaskAndShow(1); 136 | setCalendar(reminds); 137 | document.getElementById('followingArea').style.display = 'none'; 138 | document.getElementById('wholeArea').style.display = 'none'; 139 | } 140 | window.main = main; 141 | 142 | /** 143 | * @param {Object[]} followingReminds - 接下来的提醒 144 | */ 145 | function setCalendar(followingReminds) { 146 | // 设置日历 147 | const calendarEl = document.getElementById('calendar'); 148 | const initialDate = makeDateTimeString(Math.round(new Date().getTime() / 1000)); 149 | calendar = new FullCalendar.Calendar(calendarEl, { 150 | aspectRatio: 1.35, 151 | eventClick: (eventClickInfo) => { 152 | const { event } = eventClickInfo; 153 | console.log(`点击了事件${event.id}`); 154 | location.href = `/page/task/${event.id}`; 155 | }, 156 | expandRows: true, 157 | firstDay: new Date().getDay(), 158 | height: 'auto', 159 | initialDate, 160 | initialView: 'timeGrid', 161 | locale: 'zh-cn', 162 | nowIndicator: true, 163 | slotDuration: '00:10:00', 164 | slotLabelFormat: { 165 | hour: '2-digit', 166 | hour12: false, 167 | minute: '2-digit' 168 | }, 169 | slotMinTime: `${new Date().getHours()}:00:00`, 170 | timeZone: 'zh-cn', 171 | validRange: (nowDate) => { 172 | return { 173 | start: nowDate 174 | }; 175 | } 176 | }); 177 | calendar.render(); 178 | followingReminds.forEach((remind) => { 179 | const event = { 180 | allDay: false, 181 | id: remind.taskId, 182 | start: remind.dateTime, 183 | title: remind.taskBrief 184 | }; 185 | // if (task.remind.duration) { 186 | // event.end = makeDateTimeString(task.remind.timestamp + task.remind.duration); 187 | // } 188 | calendar.addEvent(event); 189 | }); 190 | } 191 | 192 | window.showCalendar = function () { 193 | // 隐藏列表和任务,露出日历 194 | document.getElementById('followingArea').style.display = 'none'; 195 | document.getElementById('wholeArea').style.display = 'none'; 196 | document.getElementById('calendar').style.display = 'block'; 197 | document.getElementById('searchResultContainer').style.display = 'none'; 198 | }; 199 | 200 | window.showFollowing = function () { 201 | // 隐藏日历和任务,露出列表 202 | document.getElementById('followingArea').style.display = 'block'; 203 | document.getElementById('wholeArea').style.display = 'none'; 204 | document.getElementById('calendar').style.display = 'none'; 205 | document.getElementById('searchResultContainer').style.display = 'none'; 206 | }; 207 | 208 | window.showTasks = function () { 209 | // 隐藏列表和日历,露出任务 210 | document.getElementById('followingArea').style.display = 'none'; 211 | document.getElementById('wholeArea').style.display = 'block'; 212 | document.getElementById('calendar').style.display = 'none'; 213 | document.getElementById('searchResultContainer').style.display = 'none'; 214 | }; 215 | 216 | window.showSearchResult = function () { 217 | // 露出搜索结果 218 | document.getElementById('followingArea').style.display = 'none'; 219 | document.getElementById('wholeArea').style.display = 'none'; 220 | document.getElementById('calendar').style.display = 'none'; 221 | document.getElementById('searchResultContainer').style.display = 'block'; 222 | }; 223 | 224 | window.search = async function () { 225 | console.log('点击了搜索'); 226 | const query = document.getElementById('query').value; 227 | console.log(`query is ${query}`); 228 | const url = `/task?brief=${query}`; 229 | const response = await fetch(url); 230 | const body = await response.json(); 231 | const { tasks } = body; 232 | const makeTable = Handlebars.compile(allTaskTemplate); 233 | const tableHTML = makeTable({ tasks }); 234 | document.getElementById('searchResultContainer').innerHTML = tableHTML; 235 | window.showSearchResult(); 236 | }; 237 | -------------------------------------------------------------------------------- /contrib/emacs/org-cuckoo.el: -------------------------------------------------------------------------------- 1 | (cl-defun org-cuckoo--request (path success &rest args &key data headers type) 2 | "封装对cuckoo的API的访问。" 3 | ;; 先构造URL 4 | ;; 再构造要传给request函数的整个参数列表 5 | ;; 最后用apply来调用request。 6 | (let ((url (format "http://localhost:7001%s" path))) 7 | (apply #'request 8 | url 9 | :encoding 'utf-8 10 | :error (cl-function (lambda (&rest args &key data response &allow-other-keys) 11 | (let ((status-code (request-response-status-code response))) 12 | (if (= status-code 400) 13 | (let ((data (json-read-from-string data))) 14 | (message "%s" (cdr (assoc 'message data)))) 15 | (message "Got error: %S" status-code))))) 16 | :parser 'json-read 17 | :success success 18 | :sync t 19 | args))) 20 | 21 | ;;; 在cuckoo中创建光标所在任务的定时提醒 22 | (cl-defun org-cuckoo--scheduled-to-repeat-type (scheduled) 23 | "提取TODO条目的SCHEDULED属性的重复模式。" 24 | (let ((pattern "[0-9]+-[0-9]+-[0-9]+ .+ [0-9]+:[0-9]+.+\\(\\+[0-9]+[dhwy]\\)") 25 | (raw-repeat-type)) 26 | (when (string-match-p pattern scheduled) 27 | (string-match pattern scheduled) 28 | (setq raw-repeat-type (match-string 1 scheduled))) 29 | (unless raw-repeat-type 30 | (return-from org-cuckoo--scheduled-to-repeat-type)) 31 | (let (n unit) 32 | (string-match "\\+\\([0-9]+\\)\\([dhwy]\\)" scheduled) 33 | (setq n (match-string 1 scheduled) 34 | unit (match-string 2 scheduled)) 35 | ;; 把org-mode的重复模式的“单位”转换为cuckoo的单位 36 | (setq unit (cond ((string= unit "d") "days") 37 | ((string= unit "h") "hours") 38 | ((string= unit "w") "weeks") 39 | ((string= unit "y") "years") 40 | (t (error "Unrecognized unit: %s" unit)))) 41 | (format "every_%s_%s" n unit)))) 42 | 43 | (defun scheduled-to-time (scheduled) 44 | "将TODO条目的SCHEDULED属性转换为UNIX时间戳" 45 | ;; 为了能够支持形如<2019-06-15 Sat 14:25-14:55>这样的时间戳,会先用正则表达式提取date-to-time能够处理的部分 46 | (let* ((date (progn 47 | (string-match "\\([0-9]+-[0-9]+-[0-9]+ .+ [0-9]+:[0-9]+\\)" scheduled) 48 | (match-string 0 scheduled))) 49 | (lst (date-to-time date))) 50 | (+ (* (car lst) (expt 2 16)) 51 | (cadr lst)))) 52 | 53 | (cl-defun create-remind-in-cuckoo (task-id timestamp 54 | &key duration repeat-type) 55 | "往cuckoo中创建一个定时提醒并返回这个刚创建的提醒的ID" 56 | (let (remind-id) 57 | (org-cuckoo--request 58 | "/remind" 59 | (cl-function 60 | (lambda (&key data &allow-other-keys) 61 | (message "返回内容为:%S" data) 62 | (let ((remind data)) 63 | (setq remind-id (cdr (assoc 'id (cdr (car remind)))))))) 64 | :data (json-encode-alist 65 | (list (cons "duration" duration) 66 | (cons "repeat_type" repeat-type) 67 | (cons "taskId" (number-to-string task-id)) 68 | (cons "timestamp" timestamp))) 69 | :headers '(("Content-Type" . "application/json")) 70 | :type "POST") 71 | remind-id)) 72 | 73 | (defun org-cuckoo--extract-task-detail () 74 | "") 75 | 76 | (defvar *org-cuckoo-default-task-detail-extractor* 'org-cuckoo--extract-task-detail 77 | "默认的、提取创建cuckoo中的任务时需要的detail参数的值的函数。") 78 | (defvar *org-cuckoo-default-icon-file* nil 79 | "默认的、创建任务时传给icon_file参数的图片文件路径。") 80 | 81 | (defun cuckoo-get-task (id) 82 | "获取ID这个任务" 83 | (let (task) 84 | (org-cuckoo--request 85 | (concat "/task/" id) 86 | (cl-function 87 | (lambda (&key data &allow-other-keys) 88 | (setq task data)))) 89 | (cdr (car task)))) 90 | 91 | (defun cuckoo-update-remind (id repeat-type timestamp) 92 | "更新指定的提醒的触发时间戳为TIMESTAMP" 93 | (org-cuckoo--request 94 | (concat "/remind/" (number-to-string id)) 95 | (cl-function 96 | (lambda (&key data &allow-other-keys) 97 | (message "更新了提醒的触发时刻"))) 98 | :data (json-encode (list (cons "repeat_type" repeat-type) 99 | (cons "timestamp" timestamp))) 100 | :headers '(("Content-Type" . "application/json")) 101 | :type "PATCH") 102 | t) 103 | 104 | (defun cuckoo-update-task (id brief detail) 105 | "更新指定的任务的简述为BRIEF,详情为DETAIL" 106 | (org-cuckoo--request 107 | (concat "/task/" (number-to-string id)) 108 | (cl-function 109 | (lambda (&key data &allow-other-keys) 110 | (message "更新了任务的简述和详情"))) 111 | :data (encode-coding-string (json-encode (list (cons "brief" brief) 112 | (cons "detail" detail) 113 | (cons "state" "active"))) 114 | 'utf-8) 115 | :headers '(("Content-Type" . "application/json")) 116 | :type "PATCH") 117 | t) 118 | 119 | (defun create-task-in-cuckoo () 120 | "根据光标所在条目创建一个cuckoo中的任务。" 121 | (interactive) 122 | (let ((brief) 123 | (detail) 124 | (device) 125 | (duration) 126 | (icon-file *org-cuckoo-default-icon-file*) 127 | (remind-id) 128 | (task-id)) 129 | 130 | (setq brief (nth 4 (org-heading-components))) 131 | (setf detail (funcall *org-cuckoo-default-task-detail-extractor*)) 132 | (setf device (org-entry-get nil "DEVICE")) 133 | (setf duration (or (org-entry-get nil "DURATION") 60)) 134 | 135 | ;; 取出旧的任务和提醒并赋值给task-id和remind-id 136 | (let ((id (org-entry-get nil "TASK_ID"))) 137 | (when id 138 | (let ((task (cuckoo-get-task id))) 139 | (when task 140 | (setq task-id (cdr (assoc 'id task))) 141 | (setq remind-id (cdr (assoc 'id (cdr (assoc 'remind task))))))))) 142 | (message "现在的task-id为:%S" task-id) 143 | (message "现在的remind-id为:%S" remind-id) 144 | 145 | ;; 如果有task-id则同样只是更新,否则就创建一个新的 146 | (if task-id 147 | (cuckoo-update-task task-id brief detail) 148 | (org-cuckoo--request 149 | "/task" 150 | (cl-function 151 | (lambda (&key data &allow-other-keys) 152 | (message "data: %S" data) 153 | (let ((task data)) 154 | (setq task-id (cdr (assoc 'id (cdr (car task))))) 155 | (message "任务%S创建完毕" task-id)))) 156 | :data (encode-coding-string (json-encode (list (cons "brief" brief) 157 | (cons "detail" detail) 158 | (cons "device" device) 159 | (cons "icon_file" icon-file))) 160 | 'utf-8) 161 | :headers '(("Content-Type" . "application/json")) 162 | :type "POST")) 163 | 164 | (let* ((scheduled (org-entry-get nil "SCHEDULED")) 165 | (repeat-type (org-cuckoo--scheduled-to-repeat-type scheduled)) 166 | (timestamp (scheduled-to-time scheduled))) 167 | ;; 如果有remind-id就更新已有的提醒的触发时刻,否则就创建一个新的 168 | (if remind-id 169 | (cuckoo-update-remind remind-id repeat-type timestamp) 170 | (setq remind-id (create-remind-in-cuckoo task-id timestamp :duration duration :repeat-type repeat-type)))) 171 | 172 | (org-set-property "TASK_ID" (number-to-string task-id)))) 173 | 174 | ;;;###autoload 175 | (defun goto-and-create-task () 176 | "先打开条目对应的.org文件再创建cuckoo中的任务" 177 | (interactive) 178 | (org-agenda-goto) 179 | (create-task-in-cuckoo)) 180 | 181 | ;;;###autoload 182 | (cl-defun org-cuckoo-schedule (arg &optional time) 183 | "调用内置的org-schedule,并在带有一个prefix argument的时候关闭cuckoo中的对应任务" 184 | (interactive "P") 185 | (org-schedule arg time) 186 | (when (= (prefix-numeric-value arg) 1) 187 | (message "设置了SCHEDULED属性,将会创建对应的cuckoo任务和提醒。") 188 | (let ((scheduled (org-entry-get nil "SCHEDULED"))) 189 | (unless (string-match " [0-9]+:[0-9]+" scheduled) 190 | (message "当前SCHEDULED属性没有小时和分钟,无法创建提醒。") 191 | (return-from org-cuckoo-schedule))) 192 | (call-interactively 'create-task-in-cuckoo)) 193 | 194 | (when (= (prefix-numeric-value arg) 4) 195 | (message "按下了一个prefix argument,此时应当从cuckoo中删除任务") 196 | (let ((id (org-entry-get nil "TASK_ID"))) 197 | (org-cuckoo--request 198 | (concat "/task/" id) 199 | (cl-function 200 | (lambda (&key data &allow-other-keys) 201 | (message "设置了任务%s为【不使用的】" id))) 202 | :data (encode-coding-string (json-encode (list (cons "state" "inactive"))) 203 | 'utf-8) 204 | :headers '(("Content-Type" . "application/json")) 205 | :type "PATCH")))) 206 | 207 | ;;;###autoload 208 | (cl-defun cuckoo-done-state () 209 | "在当前条目切换至DONE的时候,将相应的cuckoo中的任务的状态也修改为done" 210 | (let ((state org-state)) 211 | (unless (string= state "DONE") 212 | (return-from cuckoo-done-state)) 213 | 214 | ;; 获取当前条目的TASK_ID属性。如果不存在这个属性,说明没有在cuckoo中创建过任务,无须理会 215 | (let ((scheduled (org-entry-get nil "SCHEDULED")) 216 | (task-id (org-entry-get nil "TASK_ID"))) 217 | (unless task-id 218 | (return-from cuckoo-done-state)) 219 | (when (string-match "\\+[0-9]+.>$" scheduled) 220 | (message "当前条目会被重复安排,不需要关闭任务%s" task-id) 221 | (return-from cuckoo-done-state)) 222 | 223 | (org-cuckoo--request 224 | (concat "/task/" task-id) 225 | (cl-function 226 | (lambda (&key data &allow-other-keys) 227 | (message "设置了任务%s为【已完成】" task-id))) 228 | :data (encode-coding-string (json-encode (list (cons "state" "done"))) 229 | 'utf-8) 230 | :headers '(("Content-Type" . "application/json")) 231 | :type "PATCH")))) 232 | 233 | ;;;###autoload 234 | (defun cuckoo-cancelled-state () 235 | "在当前条目切换至CANCELLED的时候,将相应的cuckoo中的任务的状态也修改为inactive" 236 | (let ((state org-state)) 237 | (when (string= state "CANCELLED") 238 | ;; 获取当前条目的TASK_ID属性。如果不存在这个属性,说明没有在cuckoo中创建过任务,无须理会 239 | (let ((task-id (org-entry-get nil "TASK_ID"))) 240 | (when task-id 241 | (org-cuckoo--request 242 | (concat "/task/" task-id) 243 | (cl-function 244 | (lambda (&key data &allow-other-keys) 245 | (message "设置了任务%s为【不使用】" task-id))) 246 | :data (encode-coding-string (json-encode (list (cons "state" "inactive"))) 247 | 'utf-8) 248 | :headers '(("Content-Type" . "application/json")) 249 | :type "PATCH")))))) 250 | 251 | (defun org-cuckoo--get-task-id () 252 | "获取光标所在的条目的TASK_ID属性。" 253 | (org-entry-get nil "TASK_ID")) 254 | 255 | (cl-defun org-cuckoo-view-task () 256 | "查看当前条目对应的任务的信息。" 257 | (interactive) 258 | (let ((task-id (org-cuckoo--get-task-id))) 259 | (unless task-id 260 | (message "当前条目没有对应的任务。") 261 | (return-from org-cuckoo-view-task)) 262 | (org-cuckoo--request 263 | (concat "/task/" task-id) 264 | (cl-function 265 | (lambda (&key data &allow-other-keys) 266 | (message "请求完毕") 267 | (let ((task data)) 268 | (message "任务:\n- 标题:%s\n- 详情:%s" 269 | (cdr (assoc 'brief (cdr (car task)))) 270 | (cdr (assoc 'detail (cdr (car task))))))))))) 271 | 272 | (define-minor-mode org-cuckoo-mode 273 | "开启或关闭org-cuckoo的功能。" 274 | :lighter " cuckoo" 275 | (add-hook 'org-after-todo-state-change-hook 'cuckoo-cancelled-state) 276 | (add-hook 'org-after-todo-state-change-hook 'cuckoo-done-state)) 277 | 278 | (provide 'org-cuckoo) 279 | 280 | ;;; org-cuckoo.el ends here 281 | -------------------------------------------------------------------------------- /app/public/css/main.css: -------------------------------------------------------------------------------- 1 | 2 | /* classes attached to */ 3 | 4 | .fc-not-allowed, 5 | .fc-not-allowed .fc-event { /* override events' custom cursors */ 6 | cursor: not-allowed; 7 | } 8 | 9 | .fc-unselectable { 10 | -webkit-user-select: none; 11 | -moz-user-select: none; 12 | -ms-user-select: none; 13 | user-select: none; 14 | -webkit-touch-callout: none; 15 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0); 16 | } 17 | .fc { 18 | /* layout of immediate children */ 19 | display: flex; 20 | flex-direction: column; 21 | 22 | font-size: 1em 23 | } 24 | .fc, 25 | .fc *, 26 | .fc *:before, 27 | .fc *:after { 28 | box-sizing: border-box; 29 | } 30 | .fc table { 31 | border-collapse: collapse; 32 | border-spacing: 0; 33 | font-size: 1em; /* normalize cross-browser */ 34 | } 35 | .fc th { 36 | text-align: center; 37 | } 38 | .fc th, 39 | .fc td { 40 | vertical-align: top; 41 | padding: 0; 42 | } 43 | .fc a[data-navlink] { 44 | cursor: pointer; 45 | } 46 | .fc a[data-navlink]:hover { 47 | text-decoration: underline; 48 | } 49 | .fc-direction-ltr { 50 | direction: ltr; 51 | text-align: left; 52 | } 53 | .fc-direction-rtl { 54 | direction: rtl; 55 | text-align: right; 56 | } 57 | .fc-theme-standard td, 58 | .fc-theme-standard th { 59 | border: 1px solid #ddd; 60 | border: 1px solid var(--fc-border-color, #ddd); 61 | } 62 | /* for FF, which doesn't expand a 100% div within a table cell. use absolute positioning */ 63 | /* inner-wrappers are responsible for being absolute */ 64 | /* TODO: best place for this? */ 65 | .fc-liquid-hack td, 66 | .fc-liquid-hack th { 67 | position: relative; 68 | } 69 | 70 | @font-face { 71 | font-family: 'fcicons'; 72 | src: url("data:application/x-font-ttf;charset=utf-8;base64,AAEAAAALAIAAAwAwT1MvMg8SBfAAAAC8AAAAYGNtYXAXVtKNAAABHAAAAFRnYXNwAAAAEAAAAXAAAAAIZ2x5ZgYydxIAAAF4AAAFNGhlYWQUJ7cIAAAGrAAAADZoaGVhB20DzAAABuQAAAAkaG10eCIABhQAAAcIAAAALGxvY2ED4AU6AAAHNAAAABhtYXhwAA8AjAAAB0wAAAAgbmFtZXsr690AAAdsAAABhnBvc3QAAwAAAAAI9AAAACAAAwPAAZAABQAAApkCzAAAAI8CmQLMAAAB6wAzAQkAAAAAAAAAAAAAAAAAAAABEAAAAAAAAAAAAAAAAAAAAABAAADpBgPA/8AAQAPAAEAAAAABAAAAAAAAAAAAAAAgAAAAAAADAAAAAwAAABwAAQADAAAAHAADAAEAAAAcAAQAOAAAAAoACAACAAIAAQAg6Qb//f//AAAAAAAg6QD//f//AAH/4xcEAAMAAQAAAAAAAAAAAAAAAQAB//8ADwABAAAAAAAAAAAAAgAANzkBAAAAAAEAAAAAAAAAAAACAAA3OQEAAAAAAQAAAAAAAAAAAAIAADc5AQAAAAABAWIAjQKeAskAEwAAJSc3NjQnJiIHAQYUFwEWMjc2NCcCnuLiDQ0MJAz/AA0NAQAMJAwNDcni4gwjDQwM/wANIwz/AA0NDCMNAAAAAQFiAI0CngLJABMAACUBNjQnASYiBwYUHwEHBhQXFjI3AZ4BAA0N/wAMJAwNDeLiDQ0MJAyNAQAMIw0BAAwMDSMM4uINIwwNDQAAAAIA4gC3Ax4CngATACcAACUnNzY0JyYiDwEGFB8BFjI3NjQnISc3NjQnJiIPAQYUHwEWMjc2NCcB87e3DQ0MIw3VDQ3VDSMMDQ0BK7e3DQ0MJAzVDQ3VDCQMDQ3zuLcMJAwNDdUNIwzWDAwNIwy4twwkDA0N1Q0jDNYMDA0jDAAAAgDiALcDHgKeABMAJwAAJTc2NC8BJiIHBhQfAQcGFBcWMjchNzY0LwEmIgcGFB8BBwYUFxYyNwJJ1Q0N1Q0jDA0Nt7cNDQwjDf7V1Q0N1QwkDA0Nt7cNDQwkDLfWDCMN1Q0NDCQMt7gMIw0MDNYMIw3VDQ0MJAy3uAwjDQwMAAADAFUAAAOrA1UAMwBoAHcAABMiBgcOAQcOAQcOARURFBYXHgEXHgEXHgEzITI2Nz4BNz4BNz4BNRE0JicuAScuAScuASMFITIWFx4BFx4BFx4BFREUBgcOAQcOAQcOASMhIiYnLgEnLgEnLgE1ETQ2Nz4BNz4BNz4BMxMhMjY1NCYjISIGFRQWM9UNGAwLFQkJDgUFBQUFBQ4JCRULDBgNAlYNGAwLFQkJDgUFBQUFBQ4JCRULDBgN/aoCVgQIBAQHAwMFAQIBAQIBBQMDBwQECAT9qgQIBAQHAwMFAQIBAQIBBQMDBwQECASAAVYRGRkR/qoRGRkRA1UFBAUOCQkVDAsZDf2rDRkLDBUJCA4FBQUFBQUOCQgVDAsZDQJVDRkLDBUJCQ4FBAVVAgECBQMCBwQECAX9qwQJAwQHAwMFAQICAgIBBQMDBwQDCQQCVQUIBAQHAgMFAgEC/oAZEhEZGRESGQAAAAADAFUAAAOrA1UAMwBoAIkAABMiBgcOAQcOAQcOARURFBYXHgEXHgEXHgEzITI2Nz4BNz4BNz4BNRE0JicuAScuAScuASMFITIWFx4BFx4BFx4BFREUBgcOAQcOAQcOASMhIiYnLgEnLgEnLgE1ETQ2Nz4BNz4BNz4BMxMzFRQWMzI2PQEzMjY1NCYrATU0JiMiBh0BIyIGFRQWM9UNGAwLFQkJDgUFBQUFBQ4JCRULDBgNAlYNGAwLFQkJDgUFBQUFBQ4JCRULDBgN/aoCVgQIBAQHAwMFAQIBAQIBBQMDBwQECAT9qgQIBAQHAwMFAQIBAQIBBQMDBwQECASAgBkSEhmAERkZEYAZEhIZgBEZGREDVQUEBQ4JCRUMCxkN/asNGQsMFQkIDgUFBQUFBQ4JCBUMCxkNAlUNGQsMFQkJDgUEBVUCAQIFAwIHBAQIBf2rBAkDBAcDAwUBAgICAgEFAwMHBAMJBAJVBQgEBAcCAwUCAQL+gIASGRkSgBkSERmAEhkZEoAZERIZAAABAOIAjQMeAskAIAAAExcHBhQXFjI/ARcWMjc2NC8BNzY0JyYiDwEnJiIHBhQX4uLiDQ0MJAzi4gwkDA0N4uINDQwkDOLiDCQMDQ0CjeLiDSMMDQ3h4Q0NDCMN4uIMIw0MDOLiDAwNIwwAAAABAAAAAQAAa5n0y18PPPUACwQAAAAAANivOVsAAAAA2K85WwAAAAADqwNVAAAACAACAAAAAAAAAAEAAAPA/8AAAAQAAAAAAAOrAAEAAAAAAAAAAAAAAAAAAAALBAAAAAAAAAAAAAAAAgAAAAQAAWIEAAFiBAAA4gQAAOIEAABVBAAAVQQAAOIAAAAAAAoAFAAeAEQAagCqAOoBngJkApoAAQAAAAsAigADAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAA4ArgABAAAAAAABAAcAAAABAAAAAAACAAcAYAABAAAAAAADAAcANgABAAAAAAAEAAcAdQABAAAAAAAFAAsAFQABAAAAAAAGAAcASwABAAAAAAAKABoAigADAAEECQABAA4ABwADAAEECQACAA4AZwADAAEECQADAA4APQADAAEECQAEAA4AfAADAAEECQAFABYAIAADAAEECQAGAA4AUgADAAEECQAKADQApGZjaWNvbnMAZgBjAGkAYwBvAG4Ac1ZlcnNpb24gMS4wAFYAZQByAHMAaQBvAG4AIAAxAC4AMGZjaWNvbnMAZgBjAGkAYwBvAG4Ac2ZjaWNvbnMAZgBjAGkAYwBvAG4Ac1JlZ3VsYXIAUgBlAGcAdQBsAGEAcmZjaWNvbnMAZgBjAGkAYwBvAG4Ac0ZvbnQgZ2VuZXJhdGVkIGJ5IEljb01vb24uAEYAbwBuAHQAIABnAGUAbgBlAHIAYQB0AGUAZAAgAGIAeQAgAEkAYwBvAE0AbwBvAG4ALgAAAAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") format('truetype'); 73 | font-weight: normal; 74 | font-style: normal; 75 | } 76 | 77 | .fc-icon { 78 | /* added for fc */ 79 | display: inline-block; 80 | width: 1em; 81 | height: 1em; 82 | text-align: center; 83 | -webkit-user-select: none; 84 | -moz-user-select: none; 85 | -ms-user-select: none; 86 | user-select: none; 87 | 88 | /* use !important to prevent issues with browser extensions that change fonts */ 89 | font-family: 'fcicons' !important; 90 | speak: none; 91 | font-style: normal; 92 | font-weight: normal; 93 | font-variant: normal; 94 | text-transform: none; 95 | line-height: 1; 96 | 97 | /* Better Font Rendering =========== */ 98 | -webkit-font-smoothing: antialiased; 99 | -moz-osx-font-smoothing: grayscale; 100 | } 101 | 102 | .fc-icon-chevron-left:before { 103 | content: "\e900"; 104 | } 105 | 106 | .fc-icon-chevron-right:before { 107 | content: "\e901"; 108 | } 109 | 110 | .fc-icon-chevrons-left:before { 111 | content: "\e902"; 112 | } 113 | 114 | .fc-icon-chevrons-right:before { 115 | content: "\e903"; 116 | } 117 | 118 | .fc-icon-minus-square:before { 119 | content: "\e904"; 120 | } 121 | 122 | .fc-icon-plus-square:before { 123 | content: "\e905"; 124 | } 125 | 126 | .fc-icon-x:before { 127 | content: "\e906"; 128 | } 129 | /* 130 | Lots taken from Flatly (MIT): https://bootswatch.com/4/flatly/bootstrap.css 131 | 132 | These styles only apply when the standard-theme is activated. 133 | When it's NOT activated, the fc-button classes won't even be in the DOM. 134 | */ 135 | .fc { 136 | 137 | /* reset */ 138 | 139 | } 140 | .fc .fc-button { 141 | border-radius: 0; 142 | overflow: visible; 143 | text-transform: none; 144 | margin: 0; 145 | font-family: inherit; 146 | font-size: inherit; 147 | line-height: inherit; 148 | } 149 | .fc .fc-button:focus { 150 | outline: 1px dotted; 151 | outline: 5px auto -webkit-focus-ring-color; 152 | } 153 | .fc .fc-button { 154 | -webkit-appearance: button; 155 | } 156 | .fc .fc-button:not(:disabled) { 157 | cursor: pointer; 158 | } 159 | .fc .fc-button::-moz-focus-inner { 160 | padding: 0; 161 | border-style: none; 162 | } 163 | .fc { 164 | 165 | /* theme */ 166 | 167 | } 168 | .fc .fc-button { 169 | display: inline-block; 170 | font-weight: 400; 171 | text-align: center; 172 | vertical-align: middle; 173 | -webkit-user-select: none; 174 | -moz-user-select: none; 175 | -ms-user-select: none; 176 | user-select: none; 177 | background-color: transparent; 178 | border: 1px solid transparent; 179 | padding: 0.4em 0.65em; 180 | font-size: 1em; 181 | line-height: 1.5; 182 | border-radius: 0.25em; 183 | } 184 | .fc .fc-button:hover { 185 | text-decoration: none; 186 | } 187 | .fc .fc-button:focus { 188 | outline: 0; 189 | box-shadow: 0 0 0 0.2rem rgba(44, 62, 80, 0.25); 190 | } 191 | .fc .fc-button:disabled { 192 | opacity: 0.65; 193 | } 194 | .fc { 195 | 196 | /* "primary" coloring */ 197 | 198 | } 199 | .fc .fc-button-primary { 200 | color: #fff; 201 | color: var(--fc-button-text-color, #fff); 202 | background-color: #2C3E50; 203 | background-color: var(--fc-button-bg-color, #2C3E50); 204 | border-color: #2C3E50; 205 | border-color: var(--fc-button-border-color, #2C3E50); 206 | } 207 | .fc .fc-button-primary:hover { 208 | color: #fff; 209 | color: var(--fc-button-text-color, #fff); 210 | background-color: #1e2b37; 211 | background-color: var(--fc-button-hover-bg-color, #1e2b37); 212 | border-color: #1a252f; 213 | border-color: var(--fc-button-hover-border-color, #1a252f); 214 | } 215 | .fc .fc-button-primary:disabled { /* not DRY */ 216 | color: #fff; 217 | color: var(--fc-button-text-color, #fff); 218 | background-color: #2C3E50; 219 | background-color: var(--fc-button-bg-color, #2C3E50); 220 | border-color: #2C3E50; 221 | border-color: var(--fc-button-border-color, #2C3E50); /* overrides :hover */ 222 | } 223 | .fc .fc-button-primary:focus { 224 | box-shadow: 0 0 0 0.2rem rgba(76, 91, 106, 0.5); 225 | } 226 | .fc .fc-button-primary:not(:disabled):active, 227 | .fc .fc-button-primary:not(:disabled).fc-button-active { 228 | color: #fff; 229 | color: var(--fc-button-text-color, #fff); 230 | background-color: #1a252f; 231 | background-color: var(--fc-button-active-bg-color, #1a252f); 232 | border-color: #151e27; 233 | border-color: var(--fc-button-active-border-color, #151e27); 234 | } 235 | .fc .fc-button-primary:not(:disabled):active:focus, 236 | .fc .fc-button-primary:not(:disabled).fc-button-active:focus { 237 | box-shadow: 0 0 0 0.2rem rgba(76, 91, 106, 0.5); 238 | } 239 | .fc { 240 | 241 | /* icons within buttons */ 242 | 243 | } 244 | .fc .fc-button .fc-icon { 245 | vertical-align: middle; 246 | font-size: 1.5em; /* bump up the size (but don't make it bigger than line-height of button, which is 1.5em also) */ 247 | } 248 | .fc .fc-button-group { 249 | position: relative; 250 | display: inline-flex; 251 | vertical-align: middle; 252 | } 253 | .fc .fc-button-group > .fc-button { 254 | position: relative; 255 | flex: 1 1 auto; 256 | } 257 | .fc .fc-button-group > .fc-button:hover { 258 | z-index: 1; 259 | } 260 | .fc .fc-button-group > .fc-button:focus, 261 | .fc .fc-button-group > .fc-button:active, 262 | .fc .fc-button-group > .fc-button.fc-button-active { 263 | z-index: 1; 264 | } 265 | .fc-direction-ltr .fc-button-group > .fc-button:not(:first-child) { 266 | margin-left: -1px; 267 | border-top-left-radius: 0; 268 | border-bottom-left-radius: 0; 269 | } 270 | .fc-direction-ltr .fc-button-group > .fc-button:not(:last-child) { 271 | border-top-right-radius: 0; 272 | border-bottom-right-radius: 0; 273 | } 274 | .fc-direction-rtl .fc-button-group > .fc-button:not(:first-child) { 275 | margin-right: -1px; 276 | border-top-right-radius: 0; 277 | border-bottom-right-radius: 0; 278 | } 279 | .fc-direction-rtl .fc-button-group > .fc-button:not(:last-child) { 280 | border-top-left-radius: 0; 281 | border-bottom-left-radius: 0; 282 | } 283 | .fc .fc-toolbar { 284 | display: flex; 285 | justify-content: space-between; 286 | align-items: center; 287 | } 288 | .fc .fc-toolbar.fc-header-toolbar { 289 | margin-bottom: 1.5em; 290 | } 291 | .fc .fc-toolbar.fc-footer-toolbar { 292 | margin-top: 1.5em; 293 | } 294 | .fc .fc-toolbar-title { 295 | font-size: 1.75em; 296 | margin: 0; 297 | } 298 | .fc-direction-ltr .fc-toolbar > * > :not(:first-child) { 299 | margin-left: .75em; /* space between */ 300 | } 301 | .fc-direction-rtl .fc-toolbar > * > :not(:first-child) { 302 | margin-right: .75em; /* space between */ 303 | } 304 | .fc-direction-rtl .fc-toolbar-ltr { /* when the toolbar-chunk positioning system is explicitly left-to-right */ 305 | flex-direction: row-reverse; 306 | } 307 | .fc .fc-scroller { 308 | -webkit-overflow-scrolling: touch; 309 | position: relative; /* for abs-positioned elements within */ 310 | } 311 | .fc .fc-scroller-liquid { 312 | height: 100%; 313 | } 314 | .fc .fc-scroller-liquid-absolute { 315 | position: absolute; 316 | top: 0; 317 | right: 0; 318 | left: 0; 319 | bottom: 0; 320 | } 321 | .fc .fc-scroller-harness { 322 | position: relative; 323 | overflow: hidden; 324 | direction: ltr; 325 | /* hack for chrome computing the scroller's right/left wrong for rtl. undone below... */ 326 | /* TODO: demonstrate in codepen */ 327 | } 328 | .fc .fc-scroller-harness-liquid { 329 | height: 100%; 330 | } 331 | .fc-direction-rtl .fc-scroller-harness > .fc-scroller { /* undo above hack */ 332 | direction: rtl; 333 | } 334 | .fc-theme-standard .fc-scrollgrid { 335 | border: 1px solid #ddd; 336 | border: 1px solid var(--fc-border-color, #ddd); /* bootstrap does this. match */ 337 | } 338 | .fc .fc-scrollgrid, 339 | .fc .fc-scrollgrid table { /* all tables (self included) */ 340 | width: 100%; /* because tables don't normally do this */ 341 | table-layout: fixed; 342 | } 343 | .fc .fc-scrollgrid table { /* inner tables */ 344 | border-top-style: hidden; 345 | border-left-style: hidden; 346 | border-right-style: hidden; 347 | } 348 | .fc .fc-scrollgrid { 349 | 350 | border-collapse: separate; 351 | border-right-width: 0; 352 | border-bottom-width: 0; 353 | 354 | } 355 | .fc .fc-scrollgrid-liquid { 356 | height: 100%; 357 | } 358 | .fc .fc-scrollgrid-section { /* a */ 359 | height: 1px /* better than 0, for firefox */ 360 | 361 | } 362 | .fc .fc-scrollgrid-section > td { 363 | height: 1px; /* needs a height so inner div within grow. better than 0, for firefox */ 364 | } 365 | .fc .fc-scrollgrid-section table { 366 | height: 1px; 367 | /* for most browsers, if a height isn't set on the table, can't do liquid-height within cells */ 368 | /* serves as a min-height. harmless */ 369 | } 370 | .fc .fc-scrollgrid-section-liquid { 371 | height: auto 372 | 373 | } 374 | .fc .fc-scrollgrid-section-liquid > td { 375 | height: 100%; /* better than `auto`, for firefox */ 376 | } 377 | .fc .fc-scrollgrid-section > * { 378 | border-top-width: 0; 379 | border-left-width: 0; 380 | } 381 | .fc .fc-scrollgrid-section-header > *, 382 | .fc .fc-scrollgrid-section-footer > * { 383 | border-bottom-width: 0; 384 | } 385 | .fc .fc-scrollgrid-section-body table, 386 | .fc .fc-scrollgrid-section-footer table { 387 | border-bottom-style: hidden; /* head keeps its bottom border tho */ 388 | } 389 | .fc { 390 | 391 | /* stickiness */ 392 | 393 | } 394 | .fc .fc-scrollgrid-section-sticky > * { 395 | background: #fff; 396 | background: var(--fc-page-bg-color, #fff); 397 | position: -webkit-sticky; 398 | position: sticky; 399 | z-index: 2; /* TODO: var */ 400 | /* TODO: box-shadow when sticking */ 401 | } 402 | .fc .fc-scrollgrid-section-header.fc-scrollgrid-section-sticky > * { 403 | top: 0; /* because border-sharing causes a gap at the top */ 404 | /* TODO: give safari -1. has bug */ 405 | } 406 | .fc .fc-scrollgrid-section-footer.fc-scrollgrid-section-sticky > * { 407 | bottom: 0; /* known bug: bottom-stickiness doesn't work in safari */ 408 | } 409 | .fc .fc-scrollgrid-sticky-shim { /* for horizontal scrollbar */ 410 | height: 1px; /* needs height to create scrollbars */ 411 | margin-bottom: -1px; 412 | } 413 | .fc-sticky { /* no .fc wrap because used as child of body */ 414 | position: -webkit-sticky; 415 | position: sticky; 416 | } 417 | .fc .fc-view-harness { 418 | flex-grow: 1; /* because this harness is WITHIN the .fc's flexbox */ 419 | position: relative; 420 | } 421 | .fc { 422 | 423 | /* when the harness controls the height, make the view liquid */ 424 | 425 | } 426 | .fc .fc-view-harness-active > .fc-view { 427 | position: absolute; 428 | top: 0; 429 | right: 0; 430 | bottom: 0; 431 | left: 0; 432 | } 433 | .fc .fc-col-header-cell-cushion { 434 | display: inline-block; /* x-browser for when sticky (when multi-tier header) */ 435 | padding: 2px 4px; 436 | } 437 | .fc .fc-bg-event, 438 | .fc .fc-non-business, 439 | .fc .fc-highlight { 440 | /* will always have a harness with position:relative/absolute, so absolutely expand */ 441 | position: absolute; 442 | top: 0; 443 | left: 0; 444 | right: 0; 445 | bottom: 0; 446 | } 447 | .fc .fc-non-business { 448 | background: rgba(215, 215, 215, 0.3); 449 | background: var(--fc-non-business-color, rgba(215, 215, 215, 0.3)); 450 | } 451 | .fc .fc-bg-event { 452 | background: rgb(143, 223, 130); 453 | background: var(--fc-bg-event-color, rgb(143, 223, 130)); 454 | opacity: 0.3; 455 | opacity: var(--fc-bg-event-opacity, 0.3) 456 | } 457 | .fc .fc-bg-event .fc-event-title { 458 | margin: .5em; 459 | font-size: .85em; 460 | font-size: var(--fc-small-font-size, .85em); 461 | font-style: italic; 462 | } 463 | .fc .fc-highlight { 464 | background: rgba(188, 232, 241, 0.3); 465 | background: var(--fc-highlight-color, rgba(188, 232, 241, 0.3)); 466 | } 467 | .fc .fc-cell-shaded, 468 | .fc .fc-day-disabled { 469 | background: rgba(208, 208, 208, 0.3); 470 | background: var(--fc-neutral-bg-color, rgba(208, 208, 208, 0.3)); 471 | } 472 | /* link resets */ 473 | /* ---------------------------------------------------------------------------------------------------- */ 474 | a.fc-event, 475 | a.fc-event:hover { 476 | text-decoration: none; 477 | } 478 | /* cursor */ 479 | .fc-event[href], 480 | .fc-event.fc-event-draggable { 481 | cursor: pointer; 482 | } 483 | /* event text content */ 484 | /* ---------------------------------------------------------------------------------------------------- */ 485 | .fc-event .fc-event-main { 486 | position: relative; 487 | z-index: 2; 488 | } 489 | /* dragging */ 490 | /* ---------------------------------------------------------------------------------------------------- */ 491 | .fc-event-dragging:not(.fc-event-selected) { /* MOUSE */ 492 | opacity: 0.75; 493 | } 494 | .fc-event-dragging.fc-event-selected { /* TOUCH */ 495 | box-shadow: 0 2px 7px rgba(0, 0, 0, 0.3); 496 | } 497 | /* resizing */ 498 | /* ---------------------------------------------------------------------------------------------------- */ 499 | /* (subclasses should hone positioning for touch and non-touch) */ 500 | .fc-event .fc-event-resizer { 501 | display: none; 502 | position: absolute; 503 | z-index: 4; 504 | } 505 | .fc-event:hover, /* MOUSE */ 506 | .fc-event-selected { /* TOUCH */ 507 | 508 | } 509 | .fc-event:hover .fc-event-resizer, .fc-event-selected .fc-event-resizer { 510 | display: block; 511 | } 512 | .fc-event-selected .fc-event-resizer { 513 | border-radius: 4px; 514 | border-radius: calc(var(--fc-event-resizer-dot-total-width, 8px) / 2); 515 | border-width: 1px; 516 | border-width: var(--fc-event-resizer-dot-border-width, 1px); 517 | width: 8px; 518 | width: var(--fc-event-resizer-dot-total-width, 8px); 519 | height: 8px; 520 | height: var(--fc-event-resizer-dot-total-width, 8px); 521 | border-style: solid; 522 | border-color: inherit; 523 | background: #fff; 524 | background: var(--fc-page-bg-color, #fff) 525 | 526 | /* expand hit area */ 527 | 528 | } 529 | .fc-event-selected .fc-event-resizer:before { 530 | content: ''; 531 | position: absolute; 532 | top: -20px; 533 | left: -20px; 534 | right: -20px; 535 | bottom: -20px; 536 | } 537 | /* selecting (always TOUCH) */ 538 | /* ---------------------------------------------------------------------------------------------------- */ 539 | .fc-event-selected { 540 | box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2) 541 | 542 | /* expand hit area (subclasses should expand) */ 543 | 544 | } 545 | .fc-event-selected:before { 546 | content: ""; 547 | position: absolute; 548 | z-index: 3; 549 | top: 0; 550 | left: 0; 551 | right: 0; 552 | bottom: 0; 553 | } 554 | .fc-event-selected { 555 | 556 | /* dimmer effect */ 557 | 558 | } 559 | .fc-event-selected:after { 560 | content: ""; 561 | background: rgba(0, 0, 0, 0.25); 562 | background: var(--fc-event-selected-overlay-color, rgba(0, 0, 0, 0.25)); 563 | position: absolute; 564 | z-index: 1; 565 | 566 | /* assume there's a border on all sides. overcome it. */ 567 | /* sometimes there's NOT a border, in which case the dimmer will go over */ 568 | /* an adjacent border, which looks fine. */ 569 | top: -1px; 570 | left: -1px; 571 | right: -1px; 572 | bottom: -1px; 573 | } 574 | /* 575 | A HORIZONTAL event 576 | */ 577 | .fc-h-event { /* allowed to be top-level */ 578 | display: block; 579 | border: 1px solid #3788d8; 580 | border: 1px solid var(--fc-event-border-color, #3788d8); 581 | background-color: #3788d8; 582 | background-color: var(--fc-event-bg-color, #3788d8) 583 | 584 | } 585 | .fc-h-event .fc-event-main { 586 | color: #fff; 587 | color: var(--fc-event-text-color, #fff); 588 | } 589 | .fc-h-event .fc-event-main-frame { 590 | display: flex; /* for make fc-event-title-container expand */ 591 | } 592 | .fc-h-event .fc-event-time { 593 | max-width: 100%; /* clip overflow on this element */ 594 | overflow: hidden; 595 | } 596 | .fc-h-event .fc-event-title-container { /* serves as a container for the sticky cushion */ 597 | flex-grow: 1; 598 | flex-shrink: 1; 599 | min-width: 0; /* important for allowing to shrink all the way */ 600 | } 601 | .fc-h-event .fc-event-title { 602 | display: inline-block; /* need this to be sticky cross-browser */ 603 | vertical-align: top; /* for not messing up line-height */ 604 | left: 0; /* for sticky */ 605 | right: 0; /* for sticky */ 606 | max-width: 100%; /* clip overflow on this element */ 607 | overflow: hidden; 608 | } 609 | .fc-h-event.fc-event-selected:before { 610 | /* expand hit area */ 611 | top: -10px; 612 | bottom: -10px; 613 | } 614 | /* adjust border and border-radius (if there is any) for non-start/end */ 615 | .fc-direction-ltr .fc-daygrid-block-event:not(.fc-event-start), 616 | .fc-direction-rtl .fc-daygrid-block-event:not(.fc-event-end) { 617 | border-top-left-radius: 0; 618 | border-bottom-left-radius: 0; 619 | border-left-width: 0; 620 | } 621 | .fc-direction-ltr .fc-daygrid-block-event:not(.fc-event-end), 622 | .fc-direction-rtl .fc-daygrid-block-event:not(.fc-event-start) { 623 | border-top-right-radius: 0; 624 | border-bottom-right-radius: 0; 625 | border-right-width: 0; 626 | } 627 | /* resizers */ 628 | .fc-h-event:not(.fc-event-selected) .fc-event-resizer { 629 | top: 0; 630 | bottom: 0; 631 | width: 8px; 632 | width: var(--fc-event-resizer-thickness, 8px); 633 | } 634 | .fc-direction-ltr .fc-h-event:not(.fc-event-selected) .fc-event-resizer-start, 635 | .fc-direction-rtl .fc-h-event:not(.fc-event-selected) .fc-event-resizer-end { 636 | cursor: w-resize; 637 | left: -4px; 638 | left: calc(var(--fc-event-resizer-thickness, 8px) / -2); 639 | } 640 | .fc-direction-ltr .fc-h-event:not(.fc-event-selected) .fc-event-resizer-end, 641 | .fc-direction-rtl .fc-h-event:not(.fc-event-selected) .fc-event-resizer-start { 642 | cursor: e-resize; 643 | right: -4px; 644 | right: calc(var(--fc-event-resizer-thickness, 8px) / -2); 645 | } 646 | /* resizers for TOUCH */ 647 | .fc-h-event.fc-event-selected .fc-event-resizer { 648 | top: 50%; 649 | margin-top: -4px; 650 | margin-top: calc(var(--fc-event-resizer-dot-total-width, 8px) / -2); 651 | } 652 | .fc-direction-ltr .fc-h-event.fc-event-selected .fc-event-resizer-start, 653 | .fc-direction-rtl .fc-h-event.fc-event-selected .fc-event-resizer-end { 654 | left: -4px; 655 | left: calc(var(--fc-event-resizer-dot-total-width, 8px) / -2); 656 | } 657 | .fc-direction-ltr .fc-h-event.fc-event-selected .fc-event-resizer-end, 658 | .fc-direction-rtl .fc-h-event.fc-event-selected .fc-event-resizer-start { 659 | right: -4px; 660 | right: calc(var(--fc-event-resizer-dot-total-width, 8px) / -2); 661 | } 662 | 663 | 664 | :root { 665 | --fc-daygrid-event-dot-width: 8px; 666 | } 667 | .fc .fc-popover { 668 | position: fixed; 669 | top: 0; /* for when not positioned yet */ 670 | box-shadow: 0 2px 6px rgba(0,0,0,.15); 671 | } 672 | .fc .fc-popover-header { 673 | display: flex; 674 | flex-direction: row; 675 | justify-content: space-between; 676 | align-items: center; 677 | padding: 3px 4px; 678 | } 679 | .fc .fc-popover-title { 680 | margin: 0 2px; 681 | } 682 | .fc .fc-popover-close { 683 | cursor: pointer; 684 | opacity: 0.65; 685 | font-size: 1.1em; 686 | } 687 | .fc-theme-standard .fc-popover { 688 | border: 1px solid #ddd; 689 | border: 1px solid var(--fc-border-color, #ddd); 690 | background: #fff; 691 | background: var(--fc-page-bg-color, #fff); 692 | } 693 | .fc-theme-standard .fc-popover-header { 694 | background: rgba(208, 208, 208, 0.3); 695 | background: var(--fc-neutral-bg-color, rgba(208, 208, 208, 0.3)); 696 | } 697 | /* help things clear margins of inner content */ 698 | .fc-daygrid-day-frame, 699 | .fc-daygrid-day-events, 700 | .fc-daygrid-event-harness { /* for event top/bottom margins */ 701 | } 702 | .fc-daygrid-day-frame:before, .fc-daygrid-day-events:before, .fc-daygrid-event-harness:before { 703 | content: ""; 704 | clear: both; 705 | display: table; } 706 | .fc-daygrid-day-frame:after, .fc-daygrid-day-events:after, .fc-daygrid-event-harness:after { 707 | content: ""; 708 | clear: both; 709 | display: table; } 710 | .fc .fc-daygrid-body { /* a
that wraps the table */ 711 | position: relative; 712 | z-index: 1; /* container inner z-index's because s can't do it */ 713 | } 714 | .fc .fc-daygrid-day.fc-day-today { 715 | background-color: rgba(255, 220, 40, 0.15); 716 | background-color: var(--fc-today-bg-color, rgba(255, 220, 40, 0.15)); 717 | } 718 | .fc .fc-daygrid-day-frame { 719 | position: relative; 720 | min-height: 100%; /* seems to work better than `height` because sets height after rows/cells naturally do it */ 721 | } 722 | .fc { 723 | 724 | /* cell top */ 725 | 726 | } 727 | .fc .fc-daygrid-day-top { 728 | display: flex; 729 | flex-direction: row-reverse; 730 | } 731 | .fc .fc-day-other .fc-daygrid-day-top { 732 | opacity: 0.3; 733 | } 734 | .fc { 735 | 736 | /* day number (within cell top) */ 737 | 738 | } 739 | .fc .fc-daygrid-day-number { 740 | position: relative; 741 | z-index: 4; 742 | padding: 4px; 743 | } 744 | .fc { 745 | 746 | /* event container */ 747 | 748 | } 749 | .fc .fc-daygrid-day-events { 750 | margin-top: 1px; /* needs to be margin, not padding, so that available cell height can be computed */ 751 | } 752 | .fc { 753 | 754 | /* positioning for balanced vs natural */ 755 | 756 | } 757 | .fc .fc-daygrid-body-balanced .fc-daygrid-day-events { 758 | position: absolute; 759 | left: 0; 760 | right: 0; 761 | } 762 | .fc .fc-daygrid-body-unbalanced .fc-daygrid-day-events { 763 | position: relative; /* for containing abs positioned event harnesses */ 764 | min-height: 2em; /* in addition to being a min-height during natural height, equalizes the heights a little bit */ 765 | } 766 | .fc .fc-daygrid-body-natural { /* can coexist with -unbalanced */ 767 | } 768 | .fc .fc-daygrid-body-natural .fc-daygrid-day-events { 769 | margin-bottom: 1em; 770 | } 771 | .fc { 772 | 773 | /* event harness */ 774 | 775 | } 776 | .fc .fc-daygrid-event-harness { 777 | position: relative; 778 | } 779 | .fc .fc-daygrid-event-harness-abs { 780 | position: absolute; 781 | top: 0; /* fallback coords for when cannot yet be computed */ 782 | left: 0; /* */ 783 | right: 0; /* */ 784 | } 785 | .fc .fc-daygrid-bg-harness { 786 | position: absolute; 787 | top: 0; 788 | bottom: 0; 789 | } 790 | .fc { 791 | 792 | /* bg content */ 793 | 794 | } 795 | .fc .fc-daygrid-day-bg .fc-non-business { z-index: 1 } 796 | .fc .fc-daygrid-day-bg .fc-bg-event { z-index: 2 } 797 | .fc .fc-daygrid-day-bg .fc-highlight { z-index: 3 } 798 | .fc { 799 | 800 | /* events */ 801 | 802 | } 803 | .fc .fc-daygrid-event { 804 | z-index: 6; 805 | margin-top: 1px; 806 | } 807 | .fc .fc-daygrid-event.fc-event-mirror { 808 | z-index: 7; 809 | } 810 | .fc { 811 | 812 | /* cell bottom (within day-events) */ 813 | 814 | } 815 | .fc .fc-daygrid-day-bottom { 816 | font-size: .85em; 817 | margin: 2px 3px 0; 818 | } 819 | .fc .fc-daygrid-more-link { 820 | position: relative; 821 | z-index: 4; 822 | cursor: pointer; 823 | } 824 | .fc { 825 | 826 | /* week number (within frame) */ 827 | 828 | } 829 | .fc .fc-daygrid-week-number { 830 | position: absolute; 831 | z-index: 5; 832 | top: 0; 833 | padding: 2px; 834 | min-width: 1.5em; 835 | text-align: center; 836 | background-color: rgba(208, 208, 208, 0.3); 837 | background-color: var(--fc-neutral-bg-color, rgba(208, 208, 208, 0.3)); 838 | color: #808080; 839 | color: var(--fc-neutral-text-color, #808080); 840 | } 841 | .fc { 842 | 843 | /* popover */ 844 | 845 | } 846 | .fc .fc-more-popover { 847 | z-index: 8; 848 | } 849 | .fc .fc-more-popover .fc-popover-body { 850 | min-width: 220px; 851 | padding: 10px; 852 | } 853 | .fc-direction-ltr .fc-daygrid-event.fc-event-start, 854 | .fc-direction-rtl .fc-daygrid-event.fc-event-end { 855 | margin-left: 2px; 856 | } 857 | .fc-direction-ltr .fc-daygrid-event.fc-event-end, 858 | .fc-direction-rtl .fc-daygrid-event.fc-event-start { 859 | margin-right: 2px; 860 | } 861 | .fc-direction-ltr .fc-daygrid-week-number { 862 | left: 0; 863 | border-radius: 0 0 3px 0; 864 | } 865 | .fc-direction-rtl .fc-daygrid-week-number { 866 | right: 0; 867 | border-radius: 0 0 0 3px; 868 | } 869 | .fc-liquid-hack .fc-daygrid-day-frame { 870 | position: static; /* will cause inner absolute stuff to expand to */ 871 | } 872 | .fc-daygrid-event { /* make root-level, because will be dragged-and-dropped outside of a component root */ 873 | position: relative; /* for z-indexes assigned later */ 874 | white-space: nowrap; 875 | border-radius: 3px; /* dot event needs this to when selected */ 876 | font-size: .85em; 877 | font-size: var(--fc-small-font-size, .85em); 878 | } 879 | /* --- the rectangle ("block") style of event --- */ 880 | .fc-daygrid-block-event .fc-event-time { 881 | font-weight: bold; 882 | } 883 | .fc-daygrid-block-event .fc-event-time, 884 | .fc-daygrid-block-event .fc-event-title { 885 | padding: 1px; 886 | } 887 | /* --- the dot style of event --- */ 888 | .fc-daygrid-dot-event { 889 | display: flex; 890 | align-items: center; 891 | padding: 2px 0 892 | 893 | } 894 | .fc-daygrid-dot-event .fc-event-title { 895 | flex-grow: 1; 896 | flex-shrink: 1; 897 | min-width: 0; /* important for allowing to shrink all the way */ 898 | overflow: hidden; 899 | font-weight: bold; 900 | } 901 | .fc-daygrid-dot-event:hover, 902 | .fc-daygrid-dot-event.fc-event-mirror { 903 | background: rgba(0, 0, 0, 0.1); 904 | } 905 | .fc-daygrid-dot-event.fc-event-selected:before { 906 | /* expand hit area */ 907 | top: -10px; 908 | bottom: -10px; 909 | } 910 | .fc-daygrid-event-dot { /* the actual dot */ 911 | margin: 0 4px; 912 | box-sizing: content-box; 913 | width: 0; 914 | height: 0; 915 | border: 4px solid #3788d8; 916 | border: calc(var(--fc-daygrid-event-dot-width, 8px) / 2) solid var(--fc-event-border-color, #3788d8); 917 | border-radius: 4px; 918 | border-radius: calc(var(--fc-daygrid-event-dot-width, 8px) / 2); 919 | } 920 | /* --- spacing between time and title --- */ 921 | .fc-direction-ltr .fc-daygrid-event .fc-event-time { 922 | margin-right: 3px; 923 | } 924 | .fc-direction-rtl .fc-daygrid-event .fc-event-time { 925 | margin-left: 3px; 926 | } 927 | 928 | 929 | /* 930 | A VERTICAL event 931 | */ 932 | 933 | .fc-v-event { /* allowed to be top-level */ 934 | display: block; 935 | border: 1px solid #3788d8; 936 | border: 1px solid var(--fc-event-border-color, #3788d8); 937 | background-color: #3788d8; 938 | background-color: var(--fc-event-bg-color, #3788d8) 939 | 940 | } 941 | 942 | .fc-v-event .fc-event-main { 943 | color: #fff; 944 | color: var(--fc-event-text-color, #fff); 945 | height: 100%; 946 | } 947 | 948 | .fc-v-event .fc-event-main-frame { 949 | height: 100%; 950 | display: flex; 951 | flex-direction: column; 952 | } 953 | 954 | .fc-v-event .fc-event-time { 955 | flex-grow: 0; 956 | flex-shrink: 0; 957 | max-height: 100%; 958 | overflow: hidden; 959 | } 960 | 961 | .fc-v-event .fc-event-title-container { /* a container for the sticky cushion */ 962 | flex-grow: 1; 963 | flex-shrink: 1; 964 | min-height: 0; /* important for allowing to shrink all the way */ 965 | } 966 | 967 | .fc-v-event .fc-event-title { /* will have fc-sticky on it */ 968 | top: 0; 969 | bottom: 0; 970 | max-height: 100%; /* clip overflow */ 971 | overflow: hidden; 972 | } 973 | 974 | .fc-v-event:not(.fc-event-start) { 975 | border-top-width: 0; 976 | border-top-left-radius: 0; 977 | border-top-right-radius: 0; 978 | } 979 | 980 | .fc-v-event:not(.fc-event-end) { 981 | border-bottom-width: 0; 982 | border-bottom-left-radius: 0; 983 | border-bottom-right-radius: 0; 984 | } 985 | 986 | .fc-v-event.fc-event-selected:before { 987 | /* expand hit area */ 988 | left: -10px; 989 | right: -10px; 990 | } 991 | 992 | .fc-v-event { 993 | 994 | /* resizer (mouse AND touch) */ 995 | 996 | } 997 | 998 | .fc-v-event .fc-event-resizer-start { 999 | cursor: n-resize; 1000 | } 1001 | 1002 | .fc-v-event .fc-event-resizer-end { 1003 | cursor: s-resize; 1004 | } 1005 | 1006 | .fc-v-event { 1007 | 1008 | /* resizer for MOUSE */ 1009 | 1010 | } 1011 | 1012 | .fc-v-event:not(.fc-event-selected) .fc-event-resizer { 1013 | height: 8px; 1014 | height: var(--fc-event-resizer-thickness, 8px); 1015 | left: 0; 1016 | right: 0; 1017 | } 1018 | 1019 | .fc-v-event:not(.fc-event-selected) .fc-event-resizer-start { 1020 | top: -4px; 1021 | top: calc(var(--fc-event-resizer-thickness, 8px) / -2); 1022 | } 1023 | 1024 | .fc-v-event:not(.fc-event-selected) .fc-event-resizer-end { 1025 | bottom: -4px; 1026 | bottom: calc(var(--fc-event-resizer-thickness, 8px) / -2); 1027 | } 1028 | 1029 | .fc-v-event { 1030 | 1031 | /* resizer for TOUCH (when event is "selected") */ 1032 | 1033 | } 1034 | 1035 | .fc-v-event.fc-event-selected .fc-event-resizer { 1036 | left: 50%; 1037 | margin-left: -4px; 1038 | margin-left: calc(var(--fc-event-resizer-dot-total-width, 8px) / -2); 1039 | } 1040 | 1041 | .fc-v-event.fc-event-selected .fc-event-resizer-start { 1042 | top: -4px; 1043 | top: calc(var(--fc-event-resizer-dot-total-width, 8px) / -2); 1044 | } 1045 | 1046 | .fc-v-event.fc-event-selected .fc-event-resizer-end { 1047 | bottom: -4px; 1048 | bottom: calc(var(--fc-event-resizer-dot-total-width, 8px) / -2); 1049 | } 1050 | .fc .fc-timegrid .fc-daygrid-body { /* the all-day daygrid within the timegrid view */ 1051 | z-index: 2; /* put above the timegrid-body so that more-popover is above everything. TODO: better solution */ 1052 | } 1053 | .fc .fc-timegrid-divider { 1054 | padding: 0 0 2px; /* browsers get confused when you set height. use padding instead */ 1055 | } 1056 | .fc .fc-timegrid-body { 1057 | position: relative; 1058 | z-index: 1; /* scope the z-indexes of slots and cols */ 1059 | min-height: 100%; /* fill height always, even when slat table doesn't grow */ 1060 | } 1061 | .fc .fc-timegrid-axis-chunk { /* for advanced ScrollGrid */ 1062 | position: relative /* offset parent for now-indicator-container */ 1063 | 1064 | } 1065 | .fc .fc-timegrid-axis-chunk > table { 1066 | position: relative; 1067 | z-index: 1; /* above the now-indicator-container */ 1068 | } 1069 | .fc .fc-timegrid-slots { 1070 | position: relative; 1071 | z-index: 1; 1072 | } 1073 | .fc .fc-timegrid-slot { /* a */ 1074 | height: 1.5em; 1075 | border-bottom: 0 /* each cell owns its top border */ 1076 | } 1077 | .fc .fc-timegrid-slot:empty:before { 1078 | content: '\00a0'; /* make sure there's at least an empty space to create height for height syncing */ 1079 | } 1080 | .fc .fc-timegrid-slot-minor { 1081 | border-top-style: dotted; 1082 | } 1083 | .fc .fc-timegrid-slot-label-cushion { 1084 | display: inline-block; 1085 | white-space: nowrap; 1086 | } 1087 | .fc .fc-timegrid-slot-label { 1088 | vertical-align: middle; /* vertical align the slots */ 1089 | } 1090 | .fc { 1091 | 1092 | 1093 | /* slots AND axis cells (top-left corner of view including the "all-day" text) */ 1094 | 1095 | } 1096 | .fc .fc-timegrid-axis-cushion, 1097 | .fc .fc-timegrid-slot-label-cushion { 1098 | padding: 0 4px; 1099 | } 1100 | .fc { 1101 | 1102 | 1103 | /* axis cells (top-left corner of view including the "all-day" text) */ 1104 | /* vertical align is more complicated, uses flexbox */ 1105 | 1106 | } 1107 | .fc .fc-timegrid-axis-frame-liquid { 1108 | height: 100%; /* will need liquid-hack in FF */ 1109 | } 1110 | .fc .fc-timegrid-axis-frame { 1111 | overflow: hidden; 1112 | display: flex; 1113 | align-items: center; /* vertical align */ 1114 | justify-content: flex-end; /* horizontal align. matches text-align below */ 1115 | } 1116 | .fc .fc-timegrid-axis-cushion { 1117 | max-width: 60px; /* limits the width of the "all-day" text */ 1118 | flex-shrink: 0; /* allows text to expand how it normally would, regardless of constrained width */ 1119 | } 1120 | .fc-direction-ltr .fc-timegrid-slot-label-frame { 1121 | text-align: right; 1122 | } 1123 | .fc-direction-rtl .fc-timegrid-slot-label-frame { 1124 | text-align: left; 1125 | } 1126 | .fc-liquid-hack .fc-timegrid-axis-frame-liquid { 1127 | height: auto; 1128 | position: absolute; 1129 | top: 0; 1130 | right: 0; 1131 | bottom: 0; 1132 | left: 0; 1133 | } 1134 | .fc .fc-timegrid-col.fc-day-today { 1135 | background-color: rgba(255, 220, 40, 0.15); 1136 | background-color: var(--fc-today-bg-color, rgba(255, 220, 40, 0.15)); 1137 | } 1138 | .fc .fc-timegrid-col-frame { 1139 | min-height: 100%; /* liquid-hack is below */ 1140 | position: relative; 1141 | } 1142 | .fc-liquid-hack .fc-timegrid-col-frame { 1143 | height: auto; 1144 | position: absolute; 1145 | top: 0; 1146 | right: 0; 1147 | bottom: 0; 1148 | left: 0; 1149 | } 1150 | .fc-media-screen .fc-timegrid-cols { 1151 | position: absolute; /* no z-index. children will decide and go above slots */ 1152 | top: 0; 1153 | left: 0; 1154 | right: 0; 1155 | bottom: 0 1156 | } 1157 | .fc-media-screen .fc-timegrid-cols > table { 1158 | height: 100%; 1159 | } 1160 | .fc-media-screen .fc-timegrid-col-bg, 1161 | .fc-media-screen .fc-timegrid-col-events, 1162 | .fc-media-screen .fc-timegrid-now-indicator-container { 1163 | position: absolute; 1164 | top: 0; 1165 | left: 0; 1166 | right: 0; 1167 | } 1168 | .fc-media-screen .fc-timegrid-event-harness { 1169 | position: absolute; /* top/left/right/bottom will all be set by JS */ 1170 | } 1171 | .fc { 1172 | 1173 | /* bg */ 1174 | 1175 | } 1176 | .fc .fc-timegrid-col-bg { 1177 | z-index: 2; /* TODO: kill */ 1178 | } 1179 | .fc .fc-timegrid-col-bg .fc-non-business { z-index: 1 } 1180 | .fc .fc-timegrid-col-bg .fc-bg-event { z-index: 2 } 1181 | .fc .fc-timegrid-col-bg .fc-highlight { z-index: 3 } 1182 | .fc .fc-timegrid-bg-harness { 1183 | position: absolute; /* top/bottom will be set by JS */ 1184 | left: 0; 1185 | right: 0; 1186 | } 1187 | .fc { 1188 | 1189 | /* fg events */ 1190 | /* (the mirror segs are put into a separate container with same classname, */ 1191 | /* and they must be after the normal seg container to appear at a higher z-index) */ 1192 | 1193 | } 1194 | .fc .fc-timegrid-col-events { 1195 | z-index: 3; 1196 | /* child event segs have z-indexes that are scoped within this div */ 1197 | } 1198 | .fc { 1199 | 1200 | /* now indicator */ 1201 | 1202 | } 1203 | .fc .fc-timegrid-now-indicator-container { 1204 | bottom: 0; 1205 | overflow: hidden; /* don't let overflow of lines/arrows cause unnecessary scrolling */ 1206 | /* z-index is set on the individual elements */ 1207 | } 1208 | .fc-direction-ltr .fc-timegrid-col-events { 1209 | margin: 0 2.5% 0 2px; 1210 | } 1211 | .fc-direction-rtl .fc-timegrid-col-events { 1212 | margin: 0 2px 0 2.5%; 1213 | } 1214 | .fc-timegrid-event-harness-inset .fc-timegrid-event, 1215 | .fc-timegrid-event.fc-event-mirror { 1216 | box-shadow: 0px 0px 0px 1px #fff; 1217 | box-shadow: 0px 0px 0px 1px var(--fc-page-bg-color, #fff); 1218 | } 1219 | .fc-timegrid-event { /* events need to be root */ 1220 | 1221 | font-size: .85em; 1222 | 1223 | font-size: var(--fc-small-font-size, .85em); 1224 | border-radius: 3px 1225 | 1226 | } 1227 | .fc-timegrid-event .fc-event-main { 1228 | padding: 1px 1px 0; 1229 | } 1230 | .fc-timegrid-event .fc-event-time { 1231 | white-space: nowrap; 1232 | font-size: .85em; 1233 | font-size: var(--fc-small-font-size, .85em); 1234 | margin-bottom: 1px; 1235 | } 1236 | .fc-timegrid-event-condensed .fc-event-main-frame { 1237 | flex-direction: row; 1238 | overflow: hidden; 1239 | } 1240 | .fc-timegrid-event-condensed .fc-event-time:after { 1241 | content: '\00a0-\00a0'; /* dash surrounded by non-breaking spaces */ 1242 | } 1243 | .fc-timegrid-event-condensed .fc-event-title { 1244 | font-size: .85em; 1245 | font-size: var(--fc-small-font-size, .85em) 1246 | } 1247 | .fc-media-screen .fc-timegrid-event { 1248 | position: absolute; /* absolute WITHIN the harness */ 1249 | top: 0; 1250 | bottom: 1px; /* stay away from bottom slot line */ 1251 | left: 0; 1252 | right: 0; 1253 | } 1254 | .fc { 1255 | 1256 | /* line */ 1257 | 1258 | } 1259 | .fc .fc-timegrid-now-indicator-line { 1260 | position: absolute; 1261 | z-index: 4; 1262 | left: 0; 1263 | right: 0; 1264 | border-style: solid; 1265 | border-color: red; 1266 | border-color: var(--fc-now-indicator-color, red); 1267 | border-width: 1px 0 0; 1268 | } 1269 | .fc { 1270 | 1271 | /* arrow */ 1272 | 1273 | } 1274 | .fc .fc-timegrid-now-indicator-arrow { 1275 | position: absolute; 1276 | z-index: 4; 1277 | margin-top: -5px; /* vertically center on top coordinate */ 1278 | border-style: solid; 1279 | border-color: red; 1280 | border-color: var(--fc-now-indicator-color, red); 1281 | } 1282 | .fc-direction-ltr .fc-timegrid-now-indicator-arrow { 1283 | left: 0; 1284 | 1285 | /* triangle pointing right. TODO: mixin */ 1286 | border-width: 5px 0 5px 6px; 1287 | border-top-color: transparent; 1288 | border-bottom-color: transparent; 1289 | } 1290 | .fc-direction-rtl .fc-timegrid-now-indicator-arrow { 1291 | right: 0; 1292 | 1293 | /* triangle pointing left. TODO: mixin */ 1294 | border-width: 5px 6px 5px 0; 1295 | border-top-color: transparent; 1296 | border-bottom-color: transparent; 1297 | } 1298 | 1299 | 1300 | :root { 1301 | --fc-list-event-dot-width: 10px; 1302 | --fc-list-event-hover-bg-color: #f5f5f5; 1303 | } 1304 | .fc-theme-standard .fc-list { 1305 | border: 1px solid #ddd; 1306 | border: 1px solid var(--fc-border-color, #ddd); 1307 | } 1308 | .fc { 1309 | 1310 | /* message when no events */ 1311 | 1312 | } 1313 | .fc .fc-list-empty { 1314 | background-color: rgba(208, 208, 208, 0.3); 1315 | background-color: var(--fc-neutral-bg-color, rgba(208, 208, 208, 0.3)); 1316 | height: 100%; 1317 | display: flex; 1318 | justify-content: center; 1319 | align-items: center; /* vertically aligns fc-list-empty-inner */ 1320 | } 1321 | .fc .fc-list-empty-cushion { 1322 | margin: 5em 0; 1323 | } 1324 | .fc { 1325 | 1326 | /* table within the scroller */ 1327 | /* ---------------------------------------------------------------------------------------------------- */ 1328 | 1329 | } 1330 | .fc .fc-list-table { 1331 | width: 100%; 1332 | border-style: hidden; /* kill outer border on theme */ 1333 | } 1334 | .fc .fc-list-table tr > * { 1335 | border-left: 0; 1336 | border-right: 0; 1337 | } 1338 | .fc .fc-list-sticky .fc-list-day > * { /* the cells */ 1339 | position: -webkit-sticky; 1340 | position: sticky; 1341 | top: 0; 1342 | background: #fff; 1343 | background: var(--fc-page-bg-color, #fff); /* for when headers are styled to be transparent and sticky */ 1344 | } 1345 | .fc .fc-list-table th { 1346 | padding: 0; /* uses an inner-wrapper instead... */ 1347 | } 1348 | .fc .fc-list-table td, 1349 | .fc .fc-list-day-cushion { 1350 | padding: 8px 14px; 1351 | } 1352 | .fc { 1353 | 1354 | 1355 | /* date heading rows */ 1356 | /* ---------------------------------------------------------------------------------------------------- */ 1357 | 1358 | } 1359 | .fc .fc-list-day-cushion:after { 1360 | content: ""; 1361 | clear: both; 1362 | display: table; /* clear floating */ 1363 | } 1364 | .fc-theme-standard .fc-list-day-cushion { 1365 | background-color: rgba(208, 208, 208, 0.3); 1366 | background-color: var(--fc-neutral-bg-color, rgba(208, 208, 208, 0.3)); 1367 | } 1368 | .fc-direction-ltr .fc-list-day-text, 1369 | .fc-direction-rtl .fc-list-day-side-text { 1370 | float: left; 1371 | } 1372 | .fc-direction-ltr .fc-list-day-side-text, 1373 | .fc-direction-rtl .fc-list-day-text { 1374 | float: right; 1375 | } 1376 | /* make the dot closer to the event title */ 1377 | .fc-direction-ltr .fc-list-table .fc-list-event-graphic { padding-right: 0 } 1378 | .fc-direction-rtl .fc-list-table .fc-list-event-graphic { padding-left: 0 } 1379 | .fc .fc-list-event.fc-event-forced-url { 1380 | cursor: pointer; /* whole row will seem clickable */ 1381 | } 1382 | .fc .fc-list-event:hover td { 1383 | background-color: #f5f5f5; 1384 | background-color: var(--fc-list-event-hover-bg-color, #f5f5f5); 1385 | } 1386 | .fc { 1387 | 1388 | /* shrink certain cols */ 1389 | 1390 | } 1391 | .fc .fc-list-event-graphic, 1392 | .fc .fc-list-event-time { 1393 | white-space: nowrap; 1394 | width: 1px; 1395 | } 1396 | .fc .fc-list-event-dot { 1397 | display: inline-block; 1398 | box-sizing: content-box; 1399 | width: 0; 1400 | height: 0; 1401 | border: 5px solid #3788d8; 1402 | border: calc(var(--fc-list-event-dot-width, 10px) / 2) solid var(--fc-event-border-color, #3788d8); 1403 | border-radius: 5px; 1404 | border-radius: calc(var(--fc-list-event-dot-width, 10px) / 2); 1405 | } 1406 | .fc { 1407 | 1408 | /* reset styling */ 1409 | 1410 | } 1411 | .fc .fc-list-event-title a { 1412 | color: inherit; 1413 | text-decoration: none; 1414 | } 1415 | .fc { 1416 | 1417 | /* underline link when hovering over any part of row */ 1418 | 1419 | } 1420 | .fc .fc-list-event.fc-event-forced-url:hover a { 1421 | text-decoration: underline; 1422 | } 1423 | 1424 | 1425 | 1426 | .fc-theme-bootstrap a:not([href]) { 1427 | color: inherit; /* natural color for navlinks */ 1428 | } 1429 | 1430 | --------------------------------------------------------------------------------