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 }