db7648eb4d
- 新增微信小程序版个人收藏夹全套功能,包含聚合首页、收藏列表、编辑、详情页面与配套后端API - 新增web版时光助手门户与个人收藏夹工具,支持与小程序共享后端数据 - 优化日历提醒工具:替换本地bootstrap资源为公共CDN版本,新增批量生成循环提醒的前端界面与后端逻辑,新增数据库batch_id字段,更新鉴权配置文件 - 补充项目配置、站点地图、调试诊断脚本等配套文件 - 完善各模块的页面配置与样式文件
1339 lines
56 KiB
JavaScript
1339 lines
56 KiB
JavaScript
const express = require('express')
|
||
const Database = require('better-sqlite3')
|
||
const path = require('path')
|
||
const axios = require('axios')
|
||
const fs = require('fs')
|
||
|
||
const readCookieCfg = () => {
|
||
const cfgPath = path.join(process.cwd(), 'public', 'tools', 'calendar_reminder', 'auth_config.json')
|
||
let name = 'calendar_reminder_gate'
|
||
let maxAge = 90 * 24 * 3600 * 1000
|
||
let enable = true
|
||
try {
|
||
if (fs.existsSync(cfgPath)) {
|
||
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'))
|
||
if (cfg.cookieName) name = String(cfg.cookieName)
|
||
const days = Number(cfg.max_age_days || 0)
|
||
if (Number.isFinite(days) && days > 0) maxAge = Math.min(days, 365) * 24 * 3600 * 1000
|
||
if (cfg.enable_auth === false) enable = false
|
||
}
|
||
} catch {}
|
||
return { name, maxAge, enable }
|
||
}
|
||
const DB_PATH = path.join(process.cwd(), "data/calendar_reminder_backups/_diag", "calendar_reminder.db")
|
||
let db = null
|
||
|
||
// Config
|
||
const EXT_API_URL = `http://127.0.0.1:${process.env.PORT || 8976}/api/weekly/ext/event`
|
||
const EXT_API_KEY = process.env.CALENDAR_REMINDER_API_KEY || process.env.SYSTEM_AUTH_PASS_93220 || ''
|
||
const EXT_API_SOURCE = 'crm' // Kept from C# default
|
||
const readPublicApiKey = () => {
|
||
try {
|
||
const cfgPath = path.join(process.cwd(), 'config', 'calendar_reminder.json')
|
||
if (fs.existsSync(cfgPath)) {
|
||
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'))
|
||
const k = String(cfg.PUBLIC_API_KEY || cfg.public_api_key || '').trim()
|
||
if (k) return k
|
||
}
|
||
} catch {}
|
||
return '099239923'
|
||
}
|
||
const PUBLIC_API_KEY = readPublicApiKey()
|
||
|
||
const parseCookie = (str) => {
|
||
const list = {}
|
||
if (!str) return list
|
||
str.split(';').forEach((cookie) => {
|
||
const parts = cookie.split('=')
|
||
list[parts.shift().trim()] = decodeURI(parts.join('='))
|
||
})
|
||
return list
|
||
}
|
||
|
||
const checkAuth = (req, res, next) => {
|
||
const cookies = parseCookie(req.headers.cookie || '')
|
||
const { name, enable } = readCookieCfg()
|
||
if (enable === false) return next()
|
||
if (cookies[name] === '1') return next()
|
||
try {
|
||
const ref = String(req.headers['referer'] || req.headers['referrer'] || '')
|
||
if (ref.includes('/tools/calendar_voice/')) return next()
|
||
} catch {}
|
||
|
||
const hasOrigin = !!req.headers['origin']
|
||
const hasSecFetch = !!req.headers['sec-fetch-mode']
|
||
if (!hasOrigin && !hasSecFetch) return next()
|
||
|
||
return res.status(401).json({ error: 'unauthorized', ok: false })
|
||
}
|
||
|
||
const ensureDb = () => {
|
||
if (db) return
|
||
db = new Database(DB_PATH)
|
||
db.pragma('journal_mode = WAL')
|
||
|
||
// Create table if not exists (migrated from C#)
|
||
db.exec(`CREATE TABLE IF NOT EXISTS events (
|
||
id TEXT PRIMARY KEY,
|
||
title TEXT NOT NULL,
|
||
content TEXT,
|
||
start TEXT NOT NULL,
|
||
end TEXT,
|
||
allDay INTEGER NOT NULL DEFAULT 0,
|
||
remindAt TEXT,
|
||
recurrence TEXT,
|
||
createdAt TEXT,
|
||
updatedAt TEXT,
|
||
completed INTEGER NOT NULL DEFAULT 0,
|
||
deleted INTEGER NOT NULL DEFAULT 0
|
||
);`)
|
||
|
||
// Ensure columns
|
||
const cols = new Set(db.prepare("PRAGMA table_info(events)").all().map(c => c.name))
|
||
if (!cols.has('content')) db.exec("ALTER TABLE events ADD COLUMN content TEXT;")
|
||
if (!cols.has('completed')) db.exec("ALTER TABLE events ADD COLUMN completed INTEGER NOT NULL DEFAULT 0;")
|
||
if (!cols.has('batch_id')) db.exec("ALTER TABLE events ADD COLUMN batch_id TEXT DEFAULT NULL;")
|
||
// 注意:completedAt / deletedAt 历史上不强制补列,供 SQL 批次白名单通过(用户可能在 future 版本使用;避免 unknown_column 报错)
|
||
const optionalCols = [
|
||
['completedAt', 'TEXT'],
|
||
['deletedAt', 'TEXT']
|
||
]
|
||
for (const [name, type] of optionalCols) {
|
||
if (!cols.has(name)) {
|
||
try { db.exec(`ALTER TABLE events ADD COLUMN ${name} ${type};`) } catch {}
|
||
cols.add(name)
|
||
}
|
||
}
|
||
|
||
// Create indices to speed up queries
|
||
db.exec(`CREATE INDEX IF NOT EXISTS idx_events_start ON events(start);`)
|
||
db.exec(`CREATE INDEX IF NOT EXISTS idx_events_updatedAt ON events(updatedAt);`)
|
||
db.exec(`CREATE INDEX IF NOT EXISTS idx_events_batch_id ON events(batch_id);`)
|
||
|
||
db.exec(`CREATE TABLE IF NOT EXISTS calendar_batches (
|
||
id TEXT PRIMARY KEY,
|
||
title TEXT,
|
||
params_json TEXT,
|
||
sql_preview TEXT,
|
||
generated_count INTEGER NOT NULL DEFAULT 0,
|
||
inserted_count INTEGER NOT NULL DEFAULT 0,
|
||
dedup_count INTEGER NOT NULL DEFAULT 0,
|
||
rollback_count INTEGER NOT NULL DEFAULT 0,
|
||
status TEXT NOT NULL DEFAULT 'draft',
|
||
skip_external_sync INTEGER NOT NULL DEFAULT 0,
|
||
pre_existing_count INTEGER NOT NULL DEFAULT 0,
|
||
created_at TEXT,
|
||
updated_at TEXT,
|
||
rollbacked_at TEXT
|
||
);`)
|
||
db.exec(`CREATE INDEX IF NOT EXISTS idx_calendar_batches_created ON calendar_batches(created_at);`)
|
||
}
|
||
|
||
// Helpers
|
||
const getUUID = () => {
|
||
try { return require('crypto').randomUUID() } catch { return Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2) }
|
||
}
|
||
|
||
const syncExternalUpsert = async (ev) => {
|
||
try {
|
||
const title = (ev.title || '').trim()
|
||
const contentRaw = (ev.content || '').trim()
|
||
const start = ev.start
|
||
const end = ev.end
|
||
const remindAt = ev.remindAt
|
||
|
||
// Logic from C# BuildExternalContent
|
||
let contentBuilt = contentRaw
|
||
if (remindAt) contentBuilt = `[提醒: ${remindAt}]\n${contentBuilt}`
|
||
if (end) contentBuilt = `[结束: ${end}]\n${contentBuilt}`
|
||
if (ev.recurrence) contentBuilt = `[循环: ${ev.recurrence}]\n${contentBuilt}`
|
||
contentBuilt = contentBuilt.trim()
|
||
|
||
// Date logic
|
||
const sDate = start ? start.slice(0, 10) : new Date().toISOString().slice(0, 10)
|
||
|
||
const payload = {
|
||
key: EXT_API_KEY,
|
||
source: EXT_API_SOURCE,
|
||
external_id: ev.id,
|
||
date: sDate,
|
||
title: title,
|
||
content: contentBuilt,
|
||
status_code: ev.completed ? 'done' : (ev.deleted ? 'deleted' : 'new'),
|
||
status_label: ev.completed ? '✅已完成' : (ev.deleted ? '🗑已删除' : '📅日程'),
|
||
deadline: remindAt || end || start
|
||
}
|
||
|
||
if (ev.deleted) payload.action = 'delete'
|
||
|
||
await axios.post(EXT_API_URL, payload, { timeout: 8000, headers: { 'Content-Type': 'application/json' } }).catch(err => console.error('Sync failed', err.message))
|
||
} catch (e) {
|
||
console.error('Sync error', e)
|
||
}
|
||
}
|
||
|
||
const syncExternalDelete = async (id) => {
|
||
try {
|
||
const payload = {
|
||
key: EXT_API_KEY,
|
||
source: EXT_API_SOURCE,
|
||
external_id: id,
|
||
action: 'delete',
|
||
deleted: true
|
||
}
|
||
await axios.post(EXT_API_URL, payload, { timeout: 8000, headers: { 'Content-Type': 'application/json' } }).catch(err => console.error('Sync delete failed', err.message))
|
||
} catch (e) {
|
||
console.error('Sync delete error', e)
|
||
}
|
||
}
|
||
|
||
const handleGet = (req, res) => {
|
||
const { since, year, month } = req.query
|
||
let rows = []
|
||
|
||
if (since) {
|
||
rows = db.prepare("SELECT * FROM events WHERE updatedAt >= ?").all(since)
|
||
} else if (year && month) {
|
||
const y = parseInt(year)
|
||
const m = parseInt(month)
|
||
// Construct simplified date strings for comparison (sqlite stores text ISO)
|
||
// C# used DateTime comparison. Here we rely on ISO string comparison which works for YYYY-MM-DD
|
||
// But events.start might be YYYY-MM-DDTHH:mm:ss.
|
||
// Construct range:
|
||
const startStr = new Date(Date.UTC(y, m - 1, 1, 0, 0, 0)).toISOString()
|
||
// End of month
|
||
const endStr = new Date(Date.UTC(y, m, 0, 23, 59, 59, 999)).toISOString()
|
||
|
||
rows = db.prepare("SELECT * FROM events WHERE start >= ? AND start <= ?").all(startStr, endStr)
|
||
} else {
|
||
rows = db.prepare("SELECT * FROM events").all()
|
||
}
|
||
|
||
try {
|
||
const qInfo = since ? `since=${since}` : (year ? `year=${year},month=${month}` : 'ALL');
|
||
console.log(`[CalendarReminder] GetEvents ${qInfo} -> ${rows.length} rows`);
|
||
} catch(e){}
|
||
|
||
const data = rows.map(r => ({
|
||
id: r.id,
|
||
title: r.title,
|
||
content: r.content || '',
|
||
start: r.start,
|
||
end: r.end,
|
||
allDay: !!r.allDay,
|
||
remindAt: r.remindAt,
|
||
recurrence: r.recurrence,
|
||
createdAt: r.createdAt,
|
||
updatedAt: r.updatedAt,
|
||
completed: !!r.completed,
|
||
deleted: !!r.deleted
|
||
}))
|
||
|
||
res.json({ ok: true, data })
|
||
}
|
||
|
||
const handlePost = async (req, res) => {
|
||
const ev = req.body || {}
|
||
|
||
// Deduplication: if title and start match an existing active event, update it instead of creating new
|
||
if (ev.title && ev.start) {
|
||
try {
|
||
const existing = db.prepare("SELECT id FROM events WHERE title = ? AND start = ? AND deleted = 0 LIMIT 1").get(ev.title, ev.start)
|
||
if (existing) {
|
||
ev.id = existing.id
|
||
}
|
||
} catch (e) { console.error('Deduplication check failed', e) }
|
||
}
|
||
|
||
if (!ev.id) ev.id = getUUID()
|
||
|
||
// Defaults
|
||
ev.content = ev.content || ''
|
||
ev.createdAt = ev.createdAt || new Date().toISOString()
|
||
ev.updatedAt = ev.updatedAt || new Date().toISOString()
|
||
ev.completed = ev.completed ? 1 : 0
|
||
ev.deleted = ev.deleted ? 1 : 0
|
||
ev.allDay = ev.allDay ? 1 : 0
|
||
|
||
const stmt = db.prepare(`INSERT OR REPLACE INTO events
|
||
(id,title,content,start,end,allDay,remindAt,recurrence,createdAt,updatedAt,completed,deleted,batch_id)
|
||
VALUES (@id,@title,@content,@start,@end,@allDay,@remindAt,@recurrence,@createdAt,@updatedAt,@completed,@deleted,@batch_id)`)
|
||
|
||
stmt.run({
|
||
id: ev.id,
|
||
title: ev.title,
|
||
content: ev.content,
|
||
start: ev.start,
|
||
end: ev.end || null,
|
||
allDay: ev.allDay,
|
||
remindAt: ev.remindAt || null,
|
||
recurrence: ev.recurrence || null,
|
||
createdAt: ev.createdAt,
|
||
updatedAt: ev.updatedAt,
|
||
completed: ev.completed,
|
||
deleted: ev.deleted,
|
||
batch_id: ev.batch_id || null
|
||
})
|
||
|
||
await syncExternalUpsert(ev)
|
||
res.json({ ok: true })
|
||
}
|
||
|
||
const handlePut = async (req, res) => {
|
||
// C# logic: UPDATE specific fields
|
||
// Node logic: Similar
|
||
let ev = req.body || {}
|
||
const id = req.query.id || ev.id
|
||
if (!id) return res.status(400).json({ ok: false, error: 'missing id' })
|
||
|
||
ev.id = id
|
||
ev.updatedAt = new Date().toISOString()
|
||
|
||
// Need to read existing event to merge?
|
||
// C# HandlePut updates ALL fields provided in the query/body.
|
||
// It runs an UPDATE statement setting all fields.
|
||
|
||
// Since we might not have all fields in body, we should probably merge or expect full object?
|
||
// C# HandlePut SQL: UPDATE events SET title=@title, ... WHERE id=@id.
|
||
// It expects the body to contain the fields.
|
||
// Frontend sends full object usually.
|
||
|
||
// Let's assume body contains fields to update.
|
||
// However, simple SQL UPDATE requires all params.
|
||
// We can use COALESCE or read-modify-write.
|
||
|
||
const existing = db.prepare("SELECT * FROM events WHERE id = ?").get(id)
|
||
if (!existing) return res.status(404).json({ ok: false, error: 'not found' })
|
||
|
||
const merged = { ...existing, ...ev }
|
||
merged.completed = merged.completed ? 1 : 0
|
||
merged.deleted = merged.deleted ? 1 : 0
|
||
merged.allDay = merged.allDay ? 1 : 0
|
||
|
||
const stmt = db.prepare(`UPDATE events SET
|
||
title=@title,content=@content,start=@start,end=@end,allDay=@allDay,remindAt=@remindAt,recurrence=@recurrence,
|
||
updatedAt=@updatedAt,completed=@completed,deleted=@deleted,batch_id=@batch_id WHERE id=@id`)
|
||
|
||
stmt.run({
|
||
id: merged.id,
|
||
title: merged.title,
|
||
content: merged.content,
|
||
start: merged.start,
|
||
end: merged.end,
|
||
allDay: merged.allDay,
|
||
remindAt: merged.remindAt,
|
||
recurrence: merged.recurrence,
|
||
updatedAt: merged.updatedAt,
|
||
completed: merged.completed,
|
||
deleted: merged.deleted,
|
||
batch_id: merged.batch_id !== undefined ? merged.batch_id : existing.batch_id
|
||
})
|
||
|
||
await syncExternalUpsert(merged)
|
||
res.json({ ok: true })
|
||
}
|
||
|
||
const handleDelete = async (req, res) => {
|
||
const id = req.query.id
|
||
if (!id) return res.status(400).json({ ok: false, error: 'missing id' })
|
||
|
||
const updatedAt = new Date().toISOString()
|
||
db.prepare("UPDATE events SET deleted=1, updatedAt=? WHERE id=?").run(updatedAt, id)
|
||
|
||
await syncExternalDelete(id)
|
||
res.json({ ok: true })
|
||
}
|
||
|
||
const BATCH_MAX_ROWS = 500
|
||
const BATCH_MAX_MS = 60000
|
||
|
||
const pad = (n) => (n < 10 ? '0' + n : String(n))
|
||
const parseDate = (s) => {
|
||
if (!s) return null
|
||
const m = /^(\d{4})-(\d{1,2})-(\d{1,2})/.exec(String(s))
|
||
if (!m) return null
|
||
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
|
||
if (Number.isNaN(d.getTime())) return null
|
||
return d
|
||
}
|
||
const fmtDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||
const fmtDateTime = (d, hm) => {
|
||
const date = fmtDate(d)
|
||
if (!hm) return date
|
||
return `${date}T${hm}:00`
|
||
}
|
||
const weekdayCN = (w) => ['日', '一', '二', '三', '四', '五', '六'][w] || ''
|
||
const parseDomExpr = (expr) => {
|
||
if (!expr) return []
|
||
const out = new Set()
|
||
String(expr).split(/[,,\s]+/).filter(Boolean).forEach(part => {
|
||
const range = /^(\d{1,2})\s*-\s*(\d{1,2})$/.exec(part.trim())
|
||
if (range) {
|
||
const a = Number(range[1]), b = Number(range[2])
|
||
const lo = Math.min(a, b), hi = Math.max(a, b)
|
||
for (let i = lo; i <= hi; i++) out.add(i)
|
||
} else {
|
||
const n = Number(part.trim())
|
||
if (Number.isFinite(n) && n >= 1 && n <= 31) out.add(n)
|
||
}
|
||
})
|
||
return [...out].sort((a, b) => a - b)
|
||
}
|
||
const expandDays = (params) => {
|
||
const { rangeStart, rangeEnd, mode } = params || {}
|
||
const rs = parseDate(rangeStart)
|
||
const re = parseDate(rangeEnd)
|
||
if (!rs || !re) return []
|
||
if (re.getTime() < rs.getTime()) return []
|
||
|
||
const days = []
|
||
if (mode === 'daily') {
|
||
const everyN = Math.max(1, Number(params.dailyEvery) || 1)
|
||
for (let cur = new Date(rs); cur.getTime() <= re.getTime(); cur.setDate(cur.getDate() + everyN)) {
|
||
days.push(new Date(cur))
|
||
}
|
||
} else if (mode === 'weekly') {
|
||
const everyN = Math.max(1, Number(params.weeklyEvery) || 1)
|
||
const weekdays = Array.isArray(params.weekdays) ? params.weekdays.map(Number).filter(n => n >= 0 && n <= 6) : []
|
||
if (!weekdays.length) return []
|
||
const base = new Date(rs)
|
||
const cursor = new Date(base)
|
||
while (cursor.getTime() <= re.getTime()) {
|
||
const weekStart = new Date(cursor)
|
||
weekStart.setDate(weekStart.getDate() - weekStart.getDay())
|
||
for (const wd of weekdays.slice().sort((a, b) => a - b)) {
|
||
const d = new Date(weekStart)
|
||
d.setDate(d.getDate() + wd)
|
||
if (d.getTime() >= rs.getTime() && d.getTime() <= re.getTime()) days.push(d)
|
||
}
|
||
cursor.setDate(cursor.getDate() + 7 * everyN)
|
||
}
|
||
} else if (mode === 'monthlyByDay') {
|
||
const everyN = Math.max(1, Number(params.monthlyEvery) || 1)
|
||
const doms = parseDomExpr(params.monthlyDays)
|
||
if (!doms.length) return []
|
||
const ym = new Date(rs.getFullYear(), rs.getMonth(), 1)
|
||
const yme = new Date(re.getFullYear(), re.getMonth(), 1)
|
||
let step = 0
|
||
while (true) {
|
||
const cur = new Date(ym.getFullYear(), ym.getMonth() + step * everyN, 1)
|
||
if (cur.getTime() > yme.getTime()) break
|
||
const y = cur.getFullYear(), m = cur.getMonth()
|
||
const lastDay = new Date(y, m + 1, 0).getDate()
|
||
for (const day of doms) {
|
||
if (day < 1 || day > lastDay) continue
|
||
const d = new Date(y, m, day)
|
||
if (d.getTime() >= rs.getTime() && d.getTime() <= re.getTime()) days.push(d)
|
||
}
|
||
step++
|
||
}
|
||
} else if (mode === 'monthlyByNthWeekday') {
|
||
const everyN = Math.max(1, Number(params.monthlyEvery2) || 1)
|
||
const nth = Math.min(5, Math.max(1, Number(params.nthWeek) || 1))
|
||
const wd = Math.min(6, Math.max(0, Number(params.nthWeekday) || 0))
|
||
const ym = new Date(rs.getFullYear(), rs.getMonth(), 1)
|
||
const yme = new Date(re.getFullYear(), re.getMonth(), 1)
|
||
let step = 0
|
||
while (true) {
|
||
const cur = new Date(ym.getFullYear(), ym.getMonth() + step * everyN, 1)
|
||
if (cur.getTime() > yme.getTime()) break
|
||
const y = cur.getFullYear(), m = cur.getMonth()
|
||
let hit = null, count = 0
|
||
const lastDay = new Date(y, m + 1, 0).getDate()
|
||
for (let day = 1; day <= lastDay; day++) {
|
||
const d = new Date(y, m, day)
|
||
if (d.getDay() === wd) {
|
||
count++
|
||
if (count === nth) { hit = d; break }
|
||
}
|
||
}
|
||
if (hit && hit.getTime() >= rs.getTime() && hit.getTime() <= re.getTime()) days.push(hit)
|
||
step++
|
||
}
|
||
}
|
||
return days.sort((a, b) => a.getTime() - b.getTime())
|
||
}
|
||
const renderTpl = (tpl, d) => {
|
||
if (!tpl) return ''
|
||
return String(tpl)
|
||
.replace(/\{yyyy\}/g, d.getFullYear())
|
||
.replace(/\{MM\}/g, pad(d.getMonth() + 1))
|
||
.replace(/\{dd\}/g, pad(d.getDate()))
|
||
.replace(/\{date\}/g, fmtDate(d))
|
||
.replace(/\{W\}/g, weekdayCN(d.getDay()))
|
||
}
|
||
const escapeSql = (v) => {
|
||
if (v === null || v === undefined) return 'NULL'
|
||
return "'" + String(v).replace(/'/g, "''") + "'"
|
||
}
|
||
const buildBatchRecords = (params, batchId, now) => {
|
||
const days = expandDays(params)
|
||
if (days.length > BATCH_MAX_ROWS) return { error: `rows_exceeded: ${days.length} > ${BATCH_MAX_ROWS}` }
|
||
const startTime = params.startTime || ''
|
||
const endTime = params.endTime || ''
|
||
const remindTime = params.remindTime || ''
|
||
const allDay = !!params.allDay
|
||
const completed = params.completed ? 1 : 0
|
||
const records = []
|
||
for (const d of days) {
|
||
const title = renderTpl(params.title, d) || '未命名提醒'
|
||
const content = renderTpl(params.content || '', d)
|
||
const start = fmtDateTime(d, allDay ? '' : startTime)
|
||
const end = (allDay || !endTime) ? null : fmtDateTime(d, endTime)
|
||
const remindAt = (allDay || !remindTime) ? null : fmtDateTime(d, remindTime)
|
||
records.push({
|
||
id: getUUID(),
|
||
title,
|
||
content,
|
||
start,
|
||
end,
|
||
allDay: allDay ? 1 : 0,
|
||
remindAt,
|
||
recurrence: null,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
completed,
|
||
deleted: 0,
|
||
batch_id: batchId
|
||
})
|
||
}
|
||
return { days, records }
|
||
}
|
||
const sqlForRecord = (r) => `INSERT OR REPLACE INTO events (id,title,content,start,end,allDay,remindAt,recurrence,createdAt,updatedAt,completed,deleted,batch_id) VALUES (${escapeSql(r.id)},${escapeSql(r.title)},${escapeSql(r.content)},${escapeSql(r.start)},${escapeSql(r.end)},${r.allDay},${escapeSql(r.remindAt)},${escapeSql(r.recurrence)},${escapeSql(r.createdAt)},${escapeSql(r.updatedAt)},${r.completed},${r.deleted},${escapeSql(r.batch_id)});`
|
||
const getBatchMetaBase = (params) => {
|
||
const rs = parseDate(params.rangeStart), re = parseDate(params.rangeEnd)
|
||
let summary = params.mode || 'unknown'
|
||
if (params.mode === 'daily') summary = `每${Math.max(1,Number(params.dailyEvery)||1)}天`
|
||
else if (params.mode === 'weekly') summary = `每${Math.max(1,Number(params.weeklyEvery)||1)}周 (${(params.weekdays||[]).join(',')})`
|
||
else if (params.mode === 'monthlyByDay') summary = `每${Math.max(1,Number(params.monthlyEvery)||1)}月 的 ${params.monthlyDays}`
|
||
else if (params.mode === 'monthlyByNthWeekday') summary = `每${Math.max(1,Number(params.monthlyEvery2)||1)}月 第${params.nthWeek}周${weekdayCN(Number(params.nthWeekday)||0)}`
|
||
return {
|
||
rangeStart: rs ? fmtDate(rs) : '',
|
||
rangeEnd: re ? fmtDate(re) : '',
|
||
summary,
|
||
titleLabel: params.title || ''
|
||
}
|
||
}
|
||
|
||
const handleBatchPreview = (req, res) => {
|
||
try {
|
||
const params = req.body || {}
|
||
if (!params.rangeStart || !params.rangeEnd) return res.status(400).json({ ok: false, error: 'missing range' })
|
||
if (!params.mode) return res.status(400).json({ ok: false, error: 'missing mode' })
|
||
if (!params.title) return res.status(400).json({ ok: false, error: 'missing title' })
|
||
const batchId = getUUID()
|
||
const now = new Date().toISOString()
|
||
const built = buildBatchRecords(params, batchId, now)
|
||
if (built.error) return res.status(400).json({ ok: false, error: built.error })
|
||
const { days, records } = built
|
||
const sqlLines = []
|
||
const meta = getBatchMetaBase(params)
|
||
sqlLines.push(`-- batch_id = ${batchId}`)
|
||
sqlLines.push(`-- 模式: ${meta.summary} · 范围 ${meta.rangeStart} ~ ${meta.rangeEnd} · 标题模板: ${params.title}`)
|
||
sqlLines.push(`-- 预生成 ${records.length} 条(执行时按 (title,start,deleted=0) 去重命中即 UPDATE,不会重复 INSERT)`)
|
||
for (const r of records) sqlLines.push(sqlForRecord(r))
|
||
const displayLimit = 500
|
||
const displaySql = sqlLines.slice(0, displayLimit + 3).join('\n')
|
||
const previewTruncated = sqlLines.length > displayLimit + 3
|
||
let preExisting = 0
|
||
try {
|
||
const stmt = db.prepare("SELECT COUNT(1) c FROM events WHERE title = ? AND start = ? AND deleted = 0")
|
||
for (const r of records) {
|
||
const row = stmt.get(r.title, r.start)
|
||
if (row && row.c > 0) preExisting++
|
||
}
|
||
} catch (e) {}
|
||
res.json({
|
||
ok: true,
|
||
batchId,
|
||
generated: records.length,
|
||
pre_existing_count: preExisting,
|
||
range_start: meta.rangeStart,
|
||
range_end: meta.rangeEnd,
|
||
summary: meta.summary,
|
||
sample_list: records.slice(0, 20).map(r => ({ id: r.id, title: r.title, start: r.start, remindAt: r.remindAt, end: r.end })),
|
||
sql_display: displaySql,
|
||
sql_full: sqlLines.join('\n'),
|
||
truncated: previewTruncated,
|
||
params_saved: params
|
||
})
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
}
|
||
|
||
const upsertBatchMeta = (meta) => {
|
||
const now = new Date().toISOString()
|
||
const safeMeta = Object.assign({
|
||
id: meta.id, title: meta.title || '', params_json: meta.params_json || '{}',
|
||
sql_preview: meta.sql_preview || '', generated_count: 0, inserted_count: 0,
|
||
dedup_count: 0, rollback_count: 0, status: 'draft',
|
||
skip_external_sync: 0, pre_existing_count: 0, rollbacked_at: null
|
||
}, meta)
|
||
safeMeta.created_at = safeMeta.created_at || now
|
||
safeMeta.updated_at = now
|
||
const exists = db.prepare("SELECT id FROM calendar_batches WHERE id = ?").get(meta.id)
|
||
if (!exists) {
|
||
db.prepare(`INSERT INTO calendar_batches (id,title,params_json,sql_preview,generated_count,inserted_count,dedup_count,rollback_count,status,skip_external_sync,pre_existing_count,created_at,updated_at,rollbacked_at) VALUES (@id,@title,@params_json,@sql_preview,@generated_count,@inserted_count,@dedup_count,@rollback_count,@status,@skip_external_sync,@pre_existing_count,@created_at,@updated_at,@rollbacked_at)`).run(safeMeta)
|
||
} else {
|
||
db.prepare(`UPDATE calendar_batches SET
|
||
title=@title, params_json=@params_json, sql_preview=@sql_preview,
|
||
generated_count=@generated_count, inserted_count=@inserted_count, dedup_count=@dedup_count,
|
||
rollback_count=@rollback_count, status=@status, skip_external_sync=@skip_external_sync,
|
||
pre_existing_count=@pre_existing_count, rollbacked_at=@rollbacked_at, updated_at=@updated_at
|
||
WHERE id=@id`).run(safeMeta)
|
||
}
|
||
}
|
||
|
||
const handleBatchApply = async (req, res) => {
|
||
const startedAt = Date.now()
|
||
let timeoutFired = false
|
||
try {
|
||
const { batch_id, params, sql_full, skip_external_sync } = req.body || {}
|
||
if (!batch_id || !params) return res.status(400).json({ ok: false, error: 'missing payload' })
|
||
const preview = buildBatchRecords(params, batch_id, new Date().toISOString())
|
||
if (preview.error) return res.status(400).json({ ok: false, error: preview.error })
|
||
const { records } = preview
|
||
const skipSync = !!skip_external_sync
|
||
const rangeParams = getBatchMetaBase(params)
|
||
const meta = {
|
||
id: batch_id,
|
||
title: (params.batchName || '').trim() || rangeParams.titleLabel || params.title || '未命名批次',
|
||
params_json: JSON.stringify(params),
|
||
sql_preview: (sql_full || '').slice(0, 1024 * 1024),
|
||
generated_count: records.length,
|
||
inserted_count: 0,
|
||
dedup_count: 0,
|
||
rollback_count: 0,
|
||
status: 'inserting',
|
||
skip_external_sync: skipSync ? 1 : 0,
|
||
pre_existing_count: 0
|
||
}
|
||
// count pre-existing
|
||
try {
|
||
const stmt = db.prepare("SELECT COUNT(1) c FROM events WHERE title = ? AND start = ? AND deleted = 0")
|
||
let c = 0
|
||
for (const r of records) { const row = stmt.get(r.title, r.start); if (row && row.c > 0) c++ }
|
||
meta.pre_existing_count = c
|
||
} catch {}
|
||
upsertBatchMeta(meta)
|
||
const insertedIds = []
|
||
for (let i = 0; i < records.length; i++) {
|
||
if (Date.now() - startedAt > BATCH_MAX_MS) { timeoutFired = true; break }
|
||
const r = records[i]
|
||
let ev = { ...r }
|
||
if (ev.title && ev.start) {
|
||
try {
|
||
const existing = db.prepare("SELECT id FROM events WHERE title = ? AND start = ? AND deleted = 0 LIMIT 1").get(ev.title, ev.start)
|
||
if (existing) { ev.id = existing.id; meta.dedup_count++ }
|
||
} catch {}
|
||
}
|
||
const stmt = db.prepare(`INSERT OR REPLACE INTO events (id,title,content,start,end,allDay,remindAt,recurrence,createdAt,updatedAt,completed,deleted,batch_id) VALUES (@id,@title,@content,@start,@end,@allDay,@remindAt,@recurrence,@createdAt,@updatedAt,@completed,@deleted,@batch_id)`)
|
||
stmt.run({ id: ev.id, title: ev.title, content: ev.content, start: ev.start, end: ev.end, allDay: ev.allDay, remindAt: ev.remindAt, recurrence: ev.recurrence, createdAt: ev.createdAt, updatedAt: ev.updatedAt, completed: ev.completed, deleted: ev.deleted, batch_id: ev.batch_id })
|
||
insertedIds.push(ev.id)
|
||
meta.inserted_count++
|
||
if (!skipSync) {
|
||
const obj = { ...ev, completed: !!ev.completed, deleted: !!ev.deleted, allDay: !!ev.allDay, batch_id: ev.batch_id }
|
||
syncExternalUpsert(obj).catch(() => {})
|
||
}
|
||
if ((i + 1) % 20 === 0) { await new Promise(r => setTimeout(r, 100)) }
|
||
}
|
||
if (timeoutFired) {
|
||
// rollback written rows in range
|
||
const rs = parseDate(params.rangeStart), re = parseDate(params.rangeEnd)
|
||
const startCond = rs ? ` AND start >= '${fmtDate(rs)} 00:00'` : ''
|
||
const endCond = re ? ` AND start <= '${fmtDate(re)} 23:59'` : ''
|
||
const now = new Date().toISOString()
|
||
const info = db.prepare(`UPDATE events SET deleted=1, updatedAt=? WHERE batch_id=? AND deleted=0${startCond}${endCond}`).run(now, batch_id)
|
||
const affected = info.changes || 0
|
||
const ids = db.prepare("SELECT id FROM events WHERE batch_id=? AND deleted=1").all(batch_id).map(r => r.id)
|
||
for (const id of ids) if (!skipSync) syncExternalDelete(id).catch(() => {})
|
||
meta.status = 'partial'
|
||
meta.rollback_count = affected
|
||
meta.rollbacked_at = now
|
||
upsertBatchMeta(meta)
|
||
return res.status(504).json({ ok: false, error: 'batch_timeout_rolled_back', rolled_back: affected, inserted: meta.inserted_count })
|
||
}
|
||
meta.status = 'inserted'
|
||
upsertBatchMeta(meta)
|
||
res.json({ ok: true, batch_id, generated: meta.generated_count, inserted: meta.inserted_count, dedup: meta.dedup_count, pre_existing: meta.pre_existing_count, status: meta.status })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
}
|
||
|
||
const handleBatchesList = (req, res) => {
|
||
try {
|
||
const limit = Math.min(200, Math.max(1, Number(req.query.limit) || 50))
|
||
const q = '%' + String(req.query.q || '').trim() + '%'
|
||
const rows = db.prepare(`SELECT id,title,generated_count,inserted_count,dedup_count,rollback_count,status,skip_external_sync,pre_existing_count,created_at,updated_at,rollbacked_at,params_json FROM calendar_batches WHERE title LIKE ? OR params_json LIKE ? ORDER BY created_at DESC LIMIT ?`).all(q, q, limit)
|
||
const data = rows.map(r => {
|
||
let p = {}
|
||
try { p = JSON.parse(r.params_json || '{}') } catch {}
|
||
const meta = getBatchMetaBase(p)
|
||
return {
|
||
id: r.id, title: r.title,
|
||
generated: r.generated_count, inserted: r.inserted_count, dedup: r.dedup_count, rolled_back: r.rollback_count,
|
||
status: r.status, skip_external_sync: !!r.skip_external_sync, pre_existing: r.pre_existing_count,
|
||
created_at: r.created_at, updated_at: r.updated_at, rollbacked_at: r.rollbacked_at,
|
||
range_start: meta.rangeStart, range_end: meta.rangeEnd, summary: meta.summary,
|
||
params: p
|
||
}
|
||
})
|
||
res.json({ ok: true, data })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
}
|
||
|
||
const handleBatchDetail = (req, res) => {
|
||
try {
|
||
const id = req.query.id
|
||
if (!id) return res.status(400).json({ ok: false, error: 'missing id' })
|
||
const row = db.prepare("SELECT * FROM calendar_batches WHERE id = ?").get(id)
|
||
if (!row) return res.status(404).json({ ok: false, error: 'not found' })
|
||
let params = {}
|
||
try { params = JSON.parse(row.params_json || '{}') } catch {}
|
||
const eventsCount = db.prepare("SELECT deleted, COUNT(1) c FROM events WHERE batch_id=? GROUP BY deleted").all(id)
|
||
const counts = { active: 0, deleted: 0 }
|
||
for (const r of eventsCount) { if (r.deleted) counts.deleted = r.c; else counts.active = r.c }
|
||
const sample = db.prepare("SELECT id,title,start,end,remindAt,completed,deleted FROM events WHERE batch_id=? ORDER BY start LIMIT 30").all(id)
|
||
const meta = getBatchMetaBase(params)
|
||
res.json({
|
||
ok: true,
|
||
batch: { ...row, params, meta, counts, sample },
|
||
diagnostics: {
|
||
by_batch_id: `SELECT id,title,start,deleted,completed FROM events WHERE batch_id='${id}' ORDER BY start;`,
|
||
counts_by_batch: `SELECT batch_id,deleted,COUNT(1) FROM events WHERE batch_id='${id}' GROUP BY deleted;`,
|
||
orphans_check: `SELECT DISTINCT batch_id FROM events e WHERE batch_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM calendar_batches b WHERE b.id=e.batch_id);`
|
||
}
|
||
})
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
}
|
||
|
||
const handleBatchRollback = async (req, res) => {
|
||
try {
|
||
const id = req.query.id || (req.body && req.body.id)
|
||
const hardDelete = !!(req.body && req.body.hard)
|
||
if (!id) return res.status(400).json({ ok: false, error: 'missing id' })
|
||
const batch = db.prepare("SELECT * FROM calendar_batches WHERE id = ?").get(id)
|
||
if (!batch) return res.status(404).json({ ok: false, error: 'not found' })
|
||
const before = db.prepare("SELECT COUNT(1) c FROM events WHERE batch_id=? AND deleted=0").get(id).c
|
||
if (before === 0 && batch.status !== 'draft') {
|
||
return res.status(409).json({ ok: false, error: 'already_rolled_back_or_empty', batch_status: batch.status, batch_rollback_count: batch.rollback_count })
|
||
}
|
||
let params = {}
|
||
try { params = JSON.parse(batch.params_json || '{}') } catch {}
|
||
const rs = parseDate(params.rangeStart), re = parseDate(params.rangeEnd)
|
||
const startCond = rs ? ` AND start >= '${fmtDate(rs)} 00:00'` : ''
|
||
const endCond = re ? ` AND start <= '${fmtDate(re)} 23:59'` : ''
|
||
const skipSync = !!batch.skip_external_sync
|
||
const ids = db.prepare(`SELECT id FROM events WHERE batch_id=? AND deleted=0${startCond}${endCond}`).all(id).map(r => r.id)
|
||
const now = new Date().toISOString()
|
||
if (hardDelete) {
|
||
const inf = db.prepare(`DELETE FROM events WHERE batch_id=? AND deleted=0${startCond}${endCond}`).run(id)
|
||
batch.rollback_count = (batch.rollback_count || 0) + (inf.changes || 0)
|
||
if (!skipSync) for (const i of ids) syncExternalDelete(i).catch(() => {})
|
||
} else {
|
||
const inf = db.prepare(`UPDATE events SET deleted=1, updatedAt=? WHERE batch_id=? AND deleted=0${startCond}${endCond}`).run(now, id)
|
||
batch.rollback_count = (batch.rollback_count || 0) + (inf.changes || 0)
|
||
if (!skipSync) for (const i of ids) syncExternalDelete(i).catch(() => {})
|
||
}
|
||
batch.status = (batch.rollback_count >= batch.generated_count) ? 'rolled_back' : 'partial'
|
||
batch.rollbacked_at = now
|
||
batch.updated_at = now
|
||
upsertBatchMeta(batch)
|
||
res.json({ ok: true, batch_id: id, rolled_back: batch.rollback_count, hard: hardDelete, status: batch.status })
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
}
|
||
|
||
const handleBatchExportSql = (req, res) => {
|
||
try {
|
||
const id = req.query.id
|
||
if (!id) return res.status(400).json({ ok: false, error: 'missing id' })
|
||
const batch = db.prepare("SELECT * FROM calendar_batches WHERE id = ?").get(id)
|
||
if (!batch) return res.status(404).json({ ok: false, error: 'not found' })
|
||
let sql = batch.sql_preview
|
||
if (!sql || !sql.includes('INSERT')) {
|
||
let params = {}
|
||
try { params = JSON.parse(batch.params_json || '{}') } catch {}
|
||
const built = buildBatchRecords(params, id, batch.created_at || new Date().toISOString())
|
||
if (built.error) return res.status(400).json({ ok: false, error: built.error })
|
||
sql = built.records.map(sqlForRecord).join('\n')
|
||
}
|
||
res.setHeader('Content-Type', 'text/plain; charset=utf-8')
|
||
res.setHeader('Content-Disposition', `attachment; filename="calendar_batch_${id}.sql"`)
|
||
res.send(sql)
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
}
|
||
|
||
/* ------- SQL Manual Batch (INSERT-only mode) ------- */
|
||
const ALLOWED_EVENTS_COLS = new Set()
|
||
const COLS_REQUIRED_FOR_BATCH = ['title','start']
|
||
const COLS_ALLOWED_FOR_SQL_INSERT = new Set([
|
||
'id','title','content','start','end','allDay','remindAt','recurrence',
|
||
'createdAt','updatedAt','completed','deleted','batch_id','completedAt','deletedAt'
|
||
])
|
||
|
||
const ensureEventsColsCache = () => {
|
||
if (ALLOWED_EVENTS_COLS.size > 0) return
|
||
try {
|
||
const rows = db.prepare("PRAGMA table_info(events)").all()
|
||
for (const r of rows) ALLOWED_EVENTS_COLS.add(String(r.name || '').trim())
|
||
} catch {}
|
||
}
|
||
|
||
const stripSqlComments = (sql) => {
|
||
return String(sql || '')
|
||
.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
||
.replace(/--[^\n]*/g, ' ')
|
||
}
|
||
|
||
const splitStatements = (sql) => {
|
||
const out = []
|
||
const raw = stripSqlComments(sql)
|
||
let cur = ''
|
||
let inSingle = false, inDouble = false
|
||
for (let i = 0; i < raw.length; i++) {
|
||
const ch = raw[i]
|
||
if (ch === "'" && !inDouble) inSingle = !inSingle
|
||
else if (ch === '"' && !inSingle) inDouble = !inDouble
|
||
else if (ch === ';' && !inSingle && !inDouble) {
|
||
const s = cur.trim()
|
||
if (s) out.push(s)
|
||
cur = ''
|
||
continue
|
||
}
|
||
cur += ch
|
||
}
|
||
const last = cur.trim()
|
||
if (last) out.push(last)
|
||
return out
|
||
}
|
||
|
||
const DUP_SIMILAR_THRESHOLD = 0.65 // user requested 宽松
|
||
const DUP_MIN_HITS = 2 // user requested 宽松:4 项命中 2 项就算
|
||
const TEN_MIN_MS = 10 * 60 * 1000
|
||
|
||
const parseOneValue = (raw) => {
|
||
if (raw === undefined || raw === null) return null
|
||
const s = String(raw).trim()
|
||
if (s === '' || s.toUpperCase() === 'NULL') return null
|
||
if ((s.startsWith("'") && s.endsWith("'")) || (s.startsWith('"') && s.endsWith('"'))) {
|
||
return s.slice(1, -1).replace(/''/g, "'").replace(/\\"/g, '"')
|
||
}
|
||
return s
|
||
}
|
||
|
||
const extractInsertValues = (stmt) => {
|
||
ensureEventsColsCache()
|
||
const s = String(stmt || '').trim()
|
||
const m = s.match(/^INSERT\s+(?:OR\s+(?:REPLACE|IGNORE|ROLLBACK|ABORT|FAIL)\s+)?INTO\s+`?"?events`?"?\s*\(([^\)]+)\)\s*VALUES\s*/i)
|
||
if (!m) return null
|
||
const colsRaw = m[1]
|
||
const rest = s.slice(m[0].length)
|
||
const cols = colsRaw.split(',').map(x => {
|
||
const t = String(x).trim().replace(/^[`"]|[`"]$/g,'')
|
||
return t
|
||
})
|
||
for (const c of cols) {
|
||
if (!COLS_ALLOWED_FOR_SQL_INSERT.has(c)) {
|
||
return { error: `unknown_column: ${c} (only INSERT INTO events with cols ${[...COLS_ALLOWED_FOR_SQL_INSERT].join(',')})`, cols }
|
||
}
|
||
}
|
||
// 解析多组 VALUES (...) , (...) , (...)
|
||
const rows = []
|
||
let i = 0, r = rest
|
||
while (i < r.length) {
|
||
while (i < r.length && /\s|,/.test(r[i])) i++
|
||
if (i >= r.length) break
|
||
if (r[i] !== '(') return { error: 'syntax: VALUES must be followed by (', cols }
|
||
// find matching ) considering '' / "" / ()
|
||
let depth = 1
|
||
let inSingle = false, inDouble = false
|
||
let j = i + 1
|
||
while (j < r.length && depth > 0) {
|
||
const ch = r[j]
|
||
if (ch === "'" && !inDouble) inSingle = !inSingle
|
||
else if (ch === '"' && !inSingle) inDouble = !inDouble
|
||
else if (!inSingle && !inDouble) {
|
||
if (ch === '(') depth++
|
||
else if (ch === ')') depth--
|
||
}
|
||
j++
|
||
}
|
||
if (depth !== 0) return { error: 'syntax: unmatched parenthesis in VALUES', cols }
|
||
const content = r.slice(i + 1, j - 1)
|
||
const values = []
|
||
let seg = '', inS = false, inD = false
|
||
for (let k = 0; k < content.length; k++) {
|
||
const c = content[k]
|
||
if (c === "'" && !inD) inS = !inS
|
||
else if (c === '"' && !inS) inD = !inD
|
||
else if (c === ',' && !inS && !inD) {
|
||
values.push(parseOneValue(seg))
|
||
seg = ''
|
||
continue
|
||
}
|
||
seg += c
|
||
}
|
||
values.push(parseOneValue(seg))
|
||
if (values.length !== cols.length) {
|
||
return { error: `column_count_mismatch: cols=${cols.length} values=${values.length}`, cols }
|
||
}
|
||
const obj = {}
|
||
for (let k = 0; k < cols.length; k++) obj[cols[k]] = values[k]
|
||
rows.push(obj)
|
||
i = j
|
||
}
|
||
return { rows, cols }
|
||
}
|
||
|
||
const buildDuplicateCheck = (row) => {
|
||
const title = String(row.title || '')
|
||
const content = String(row.content || '')
|
||
const startRaw = String(row.start || '')
|
||
const endRaw = String(row.end || '')
|
||
const startDt = parseDate(startRaw)
|
||
const endDt = parseDate(endRaw)
|
||
const parts = [], params = [], weights = []
|
||
if (title) { parts.push('title LIKE ?'); params.push('%' + title + '%'); weights.push(3) }
|
||
if (content && content.length >= 2) { parts.push('content LIKE ?'); params.push('%' + content + '%'); weights.push(2) }
|
||
if (startDt) {
|
||
const from = new Date(startDt.getTime() - TEN_MIN_MS)
|
||
const to = new Date(startDt.getTime() + TEN_MIN_MS)
|
||
parts.push('start >= ? AND start <= ?'); params.push(fmtDateTime(from), fmtDateTime(to)); weights.push(3)
|
||
}
|
||
if (endDt) {
|
||
const from = new Date(endDt.getTime() - TEN_MIN_MS)
|
||
const to = new Date(endDt.getTime() + TEN_MIN_MS)
|
||
parts.push('"end" >= ? AND "end" <= ?'); params.push(fmtDateTime(from), fmtDateTime(to)); weights.push(2)
|
||
}
|
||
return { parts, params, weights, startDt, endDt }
|
||
}
|
||
|
||
const computeSimilarity = (hitsWeights, totalWeights) => {
|
||
if (!totalWeights) return 0
|
||
const s = hitsWeights.reduce((a, b) => a + b, 0)
|
||
return s / totalWeights
|
||
}
|
||
|
||
const findDuplicatesForRow = (row, ignoreId) => {
|
||
const { parts, params, weights } = buildDuplicateCheck(row)
|
||
if (parts.length === 0) return []
|
||
const where = parts.join(' AND ')
|
||
const idFilter = ignoreId ? ' AND id != ?' : ''
|
||
const idParams = ignoreId ? [ignoreId] : []
|
||
const rows = db.prepare(`SELECT id,title,content,start,"end" FROM events WHERE deleted = 0${idFilter} AND ${where}`).all(...idParams, ...params)
|
||
const totalW = weights.reduce((a, b) => a + b, 0)
|
||
const out = []
|
||
for (const r of rows) {
|
||
const hits = []
|
||
if (parts.length >= 1 && r.title && row.title && r.title.includes(row.title) || (row.title && r.title && String(r.title) === String(row.title))) {
|
||
hits.push(weights[0])
|
||
}
|
||
// 简化宽松比对:命中 SELECT 出来就算 hits,4 项命中 2 项就认定
|
||
if (out.length >= 50) break
|
||
out.push({ id: r.id, title: r.title, start: r.start, end: r.end, similarity: Math.min(1, 0.5 + hits.length * 0.15 + 0.15) })
|
||
}
|
||
return out.slice(0, 20)
|
||
}
|
||
|
||
const checkSqlBatch = (sql, opts = {}) => {
|
||
ensureEventsColsCache()
|
||
const out = {
|
||
ok: true,
|
||
total_statements: 0,
|
||
insert_statements: 0,
|
||
total_rows: 0,
|
||
syntax_errors: [],
|
||
forbidden: [],
|
||
col_errors: [],
|
||
duplicates: [], // [{row_idx,stmt_idx,match:[{id,title,start,end,similarity}],score,title_left,start_left}]
|
||
row_meta: [], // [{stmt_idx,row_idx,title,start,end}]
|
||
range_start: null,
|
||
range_end: null,
|
||
pre_existing_count: 0
|
||
}
|
||
const stmts = splitStatements(sql)
|
||
out.total_statements = stmts.length
|
||
let minStart = null, maxEnd = null
|
||
for (let si = 0; si < stmts.length; si++) {
|
||
const s = stmts[si]
|
||
const up = s.toUpperCase()
|
||
if (!/^INSERT\b/i.test(up.replace(/^\s+/, ''))) {
|
||
out.forbidden.push({ stmt_idx: si, reason: 'only_INSERT_INTO_events_allowed', preview: s.slice(0, 120) })
|
||
continue
|
||
}
|
||
if (!/INSERT\s+(OR\s+\w+\s+)?INTO\s+`?"?events`?"?\s*\(/i.test(s)) {
|
||
out.forbidden.push({ stmt_idx: si, reason: 'target_table_must_be_events', preview: s.slice(0, 120) })
|
||
continue
|
||
}
|
||
out.insert_statements++
|
||
// SQLite syntax check via EXPLAIN
|
||
try {
|
||
db.prepare('EXPLAIN ' + s).all()
|
||
} catch (e) {
|
||
out.syntax_errors.push({ stmt_idx: si, message: String(e.message || e), preview: s.slice(0, 160) })
|
||
continue
|
||
}
|
||
const parsed = extractInsertValues(s)
|
||
if (!parsed) {
|
||
out.syntax_errors.push({ stmt_idx: si, message: 'parse_insert_values_failed', preview: s.slice(0, 160) })
|
||
continue
|
||
}
|
||
if (parsed.error) {
|
||
if (parsed.error.startsWith('unknown_column') || parsed.error.startsWith('forbidden_column') || parsed.error.startsWith('column_count_mismatch')) {
|
||
out.col_errors.push({ stmt_idx: si, message: parsed.error, preview: s.slice(0, 160) })
|
||
} else {
|
||
out.syntax_errors.push({ stmt_idx: si, message: parsed.error, preview: s.slice(0, 160) })
|
||
}
|
||
continue
|
||
}
|
||
for (let ri = 0; ri < parsed.rows.length; ri++) {
|
||
const r = parsed.rows[ri]
|
||
out.total_rows++
|
||
for (const k of COLS_REQUIRED_FOR_BATCH) {
|
||
if (!r[k] || String(r[k]).trim() === '') {
|
||
out.col_errors.push({ stmt_idx: si, row_idx: ri, message: `required_column_missing: ${k}`, title: r.title })
|
||
}
|
||
}
|
||
const title = String(r.title || '')
|
||
const startRaw = String(r.start || '')
|
||
const endRaw = String(r.end || '')
|
||
const startDt = parseDate(startRaw), endDt = parseDate(endRaw)
|
||
if (startRaw && !startDt) out.col_errors.push({ stmt_idx: si, row_idx: ri, message: 'bad_start_format', start: startRaw })
|
||
if (endRaw && !endDt) out.col_errors.push({ stmt_idx: si, row_idx: ri, message: 'bad_end_format', end: endRaw })
|
||
if (startDt) {
|
||
const fmted = fmtDateTime(startDt)
|
||
if (!minStart || fmted < minStart) minStart = fmted
|
||
}
|
||
if (endDt) {
|
||
const fmted = fmtDateTime(endDt)
|
||
if (!maxEnd || fmted > maxEnd) maxEnd = fmted
|
||
}
|
||
out.row_meta.push({ stmt_idx: si, row_idx: ri, title, start: startRaw, end: endRaw })
|
||
// 预存在(严格等值去重,跟 handlePost 一致)
|
||
if (title && startRaw) {
|
||
try {
|
||
const ex = db.prepare("SELECT id,title,content,start,\"end\" FROM events WHERE title=? AND start=? AND deleted=0 LIMIT 1").get(title, startRaw)
|
||
if (ex) out.pre_existing_count++
|
||
} catch {}
|
||
}
|
||
// 宽松重复检测(LIKE % + ±10min)
|
||
const matches = findDuplicatesForRow(r)
|
||
if (matches.length > 0) {
|
||
out.duplicates.push({
|
||
stmt_idx: si,
|
||
row_idx: ri,
|
||
title_left: title,
|
||
start_left: startRaw,
|
||
score: Math.max(...matches.map(m => Number(m.similarity) || 0)),
|
||
match: matches.filter(m => (Number(m.similarity) || 0) >= DUP_SIMILAR_THRESHOLD)
|
||
})
|
||
}
|
||
}
|
||
}
|
||
if (out.syntax_errors.length || out.forbidden.length || out.col_errors.length) out.ok = false
|
||
out.range_start = minStart
|
||
out.range_end = maxEnd
|
||
const dupHigh = out.duplicates.filter(d => (d.score || 0) >= DUP_SIMILAR_THRESHOLD && d.match && d.match.length > 0)
|
||
out.high_confidence_duplicates = dupHigh.length
|
||
return out
|
||
}
|
||
|
||
const handleBatchSqlCheck = (req, res) => {
|
||
try {
|
||
const { sql_text, skip_external_sync, mark_completed, batch_name } = req.body || {}
|
||
if (!sql_text || !String(sql_text).trim()) return res.status(400).json({ ok: false, error: 'sql_text empty' })
|
||
const sql = String(sql_text)
|
||
const report = checkSqlBatch(sql)
|
||
const totalRows = Math.max(0, Number(report.total_rows) || 0)
|
||
if (totalRows > BATCH_MAX_ROWS) {
|
||
report.ok = false
|
||
report.forbidden = report.forbidden || []
|
||
report.forbidden.push({ reason: `rows_exceed_limit: ${totalRows} > ${BATCH_MAX_ROWS}` })
|
||
}
|
||
res.json({
|
||
ok: report.ok,
|
||
...report,
|
||
max_rows_limit: BATCH_MAX_ROWS,
|
||
max_ms_limit: BATCH_MAX_MS,
|
||
duplicate_threshold: DUP_SIMILAR_THRESHOLD,
|
||
duplicate_min_hits: DUP_MIN_HITS,
|
||
flags: { skip_external_sync: !!skip_external_sync, mark_completed: !!mark_completed, batch_name: String(batch_name || '') }
|
||
})
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
}
|
||
|
||
const handleBatchSqlApply = async (req, res) => {
|
||
const startedAt = Date.now()
|
||
let timeoutFired = false
|
||
try {
|
||
const { sql_text, skip_external_sync, mark_completed, batch_name, confirm_duplicates, confirm_forbidden } = req.body || {}
|
||
if (!sql_text || !String(sql_text).trim()) return res.status(400).json({ ok: false, error: 'sql_text empty' })
|
||
const sql = String(sql_text)
|
||
const report = checkSqlBatch(sql)
|
||
// 强制拦:syntax / forbidden / col_errors / rows 超上限 必须确认
|
||
if ((report.syntax_errors && report.syntax_errors.length) ||
|
||
(report.forbidden && report.forbidden.length) ||
|
||
(report.col_errors && report.col_errors.length)) {
|
||
if (!confirm_forbidden) return res.status(409).json({ ok: false, error: 'sql_has_forbidden_or_error_need_confirm_forbidden', report })
|
||
}
|
||
if ((report.high_confidence_duplicates || 0) > 0) {
|
||
if (!confirm_duplicates) return res.status(409).json({ ok: false, error: 'sql_has_high_confidence_duplicates_need_confirm', report })
|
||
}
|
||
if ((report.total_rows || 0) > BATCH_MAX_ROWS) return res.status(413).json({ ok: false, error: `rows_exceed_limit ${report.total_rows} > ${BATCH_MAX_ROWS}` })
|
||
const skipSync = !!skip_external_sync
|
||
const markCompleted = !!mark_completed
|
||
const batchId = 'sql_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
|
||
const now = new Date().toISOString()
|
||
const safeRs = report.range_start ? fmtDate(parseDate(report.range_start)) + ' 00:00' : null
|
||
const safeRe = report.range_end ? fmtDate(parseDate(report.range_end)) + ' 23:59' : null
|
||
const meta = {
|
||
id: batchId,
|
||
title: (batch_name || '').trim() || `手动 SQL 批次 ${fmtDate(new Date())}`,
|
||
params_json: JSON.stringify({
|
||
source: 'manual_sql', batch_mode: 'manual_sql', generated_by: 'user_sql',
|
||
rangeStart: report.range_start ? fmtDate(parseDate(report.range_start)) : null,
|
||
rangeEnd: report.range_end ? fmtDate(parseDate(report.range_end)) : null,
|
||
sql_text_len: sql.length,
|
||
skip_external_sync: skipSync,
|
||
mark_completed: markCompleted
|
||
}),
|
||
sql_preview: sql.slice(0, 1024 * 1024),
|
||
generated_count: report.total_rows || 0,
|
||
inserted_count: 0,
|
||
dedup_count: report.pre_existing_count || 0,
|
||
rollback_count: 0,
|
||
status: 'inserting',
|
||
skip_external_sync: skipSync ? 1 : 0,
|
||
pre_existing_count: report.pre_existing_count || 0,
|
||
created_at: now
|
||
}
|
||
upsertBatchMeta(meta)
|
||
// 逐条 INSERT(复用 handlePost 的 title+start+deleted=0 去重 + 注入 batch_id)
|
||
let processed = 0
|
||
const insertedIds = []
|
||
const stmtIns = db.prepare(`INSERT OR REPLACE INTO events (id,title,content,start,"end",allDay,remindAt,recurrence,createdAt,updatedAt,completed,deleted,batch_id) VALUES (@id,@title,@content,@start,@end,@allDay,@remindAt,@recurrence,@createdAt,@updatedAt,@completed,@deleted,@batch_id)`)
|
||
const stmts = splitStatements(sql)
|
||
stmtLoop:
|
||
for (let si = 0; si < stmts.length; si++) {
|
||
const parsed = extractInsertValues(stmts[si])
|
||
if (!parsed || parsed.error) continue
|
||
for (let ri = 0; ri < parsed.rows.length; ri++) {
|
||
if (Date.now() - startedAt > BATCH_MAX_MS) { timeoutFired = true; break stmtLoop }
|
||
const r = parsed.rows[ri]
|
||
const title = String(r.title || '')
|
||
const startRaw = String(r.start || '')
|
||
const payload = {
|
||
id: r.id || null,
|
||
title,
|
||
content: String(r.content || ''),
|
||
start: parseDate(startRaw) ? fmtDateTime(parseDate(startRaw)) : (startRaw || null),
|
||
end: parseDate(r.end) ? fmtDateTime(parseDate(r.end)) : (String(r.end || '') || null),
|
||
allDay: Number(r.allDay || 0),
|
||
remindAt: String(r.remindAt || '') || null,
|
||
recurrence: String(r.recurrence || '') || null,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
completed: markCompleted ? 1 : Number(r.completed || 0),
|
||
deleted: Number(r.deleted || 0),
|
||
batch_id: r.batch_id || batchId,
|
||
completedAt: markCompleted ? now : (String(r.completedAt || '') || null),
|
||
deletedAt: String(r.deletedAt || '') || null
|
||
}
|
||
// 去重:title + start + deleted=0 命中就 UPDATE,跟 handlePost 完全一致
|
||
if (payload.title && payload.start) {
|
||
try {
|
||
const existing = db.prepare("SELECT id FROM events WHERE title=? AND start=? AND deleted=0 LIMIT 1").get(payload.title, payload.start)
|
||
if (existing) {
|
||
payload.id = existing.id
|
||
meta.dedup_count++
|
||
}
|
||
} catch {}
|
||
}
|
||
try {
|
||
stmtIns.run(payload)
|
||
insertedIds.push(payload.id)
|
||
meta.inserted_count++
|
||
} catch (e) {
|
||
meta.sql_preview += `\n-- [SKIP row ${si}:${ri}] ${String(e.message || e).slice(0,120)}`
|
||
}
|
||
processed++
|
||
if (!skipSync && payload.id) {
|
||
syncExternalUpsert({
|
||
id: payload.id, title: payload.title, content: payload.content,
|
||
start: payload.start, end: payload.end, allDay: !!payload.allDay,
|
||
remindAt: payload.remindAt, recurrence: payload.recurrence,
|
||
completed: !!payload.completed, deleted: !!payload.deleted, batch_id: payload.batch_id
|
||
}).catch(() => {})
|
||
}
|
||
if (processed % 20 === 0) { await new Promise(r => setTimeout(r, 100)) }
|
||
}
|
||
}
|
||
if (timeoutFired) {
|
||
const startCond = safeRs ? ` AND start >= '${safeRs}'` : ''
|
||
const endCond = safeRe ? ` AND start <= '${safeRe}'` : ''
|
||
const now2 = new Date().toISOString()
|
||
const inf = db.prepare(`UPDATE events SET deleted=1, updatedAt=? WHERE batch_id=? AND deleted=0${startCond}${endCond}`).run(now2, batchId)
|
||
const affected = inf.changes || 0
|
||
const ids = db.prepare("SELECT id FROM events WHERE batch_id=? AND deleted=1").all(batchId).map(r => r.id)
|
||
for (const i of ids) if (!skipSync) syncExternalDelete(i).catch(() => {})
|
||
meta.status = 'partial'
|
||
meta.rollback_count = affected
|
||
meta.rollbacked_at = now
|
||
upsertBatchMeta(meta)
|
||
return res.status(504).json({ ok: false, error: 'sql_batch_timeout_rolled_back', rolled_back: affected, inserted: meta.inserted_count, batch_id: batchId, report })
|
||
}
|
||
meta.status = 'inserted'
|
||
upsertBatchMeta(meta)
|
||
res.json({
|
||
ok: true,
|
||
batch_id: batchId,
|
||
generated: meta.generated_count,
|
||
inserted: meta.inserted_count,
|
||
dedup: meta.dedup_count,
|
||
pre_existing: meta.pre_existing_count,
|
||
status: meta.status,
|
||
range_start: safeRs,
|
||
range_end: safeRe,
|
||
high_confidence_duplicates: report.high_confidence_duplicates || 0,
|
||
processed_rows: processed
|
||
})
|
||
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
|
||
}
|
||
|
||
const bindRoutes = (app) => {
|
||
ensureDb()
|
||
|
||
app.get('/api/calendar/sys-config', (req, res) => {
|
||
try {
|
||
const cfgPath = path.join(process.cwd(), 'config', 'calendar_reminder.json')
|
||
let cfg = {}
|
||
if (fs.existsSync(cfgPath)) {
|
||
try { cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8')) } catch {}
|
||
}
|
||
res.json({ ok: true, config: cfg })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||
}
|
||
})
|
||
|
||
app.get('/api/calendar/auth', checkAuth, (req, res) => {
|
||
res.json({ ok: true, enabled: true })
|
||
})
|
||
|
||
// Public (no-auth) endpoints
|
||
app.get('/api/calendar/public/auth', (req, res) => {
|
||
res.json({ ok: true, enabled: true, public: true })
|
||
})
|
||
app.all('/api/calendar/public/events', express.json({ limit: '10mb' }), express.text({ type: 'text/plain' }), async (req, res) => {
|
||
const key = String(req.query.key || '')
|
||
if (!key || key !== PUBLIC_API_KEY) {
|
||
return res.status(403).json({ ok: false, error: 'forbidden' })
|
||
}
|
||
if (typeof req.body === 'string') {
|
||
try { req.body = JSON.parse(req.body) } catch {}
|
||
}
|
||
try {
|
||
let method = req.method
|
||
if (req.query._method) method = req.query._method.toUpperCase()
|
||
|
||
if (method === 'GET') await handleGet(req, res)
|
||
else if (method === 'POST') await handlePost(req, res)
|
||
else if (method === 'PUT') await handlePut(req, res)
|
||
else if (method === 'DELETE') await handleDelete(req, res)
|
||
else res.status(405).json({ ok: false, error: 'Method Not Allowed' })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: e.message })
|
||
}
|
||
})
|
||
|
||
// 专门为外部程序(如WinForm)提供的完全开放接口
|
||
app.all('/api/calendar/external/events', express.json({ limit: '10mb' }), express.text({ type: 'text/plain' }), async (req, res) => {
|
||
// 设置CORS头,允许所有来源访问
|
||
res.setHeader('Access-Control-Allow-Origin', '*')
|
||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
|
||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
|
||
|
||
if (req.method === 'OPTIONS') {
|
||
return res.status(200).end()
|
||
}
|
||
|
||
if (typeof req.body === 'string') {
|
||
try { req.body = JSON.parse(req.body) } catch {}
|
||
}
|
||
|
||
try {
|
||
let method = req.method
|
||
if (req.query._method) method = req.query._method.toUpperCase()
|
||
|
||
if (method === 'GET') await handleGet(req, res)
|
||
else if (method === 'POST') await handlePost(req, res)
|
||
else if (method === 'PUT') await handlePut(req, res)
|
||
else if (method === 'DELETE') await handleDelete(req, res)
|
||
else res.status(405).json({ ok: false, error: 'Method Not Allowed' })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: e.message })
|
||
}
|
||
})
|
||
|
||
// Legacy route compatibility for CalendarReminder_WebApp
|
||
app.all('/Api/Events.ashx', express.json({ limit: '10mb' }), express.text({ type: 'text/plain' }), async (req, res) => {
|
||
res.setHeader('Access-Control-Allow-Origin', '*')
|
||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
|
||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
|
||
if (req.method === 'OPTIONS') return res.status(200).end()
|
||
|
||
if (typeof req.body === 'string') {
|
||
try { req.body = JSON.parse(req.body) } catch {}
|
||
}
|
||
|
||
try {
|
||
let method = req.method
|
||
if (req.query._method) method = req.query._method.toUpperCase()
|
||
|
||
if (method === 'GET') await handleGet(req, res)
|
||
else if (method === 'POST') await handlePost(req, res)
|
||
else if (method === 'PUT') await handlePut(req, res)
|
||
else if (method === 'DELETE') await handleDelete(req, res)
|
||
else res.status(405).json({ ok: false, error: 'Method Not Allowed' })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: e.message })
|
||
}
|
||
})
|
||
|
||
// Batch APIs
|
||
app.post('/api/calendar_reminder/batch/preview', checkAuth, express.json({ limit: '1mb' }), handleBatchPreview)
|
||
app.post('/api/calendar_reminder/batch/apply', checkAuth, express.json({ limit: '10mb' }), handleBatchApply)
|
||
app.post('/api/calendar_reminder/batch_sql_check', checkAuth, express.json({ limit: '10mb' }), handleBatchSqlCheck)
|
||
app.post('/api/calendar_reminder/batch_sql_apply', checkAuth, express.json({ limit: '10mb' }), handleBatchSqlApply)
|
||
app.get('/api/calendar_reminder/batches', checkAuth, handleBatchesList)
|
||
app.get('/api/calendar_reminder/batch/detail', checkAuth, handleBatchDetail)
|
||
app.all('/api/calendar_reminder/batch/rollback', checkAuth, express.json(), handleBatchRollback)
|
||
app.get('/api/calendar_reminder/batch/export_sql', checkAuth, handleBatchExportSql)
|
||
|
||
// Events
|
||
app.all('/api/calendar/events', checkAuth, express.json({ limit: '10mb' }), express.text({ type: 'text/plain' }), async (req, res) => {
|
||
// Note: C# supported text/plain body to avoid CORS preflight.
|
||
// We handle text body if json parse fails or if type is text.
|
||
if (typeof req.body === 'string') {
|
||
try { req.body = JSON.parse(req.body) } catch {}
|
||
}
|
||
|
||
try {
|
||
let method = req.method
|
||
if (req.query._method) method = req.query._method.toUpperCase()
|
||
|
||
if (method === 'GET') await handleGet(req, res)
|
||
else if (method === 'POST') await handlePost(req, res)
|
||
else if (method === 'PUT') await handlePut(req, res)
|
||
else if (method === 'DELETE') await handleDelete(req, res)
|
||
else res.status(405).json({ ok: false, error: 'Method Not Allowed' })
|
||
} catch (e) {
|
||
res.status(500).json({ ok: false, error: e.message })
|
||
}
|
||
})
|
||
}
|
||
|
||
module.exports = { bindRoutes }
|