Files
Toolbox/dev_test_scripts/unit/_calendar_reminder_diag.js
T
yangxiangyuan db7648eb4d feat(mp, web, calendar): 新增微信收藏夹与日历批量生成功能
- 新增微信小程序版个人收藏夹全套功能,包含聚合首页、收藏列表、编辑、详情页面与配套后端API
- 新增web版时光助手门户与个人收藏夹工具,支持与小程序共享后端数据
- 优化日历提醒工具:替换本地bootstrap资源为公共CDN版本,新增批量生成循环提醒的前端界面与后端逻辑,新增数据库batch_id字段,更新鉴权配置文件
- 补充项目配置、站点地图、调试诊断脚本等配套文件
- 完善各模块的页面配置与样式文件
2026-09-02 22:20:37 +08:00

1339 lines
56 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }