feat(mp, web, calendar): 新增微信收藏夹与日历批量生成功能

- 新增微信小程序版个人收藏夹全套功能,包含聚合首页、收藏列表、编辑、详情页面与配套后端API
- 新增web版时光助手门户与个人收藏夹工具,支持与小程序共享后端数据
- 优化日历提醒工具:替换本地bootstrap资源为公共CDN版本,新增批量生成循环提醒的前端界面与后端逻辑,新增数据库batch_id字段,更新鉴权配置文件
- 补充项目配置、站点地图、调试诊断脚本等配套文件
- 完善各模块的页面配置与样式文件
This commit is contained in:
yangxiangyuan
2026-09-02 22:20:37 +08:00
parent 132d132473
commit db7648eb4d
75 changed files with 8618 additions and 70 deletions
+9 -5
View File
@@ -34,9 +34,11 @@ const ensureDb = () => {
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;");
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);`);
};
const getUUID = () => {
@@ -156,8 +158,8 @@ const handlePost = async (req, res) => {
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)
VALUES (@id,@title,@content,@start,@end,@allDay,@remindAt,@recurrence,@createdAt,@updatedAt,@completed,@deleted)`);
(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,
@@ -171,7 +173,8 @@ const handlePost = async (req, res) => {
createdAt: ev.createdAt,
updatedAt: ev.updatedAt,
completed: ev.completed,
deleted: ev.deleted
deleted: ev.deleted,
batch_id: ev.batch_id || null
});
await syncExternalUpsert(ev);
@@ -196,7 +199,7 @@ const handlePut = async (req, res) => {
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 WHERE id=@id`);
updatedAt=@updatedAt,completed=@completed,deleted=@deleted,batch_id=@batch_id WHERE id=@id`);
stmt.run({
id: merged.id,
@@ -209,7 +212,8 @@ const handlePut = async (req, res) => {
recurrence: merged.recurrence,
updatedAt: merged.updatedAt,
completed: merged.completed,
deleted: merged.deleted
deleted: merged.deleted,
batch_id: merged.batch_id !== undefined ? merged.batch_id : existing.batch_id
});
await syncExternalUpsert(merged);
+920 -5
View File
@@ -92,10 +92,41 @@ const ensureDb = () => {
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
@@ -225,8 +256,8 @@ const handlePost = async (req, res) => {
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)
VALUES (@id,@title,@content,@start,@end,@allDay,@remindAt,@recurrence,@createdAt,@updatedAt,@completed,@deleted)`)
(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,
@@ -240,7 +271,8 @@ const handlePost = async (req, res) => {
createdAt: ev.createdAt,
updatedAt: ev.updatedAt,
completed: ev.completed,
deleted: ev.deleted
deleted: ev.deleted,
batch_id: ev.batch_id || null
})
await syncExternalUpsert(ev)
@@ -280,7 +312,7 @@ const handlePut = async (req, res) => {
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 WHERE id=@id`)
updatedAt=@updatedAt,completed=@completed,deleted=@deleted,batch_id=@batch_id WHERE id=@id`)
stmt.run({
id: merged.id,
@@ -293,7 +325,8 @@ const handlePut = async (req, res) => {
recurrence: merged.recurrence,
updatedAt: merged.updatedAt,
completed: merged.completed,
deleted: merged.deleted
deleted: merged.deleted,
batch_id: merged.batch_id !== undefined ? merged.batch_id : existing.batch_id
})
await syncExternalUpsert(merged)
@@ -311,6 +344,878 @@ const handleDelete = async (req, res) => {
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++
const parsedBefore = extractInsertValues(s)
if (parsedBefore && parsedBefore.error) {
if (/column/i.test(parsedBefore.error)) {
out.col_errors.push({ stmt_idx: si, message: parsedBefore.error, preview: s.slice(0, 160) })
} else {
out.syntax_errors.push({ stmt_idx: si, message: parsedBefore.error, preview: s.slice(0, 160) })
}
continue
}
// SQLite syntax check via EXPLAIN
try {
db.prepare('EXPLAIN ' + s).all()
} catch (e) {
const msg = String(e.message || e)
if (/column/i.test(msg)) {
out.col_errors.push({ stmt_idx: si, message: msg, preview: s.slice(0, 160) })
} else {
out.syntax_errors.push({ stmt_idx: si, message: msg, preview: s.slice(0, 160) })
}
continue
}
const parsed = parsedBefore || 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()
@@ -411,6 +1316,16 @@ const bindRoutes = (app) => {
}
})
// 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.
+160 -1
View File
@@ -304,6 +304,20 @@ const ensureAppOrderBoxLoaded = () => {
}
ensureAppOrderBoxLoaded()
const ensureMpTimeAssistantLoaded = () => {
let mod = null
try { mod = require('./mp_time_assistant') } catch (e) { try { logJSON('mp_time_assistant.require.error', { error: String(e.message || e) }, 'mp_time_assistant') } catch {}; setTimeout(ensureMpTimeAssistantLoaded, 2000); return }
try {
if (mod && mod.bindRoutes) {
mod.bindRoutes(app)
try { logJSON('mp_time_assistant.bind_routes.done', { ok: true }, 'mp_time_assistant') } catch {}
}
} catch (e) {
try { logJSON('mp_time_assistant.bind_routes.error', { error: String(e.message || e) }, 'mp_time_assistant') } catch {}
}
}
ensureMpTimeAssistantLoaded()
const CALENDAR_VOICE_AUTH_COOKIE = 'calendar_voice_gate'
const readCalendarVoiceAuthConfig = () => {
try {
@@ -819,6 +833,150 @@ const ensureZhiziNotesLoaded = () => {
}
ensureZhiziNotesLoaded()
const WEB_MP_TIME_ASSISTANT_AUTH_COOKIE = 'web_mp_time_assistant_gate'
const readWebMpTimeAssistantAuthConfig = () => {
try {
const p = path.join(process.cwd(), 'public', 'tools', 'web_mp_time_assistant', 'auth_config.json')
if (fs.existsSync(p)) {
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
if (cfg && typeof cfg === 'object') return cfg
}
} catch {}
return {}
}
const readWebMpTimeAssistantJwk = () => {
try {
const p = path.join(process.cwd(), 'config', 'web_mp_time_assistant.jwk.json')
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
} catch {}
return { kty: 'RSA', n: '', e: '' }
}
const getWebMpTimeAssistantCookieName = () => {
try {
const cfg = readWebMpTimeAssistantAuthConfig()
const n = String(cfg.cookieName || '')
if (n) return n
} catch {}
return WEB_MP_TIME_ASSISTANT_AUTH_COOKIE
}
const getWebMpTimeAssistantMaxAge = () => {
try {
const cfg = readWebMpTimeAssistantAuthConfig()
const days = Number(cfg.max_age_days || 0)
if (Number.isFinite(days) && days > 0) return Math.min(days, 365) * 24 * 3600 * 1000
} catch {}
return 30 * 24 * 3600 * 1000
}
const getWebMpTimeAssistantGuidConfig = () => {
try {
const cfg = readWebMpTimeAssistantAuthConfig()
const keyRaw = String(cfg.guidParam || cfg.guid_param || 'guid').trim()
const valueRaw = String(cfg.guidValue || cfg.guid_value || '').trim()
return { key: keyRaw || 'guid', value: valueRaw }
} catch {
return { key: 'guid', value: '' }
}
}
const hasValidWebMpTimeAssistantGuid = (req) => {
try {
const cfg = readWebMpTimeAssistantAuthConfig()
if (cfg.enable_auth === false) return false
const { key, value } = getWebMpTimeAssistantGuidConfig()
if (!value) return false
const q = req && req.query ? req.query : {}
const raw = q[key]
if (raw === undefined || raw === null) return false
const incoming = Array.isArray(raw) ? String(raw[0] || '') : String(raw)
return incoming === value
} catch { return false }
}
const isWebMpTimeAssistantForceGuid = () => {
try {
const cfg = readWebMpTimeAssistantAuthConfig()
if (cfg.enable_auth === false) return false
if (Object.prototype.hasOwnProperty.call(cfg, 'requireGuid')) return !!cfg.requireGuid
if (Object.prototype.hasOwnProperty.call(cfg, 'require_guid')) return !!cfg.require_guid
return false
} catch { return false }
}
const hasWebMpTimeAssistantAuth = (req) => {
try {
const config = readWebMpTimeAssistantAuthConfig()
if (config.enable_auth === false) return true
const cookies = parseCookie(req.headers.cookie || '')
if (cookies[getWebMpTimeAssistantCookieName()] === '1') return true
return false
} catch { return false }
}
app.get('/tools/web_mp_time_assistant', (req, res, next) => {
setNoCache(res)
try {
const forceGuid = isWebMpTimeAssistantForceGuid()
if (hasWebMpTimeAssistantAuth(req)) return next()
const guidKey = getWebMpTimeAssistantGuidConfig().key
const hasGuidInput = req && req.query && Object.prototype.hasOwnProperty.call(req.query, guidKey)
if (hasValidWebMpTimeAssistantGuid(req)) {
const maxAge = getWebMpTimeAssistantMaxAge()
const cookieName = getWebMpTimeAssistantCookieName()
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
return res.redirect('/tools/web_mp_time_assistant/index.html')
}
if (hasGuidInput) return res.status(401).send('未授权')
const token = String(req.query.token || '')
if (!token) {
if (forceGuid) return res.status(401).send('未授权')
return next()
}
const payload = verifyJwtWithKeys(token, jwkKeys(readWebMpTimeAssistantJwk()))
if (!payload) return res.status(401).send('未授权')
{
const iss = String(payload.iss || '')
const navIss = getNavIssFromFlags()
if (iss === navIss && !audMatch(payload, 'Tools-web_mp_time_assistant')) return res.status(401).send('未授权')
}
const maxAge = getWebMpTimeAssistantMaxAge()
const cookieName = getWebMpTimeAssistantCookieName()
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
return res.redirect('/tools/web_mp_time_assistant/index.html')
} catch (e) { return res.status(401).send('未授权') }
})
app.use('/tools/web_mp_time_assistant', (req, res, next) => {
const forceGuid = isWebMpTimeAssistantForceGuid()
if (hasWebMpTimeAssistantAuth(req)) return next()
const isHandshake = req.method === 'GET' && (req.path === '/' || req.path === '' || req.path === '/index.html' || req.path === '/hub.html')
const guidKey = getWebMpTimeAssistantGuidConfig().key
const hasGuidInput = isHandshake && req && req.query && Object.prototype.hasOwnProperty.call(req.query, guidKey)
if (isHandshake && hasValidWebMpTimeAssistantGuid(req)) {
const maxAge = getWebMpTimeAssistantMaxAge()
const cookieName = getWebMpTimeAssistantCookieName()
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
return res.redirect('/tools/web_mp_time_assistant/index.html')
}
if (hasGuidInput) return res.status(401).send('未授权')
if (isHandshake && String(req.query.token || '')) {
const payload = verifyJwtWithKeys(String(req.query.token), jwkKeys(readWebMpTimeAssistantJwk()))
if (payload) {
const iss = String(payload.iss || '')
const navIss = getNavIssFromFlags()
if (iss === navIss && !audMatch(payload, 'Tools-web_mp_time_assistant')) return res.status(401).send('未授权')
const maxAge = getWebMpTimeAssistantMaxAge()
const cookieName = getWebMpTimeAssistantCookieName()
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
return res.redirect('/tools/web_mp_time_assistant/index.html')
}
}
if (isHandshake && forceGuid) return res.status(401).send('未授权')
return res.status(401).send('未授权')
})
app.use('/api/mp_time_assistant', (req, res, next) => {
if (hasWebMpTimeAssistantAuth(req)) return next()
return res.status(401).json({ ok: false, error: 'unauthorized' })
})
app.use('/uploads/mp_time_assistant', (req, res, next) => {
if (hasWebMpTimeAssistantAuth(req)) return next()
return res.status(401).send('未授权')
})
const PLANT_HOME_AUTH_COOKIE = 'plant_home_gate'
const readPlantHomeAuthConfig = () => {
try {
@@ -1643,7 +1801,8 @@ const mapSystemIdToUrl = (systemId) => {
'Tools-short_link': '/tools/short_link',
'Tools-web_mobile_calendar_reminder': '/tools/web_mobile_calendar_reminder',
'Tools-greedy_snake': '/tools/greedy_snake',
'Tools-oss_browser_link': '/tools/oss_browser_link'
'Tools-oss_browser_link': '/tools/oss_browser_link',
'Tools-web_mp_time_assistant': '/tools/web_mp_time_assistant'
}
if (m[systemId]) return m[systemId]
if (systemId.startsWith('Tools-')) {
+92
View File
@@ -0,0 +1,92 @@
const Database = require('better-sqlite3')
const path = require('path')
const { bindRoutes: bindPersonalCollectRoutes } = require('./kits/personal_collect')
const CORE_DB_PATH = path.join(process.cwd(), 'data', 'mp_time_assistant_core.db')
let db = null
const KIT_META = [
{
id: 'personal_collect',
name: '微信收藏夹',
description: '聊天图片、文件收藏管理,标签搜索,软删除可追溯。',
},
]
const nowIso = () => new Date().toISOString()
const getDb = () => {
if (db) return db
db = new Database(CORE_DB_PATH)
db.pragma('journal_mode = WAL')
db.exec(`CREATE TABLE IF NOT EXISTS core_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
active_kit TEXT,
updated_at TEXT NOT NULL
);`)
db.prepare(`
INSERT OR IGNORE INTO core_state (id, active_kit, updated_at)
VALUES (1, NULL, ?)
`).run(nowIso())
return db
}
const getStateRow = () => {
const row = getDb().prepare(`SELECT * FROM core_state WHERE id = 1 LIMIT 1`).get()
if (!row) {
getDb().prepare(`
INSERT INTO core_state (id, active_kit, updated_at)
VALUES (1, NULL, ?)
`).run(nowIso())
return getStateRow()
}
return row
}
const setActiveKit = kitId => {
getDb().prepare(`UPDATE core_state SET active_kit = ?, updated_at = ? WHERE id = 1`).run(kitId || null, nowIso())
}
const serializeState = () => {
const row = getStateRow()
return {
ok: true,
activeKit: row.active_kit || null,
}
}
const bindRoutes = app => {
getDb()
app.get('/api/mp_time_assistant/meta', (req, res) => {
res.json({
ok: true,
appName: 'MpTimeAssistant',
appNameZh: 'MilesYang时光助手',
kits: KIT_META,
})
})
app.get('/api/mp_time_assistant/state', (req, res) => {
res.json(serializeState())
})
app.put('/api/mp_time_assistant/state/active-kit', (req, res) => {
try {
const raw = String(req.body && req.body.kitId || '').trim()
const nextKit = raw || null
if (nextKit && !KIT_META.find(item => item.id === nextKit)) {
return res.status(400).json({ ok: false, error: 'unknown kit id' })
}
setActiveKit(nextKit)
return res.json({ ok: true, activeKit: nextKit })
} catch (e) {
return res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
bindPersonalCollectRoutes(app)
}
module.exports = { bindRoutes }
@@ -0,0 +1,158 @@
const crypto = require('crypto')
const fs = require('fs')
const path = require('path')
const multer = require('multer')
const store = require('./store')
const UPLOAD_ROOT = path.join(process.cwd(), 'uploads', 'mp_time_assistant', 'personal_collect')
const API_PREFIX = '/api/mp_time_assistant/kits/personal_collect'
const createId = () => {
try { return crypto.randomUUID() } catch { return `${Date.now()}_${Math.random().toString(36).slice(2)}` }
}
const ensureDir = dir => {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
}
ensureDir(UPLOAD_ROOT)
const isImageByMime = mime => /^image\//i.test(String(mime || ''))
const isImageByName = name => /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(String(name || ''))
const storage = multer.diskStorage({
destination: (req, file, cb) => {
const groupId = String(req.body && req.body.groupId || createId()).trim()
const dir = path.join(UPLOAD_ROOT, groupId)
ensureDir(dir)
req._uploadGroupId = groupId
cb(null, dir)
},
filename: (req, file, cb) => {
const ext = path.extname(file.originalname) || ''
const storedName = `${Date.now()}_${Math.random().toString(36).slice(2, 10)}${ext}`
cb(null, storedName)
},
})
const upload = multer({
storage,
limits: { fileSize: 50 * 1024 * 1024 },
})
const parseTags = raw => {
const s = String(raw || '').trim()
if (!s) return []
return s
.split(/[,,\s]+/)
.map(t => t.trim())
.filter(Boolean)
}
const bindRoutes = app => {
app.get(`${API_PREFIX}/items`, (req, res) => {
try {
const keyword = String(req.query.keyword || '').trim()
res.json({ ok: true, data: store.listItems({ keyword }) })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.get(`${API_PREFIX}/items/:id`, (req, res) => {
try {
const id = String(req.params.id || '').trim()
if (!id) return res.status(400).json({ ok: false, error: 'missing id' })
const item = store.getItemById(id)
if (!item) return res.status(404).json({ ok: false, error: 'not found' })
res.json({ ok: true, data: item })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.post(`${API_PREFIX}/items`, (req, res) => {
try {
const body = req.body || {}
const title = String(body.title || '').trim()
const remark = String(body.remark || '').trim()
if (!title && !remark) return res.status(400).json({ ok: false, error: '标题或备注至少填一个' })
const tags = Array.isArray(body.tags) ? body.tags : parseTags(body.tagsText)
const attachmentIds = Array.isArray(body.attachmentIds) ? body.attachmentIds : []
const saved = store.insertItem({
id: createId(),
title,
remark,
tags,
attachmentIds,
})
return res.json({ ok: true, data: saved })
} catch (e) {
return res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.put(`${API_PREFIX}/items`, (req, res) => {
try {
const body = req.body || {}
const id = String(body.id || '').trim()
if (!id) return res.status(400).json({ ok: false, error: 'missing id' })
const existing = store.getItemById(id)
if (!existing) return res.status(404).json({ ok: false, error: 'not found' })
const title = String(body.title || '').trim()
const remark = String(body.remark || '').trim()
if (!title && !remark) return res.status(400).json({ ok: false, error: '标题或备注至少填一个' })
const tags = Array.isArray(body.tags) ? body.tags : parseTags(body.tagsText)
const attachmentIds = Array.isArray(body.attachmentIds) ? body.attachmentIds : null
const saved = store.updateItem({ id, title, remark, tags, attachmentIds })
return res.json({ ok: true, data: saved })
} catch (e) {
return res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.delete(`${API_PREFIX}/items`, (req, res) => {
try {
const id = String(req.query.id || '').trim()
if (!id) return res.status(400).json({ ok: false, error: 'missing id' })
store.deleteItem(id)
return res.json({ ok: true })
} catch (e) {
return res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.post(`${API_PREFIX}/upload`, upload.single('file'), (req, res) => {
try {
const f = req.file
if (!f) return res.status(400).json({ ok: false, error: '未接收到文件' })
const groupId = String(req._uploadGroupId || createId())
const relDir = path.join('uploads', 'mp_time_assistant', 'personal_collect', groupId)
const relPath = path.join(relDir, f.filename).replace(/\\/g, '/')
const urlPath = '/' + relPath
const mimeType = String(f.mimetype || '').trim()
const isImage = isImageByMime(mimeType) || isImageByName(f.originalname)
const saved = store.insertAttachment({
id: createId(),
favoriteId: null,
fileName: f.originalname,
storedName: f.filename,
filePath: relPath,
fileSize: f.size || 0,
mimeType,
isImage,
})
return res.json({
ok: true,
data: {
...saved,
url: urlPath,
groupId,
},
})
} catch (e) {
return res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
}
module.exports = { bindRoutes }
@@ -0,0 +1,189 @@
const Database = require('better-sqlite3')
const path = require('path')
const DB_PATH = path.join(process.cwd(), 'data', 'mp_time_assistant_personal_collect.db')
let db = null
const nowIso = () => new Date().toISOString()
const getDb = () => {
if (db) return db
db = new Database(DB_PATH)
db.pragma('journal_mode = WAL')
db.exec(`CREATE TABLE IF NOT EXISTS favorites (
id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT '',
remark TEXT NOT NULL DEFAULT '',
tags_json TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted INTEGER NOT NULL DEFAULT 0
);`)
db.exec(`CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY,
favorite_id TEXT,
file_name TEXT NOT NULL,
stored_name TEXT NOT NULL,
file_path TEXT NOT NULL,
file_size INTEGER NOT NULL DEFAULT 0,
mime_type TEXT NOT NULL DEFAULT '',
is_image INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_mp_collect_favorites_updated ON favorites(updated_at);`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_mp_collect_attachments_favorite ON attachments(favorite_id);`)
return db
}
const safeParseJson = (raw, fallback) => {
try { return raw ? JSON.parse(raw) : fallback } catch { return fallback }
}
const mapFavorite = row => ({
id: row.id,
title: row.title,
remark: row.remark,
tags: safeParseJson(row.tags_json, []),
createdAt: row.created_at,
updatedAt: row.updated_at,
})
const mapAttachment = row => ({
id: row.id,
favoriteId: row.favorite_id,
fileName: row.file_name,
storedName: row.stored_name,
filePath: row.file_path,
fileSize: Number(row.file_size || 0),
mimeType: row.mime_type,
isImage: !!row.is_image,
createdAt: row.created_at,
})
const listItems = ({ keyword = '' } = {}) => {
const kw = String(keyword || '').trim()
const rows = kw
? getDb().prepare(`
SELECT * FROM favorites
WHERE deleted = 0
AND (title LIKE @kw OR remark LIKE @kw OR tags_json LIKE @kw)
ORDER BY datetime(updated_at) DESC, datetime(created_at) DESC
`).all({ kw: `%${kw}%` })
: getDb().prepare(`
SELECT * FROM favorites
WHERE deleted = 0
ORDER BY datetime(updated_at) DESC, datetime(created_at) DESC
`).all()
return rows.map(row => {
const item = mapFavorite(row)
item.attachments = getAttachmentsByFavoriteId(row.id)
return item
})
}
const getItemById = id => {
const row = getDb().prepare(`SELECT * FROM favorites WHERE id = ? AND deleted = 0 LIMIT 1`).get(id)
if (!row) return null
const item = mapFavorite(row)
item.attachments = getAttachmentsByFavoriteId(row.id)
return item
}
const insertItem = item => {
const createdAt = nowIso()
const payload = {
id: item.id,
title: String(item.title || '').trim(),
remark: String(item.remark || '').trim(),
tags_json: JSON.stringify(Array.isArray(item.tags) ? item.tags : []),
created_at: createdAt,
updated_at: createdAt,
}
getDb().prepare(`
INSERT INTO favorites (id, title, remark, tags_json, created_at, updated_at, deleted)
VALUES (@id, @title, @remark, @tags_json, @created_at, @updated_at, 0)
`).run(payload)
if (Array.isArray(item.attachmentIds) && item.attachmentIds.length) {
bindAttachmentsToFavorite(payload.id, item.attachmentIds)
}
return getItemById(payload.id)
}
const updateItem = item => {
const payload = {
id: item.id,
title: String(item.title || '').trim(),
remark: String(item.remark || '').trim(),
tags_json: JSON.stringify(Array.isArray(item.tags) ? item.tags : []),
updated_at: nowIso(),
}
getDb().prepare(`
UPDATE favorites
SET title = @title,
remark = @remark,
tags_json = @tags_json,
updated_at = @updated_at
WHERE id = @id AND deleted = 0
`).run(payload)
if (Array.isArray(item.attachmentIds)) {
getDb().prepare(`DELETE FROM attachments WHERE favorite_id = ?`).run(payload.id)
bindAttachmentsToFavorite(payload.id, item.attachmentIds)
}
return getItemById(payload.id)
}
const deleteItem = id => {
getDb().prepare(`UPDATE favorites SET deleted = 1, updated_at = ? WHERE id = ? AND deleted = 0`).run(nowIso(), id)
}
const insertAttachment = ({ id, favoriteId, fileName, storedName, filePath, fileSize, mimeType, isImage }) => {
const createdAt = nowIso()
const payload = {
id,
favorite_id: favoriteId || null,
file_name: String(fileName || '').trim() || 'untitled',
stored_name: String(storedName || '').trim(),
file_path: String(filePath || '').trim(),
file_size: Math.max(0, Number(fileSize || 0)),
mime_type: String(mimeType || '').trim(),
is_image: isImage ? 1 : 0,
created_at: createdAt,
}
getDb().prepare(`
INSERT INTO attachments (id, favorite_id, file_name, stored_name, file_path, file_size, mime_type, is_image, created_at)
VALUES (@id, @favorite_id, @file_name, @stored_name, @file_path, @file_size, @mime_type, @is_image, @created_at)
`).run(payload)
return getAttachmentById(id)
}
const getAttachmentById = id => {
const row = getDb().prepare(`SELECT * FROM attachments WHERE id = ? LIMIT 1`).get(id)
return row ? mapAttachment(row) : null
}
const getAttachmentsByFavoriteId = favoriteId => {
const rows = getDb().prepare(`SELECT * FROM attachments WHERE favorite_id = ? ORDER BY datetime(created_at) ASC`).all(favoriteId)
return rows.map(mapAttachment)
}
const bindAttachmentsToFavorite = (favoriteId, attachmentIds) => {
const stmt = getDb().prepare(`UPDATE attachments SET favorite_id = ? WHERE id = ?`)
const tx = getDb().transaction(ids => {
for (const aid of ids) {
if (aid) stmt.run(favoriteId, String(aid))
}
})
tx(attachmentIds)
}
module.exports = {
listItems,
getItemById,
insertItem,
updateItem,
deleteItem,
insertAttachment,
getAttachmentById,
getAttachmentsByFavoriteId,
}