feat(mp, web, calendar): 新增微信收藏夹与日历批量生成功能
- 新增微信小程序版个人收藏夹全套功能,包含聚合首页、收藏列表、编辑、详情页面与配套后端API - 新增web版时光助手门户与个人收藏夹工具,支持与小程序共享后端数据 - 优化日历提醒工具:替换本地bootstrap资源为公共CDN版本,新增批量生成循环提醒的前端界面与后端逻辑,新增数据库batch_id字段,更新鉴权配置文件 - 补充项目配置、站点地图、调试诊断脚本等配套文件 - 完善各模块的页面配置与样式文件
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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
@@ -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-')) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user