feat: 新增运动节拍训练工具套件与子工具页面
新增运动节拍RepClock全套小程序页面、后端API与数据库服务,新增subhub子工具汇总页面,优化全局主页样式与配置,为web静态资源添加版本缓存参数,补充配套调试脚本
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
const crypto = require('crypto')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const multer = require('multer')
|
||||
const iconv = require('iconv-lite')
|
||||
const store = require('./store')
|
||||
|
||||
function scoreChinese(str) {
|
||||
if (!str) return -Infinity
|
||||
let cjk = 0, asciiPrint = 0, ext = 0, weird = 0
|
||||
const len = str.length || 1
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const c = str.charCodeAt(i)
|
||||
if (c >= 0x4e00 && c <= 0x9fff) cjk++
|
||||
else if (c >= 0x3400 && c <= 0x4dbf) cjk++
|
||||
else if (c >= 0x3000 && c <= 0x303f) cjk++
|
||||
else if (c >= 0xff00 && c <= 0xffef) cjk++
|
||||
else if (c === 0xb7 || c === 0x2022) cjk++
|
||||
else if (c >= 0x20 && c <= 0x7e) asciiPrint++
|
||||
else if (c < 0x20 && c !== 9 && c !== 10 && c !== 13) weird++
|
||||
else if (c >= 0x80 && c <= 0xff) ext++
|
||||
else if (c >= 0x100 && c < 0x3000) ext++
|
||||
else ext++
|
||||
}
|
||||
return cjk * 3 + asciiPrint * 1 - ext * 2 - weird * 10 - (str.length > len * 2 ? 5 : 0)
|
||||
}
|
||||
|
||||
function hasRepeatedMojibake(str) {
|
||||
if (!str) return false
|
||||
return (
|
||||
/[鏃犳爣棰鍒氫綘鍙戠幇浣犲凡缁忎粖澶╂垜浠埌杈剧殑鏄繖涓柟闈㈠鐞嗙殑闂鍦ㄥ綋鍓嶇幆澧冧笅閫氳繃浠ヤ笅鏂瑰紡瑙e喅娉ㄦ剰浜嬮」閫夋嫨鐨勫唴瀹规槸浠€涔堟椂鍊欑殑妗堜緥璇存槑浣跨敤杩欎釜鍔熻兘鏉ユ洿濂界殑鍒╃敤鑷繁鐨勬洿濂界殑鐢熸椿宸ュ叿浣跨敤鎵嬫]/.test(str) ||
|
||||
(/[\u4e00-\u9fa5]/.test(str) && /[çèéêëìíîïðñòóôõöøùúûüýþÿÃÂÄÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞß]/.test(str)) ||
|
||||
(/[\u4e00-\u9fa5]/.test(str) && /\uFFFD/.test(str))
|
||||
)
|
||||
}
|
||||
|
||||
function fixFileNameEncoding(rawName) {
|
||||
const name = String(rawName == null ? '' : rawName).trim()
|
||||
if (!name) return 'untitled'
|
||||
|
||||
const extMatch = name.match(/(\.[A-Za-z0-9]{1,10})$/)
|
||||
const ext = extMatch ? extMatch[1] : ''
|
||||
const base = ext ? name.slice(0, name.length - ext.length) : name
|
||||
if (!base) return name
|
||||
|
||||
const candidates = [{ s: name, src: 'origin' }]
|
||||
|
||||
const pushDec = (buf, enc, src) => {
|
||||
try {
|
||||
if (!iconv.encodingExists(enc)) return
|
||||
const d = iconv.decode(buf, enc)
|
||||
if (d && typeof d === 'string') {
|
||||
candidates.push({ s: d + ext, src })
|
||||
candidates.push({ s: d.replace(/\u0000/g, '') + ext, src: src + '_nozero' })
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
let buf
|
||||
try { buf = Buffer.from(base, 'latin1') } catch { buf = null }
|
||||
if (buf && buf.length) {
|
||||
pushDec(buf, 'gbk', 'lat_gbk')
|
||||
pushDec(buf, 'gb18030', 'lat_gb18030')
|
||||
pushDec(buf, 'utf-8', 'lat_utf8')
|
||||
pushDec(buf, 'big5', 'lat_big5')
|
||||
pushDec(buf, 'cp936', 'lat_cp936')
|
||||
}
|
||||
|
||||
try { buf = Buffer.from(base, 'binary') } catch { buf = null }
|
||||
if (buf && buf.length) {
|
||||
pushDec(buf, 'gbk', 'bin_gbk')
|
||||
pushDec(buf, 'gb18030', 'bin_gb18030')
|
||||
pushDec(buf, 'utf-8', 'bin_utf8')
|
||||
}
|
||||
|
||||
const utf8Buf = Buffer.from(base, 'utf-8')
|
||||
if (utf8Buf && utf8Buf.length) {
|
||||
pushDec(utf8Buf, 'latin1', 'utf8_lat1')
|
||||
pushDec(utf8Buf, 'binary', 'utf8_bin')
|
||||
}
|
||||
|
||||
const bytes = []
|
||||
for (let i = 0; i < base.length; i++) {
|
||||
const c = base.charCodeAt(i)
|
||||
if (c >= 0xd800 && c <= 0xdbff && i + 1 < base.length) {
|
||||
const low = base.charCodeAt(i + 1)
|
||||
if (low >= 0xdc00 && low <= 0xdfff) {
|
||||
const cp = 0x10000 + (c - 0xd800) * 0x400 + (low - 0xdc00)
|
||||
bytes.push(0xff & (cp >> 8), 0xff & cp)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
}
|
||||
bytes.push(0xff & c)
|
||||
}
|
||||
if (bytes.length) {
|
||||
const raw = Buffer.from(bytes)
|
||||
pushDec(raw, 'gbk', 'raw_gbk')
|
||||
pushDec(raw, 'gb18030', 'raw_gb18030')
|
||||
pushDec(raw, 'utf-8', 'raw_utf8')
|
||||
pushDec(raw, 'big5', 'raw_big5')
|
||||
pushDec(raw, 'shift_jis', 'raw_sjis')
|
||||
pushDec(raw, 'euc-kr', 'raw_euckr')
|
||||
if (raw.length >= 2) {
|
||||
const buf2 = Buffer.alloc(raw.length)
|
||||
for (let i = 0; i < raw.length; i++) buf2[i] = raw[i]
|
||||
pushDec(buf2, 'cp936', 'raw_cp936')
|
||||
}
|
||||
}
|
||||
|
||||
let best = null
|
||||
let bestScore = -Infinity
|
||||
for (const cand of candidates) {
|
||||
if (!cand.s) continue
|
||||
if (/\uFFFD/.test(cand.s) && cand.src !== 'origin') continue
|
||||
const s = cand.s.replace(/\u0000/g, '')
|
||||
if (!s) continue
|
||||
let sc = scoreChinese(s)
|
||||
if (cand.src === 'origin' && /[ÃÂÄÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßçèéêëìíîïðñòóôõöøùúûüýþÿ]/.test(s)) sc -= 15
|
||||
if (cand.src === 'origin' && hasRepeatedMojibake(s)) sc -= 30
|
||||
if (/[ÃÂÄÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßçèéêëìíîïðñòóôõöøùúûüýþÿ]/.test(s) && cand.src !== 'origin') sc -= 10
|
||||
if (sc > bestScore) { bestScore = sc; best = s }
|
||||
}
|
||||
|
||||
if (!best) best = name
|
||||
if (best.length > 255) best = best.slice(0, 250) + (ext || '')
|
||||
return best
|
||||
}
|
||||
|
||||
const UPLOAD_ROOT = path.join(process.cwd(), 'uploads', 'mp_personal_collect')
|
||||
const API_PREFIX = '/api/mp_personal_collect/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_personal_collect', groupId)
|
||||
const relPath = path.join(relDir, f.filename).replace(/\\/g, '/')
|
||||
const urlPath = '/' + relPath
|
||||
const mimeType = String(f.mimetype || '').trim()
|
||||
const originalName = fixFileNameEncoding(f.originalname)
|
||||
const isImage = isImageByMime(mimeType) || isImageByName(originalName)
|
||||
const saved = store.insertAttachment({
|
||||
id: createId(),
|
||||
favoriteId: null,
|
||||
fileName: 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, API_PREFIX }
|
||||
@@ -0,0 +1,332 @@
|
||||
const Database = require('better-sqlite3')
|
||||
const path = require('path')
|
||||
const iconv = require('iconv-lite')
|
||||
|
||||
function scoreChinese(str) {
|
||||
if (!str) return -Infinity
|
||||
let cjk = 0, asciiPrint = 0, ext = 0, weird = 0
|
||||
const len = str.length || 1
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const c = str.charCodeAt(i)
|
||||
if (c >= 0x4e00 && c <= 0x9fff) cjk++
|
||||
else if (c >= 0x3400 && c <= 0x4dbf) cjk++
|
||||
else if (c >= 0x3000 && c <= 0x303f) cjk++
|
||||
else if (c >= 0xff00 && c <= 0xffef) cjk++
|
||||
else if (c === 0xb7 || c === 0x2022) cjk++
|
||||
else if (c >= 0x20 && c <= 0x7e) asciiPrint++
|
||||
else if (c < 0x20 && c !== 9 && c !== 10 && c !== 13) weird++
|
||||
else if (c >= 0x80 && c <= 0xff) ext++
|
||||
else if (c >= 0x100 && c < 0x3000) ext++
|
||||
else ext++
|
||||
}
|
||||
return cjk * 3 + asciiPrint * 1 - ext * 2 - weird * 10 - (str.length > len * 2 ? 5 : 0)
|
||||
}
|
||||
|
||||
function hasRepeatedMojibake(str) {
|
||||
if (!str) return false
|
||||
return /[鏃犳爣棰鍒氫綘鍙戠幇浣犲凡缁忎粖澶╂垜浠埌杈剧殑鏄繖涓柟闈㈠鐞嗙殑闂鍦ㄥ綋鍓嶇幆澧冧笅閫氳繃浠ヤ笅鏂瑰紡瑙e喅娉ㄦ剰浜嬮」閫夋嫨鐨勫唴瀹规槸浠€涔堟椂鍊欑殑妗堜緥璇存槑浣跨敤杩欎釜鍔熻兘鏉ユ洿濂界殑鍒╃敤鑷繁鐨勬洿濂界殑鐢熸椿宸ュ叿浣跨敤鎵嬫]/.test(str) ||
|
||||
(/[\u4e00-\u9fa5]{2}/.test(str) && /[çèéêëìíîïðñòóôõöøùúûüýþÿÃÂÄÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞß]/.test(str)) ||
|
||||
(/[\u4e00-\u9fa5]/.test(str) && /\uFFFD/.test(str))
|
||||
}
|
||||
|
||||
function fixFileNameEncoding(rawName) {
|
||||
const name = String(rawName == null ? '' : rawName).trim()
|
||||
if (!name) return 'untitled'
|
||||
|
||||
const extMatch = name.match(/(\.[A-Za-z0-9]{1,10})$/)
|
||||
const ext = extMatch ? extMatch[1] : ''
|
||||
const base = ext ? name.slice(0, name.length - ext.length) : name
|
||||
if (!base) return name
|
||||
|
||||
const candidates = [{ s: name, src: 'origin' }]
|
||||
|
||||
const pushDec = (buf, enc, src) => {
|
||||
try {
|
||||
if (!iconv.encodingExists(enc)) return
|
||||
const d = iconv.decode(buf, enc)
|
||||
if (d && typeof d === 'string') {
|
||||
candidates.push({ s: d + ext, src })
|
||||
candidates.push({ s: d.replace(/\u0000/g, '') + ext, src: src + '_nozero' })
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
let buf
|
||||
try { buf = Buffer.from(base, 'latin1') } catch { buf = null }
|
||||
if (buf && buf.length) {
|
||||
pushDec(buf, 'gbk', 'lat_gbk')
|
||||
pushDec(buf, 'gb18030', 'lat_gb18030')
|
||||
pushDec(buf, 'utf-8', 'lat_utf8')
|
||||
pushDec(buf, 'big5', 'lat_big5')
|
||||
pushDec(buf, 'cp936', 'lat_cp936')
|
||||
}
|
||||
|
||||
try { buf = Buffer.from(base, 'binary') } catch { buf = null }
|
||||
if (buf && buf.length) {
|
||||
pushDec(buf, 'gbk', 'bin_gbk')
|
||||
pushDec(buf, 'gb18030', 'bin_gb18030')
|
||||
pushDec(buf, 'utf-8', 'bin_utf8')
|
||||
}
|
||||
|
||||
const utf8Buf = Buffer.from(base, 'utf-8')
|
||||
if (utf8Buf && utf8Buf.length) {
|
||||
pushDec(utf8Buf, 'latin1', 'utf8_lat1')
|
||||
pushDec(utf8Buf, 'binary', 'utf8_bin')
|
||||
}
|
||||
|
||||
const bytes = []
|
||||
for (let i = 0; i < base.length; i++) {
|
||||
const c = base.charCodeAt(i)
|
||||
if (c >= 0xd800 && c <= 0xdbff && i + 1 < base.length) {
|
||||
const low = base.charCodeAt(i + 1)
|
||||
if (low >= 0xdc00 && low <= 0xdfff) {
|
||||
const cp = 0x10000 + (c - 0xd800) * 0x400 + (low - 0xdc00)
|
||||
bytes.push(0xff & (cp >> 8), 0xff & cp)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
}
|
||||
bytes.push(0xff & c)
|
||||
}
|
||||
if (bytes.length) {
|
||||
const raw = Buffer.from(bytes)
|
||||
pushDec(raw, 'gbk', 'raw_gbk')
|
||||
pushDec(raw, 'gb18030', 'raw_gb18030')
|
||||
pushDec(raw, 'utf-8', 'raw_utf8')
|
||||
pushDec(raw, 'big5', 'raw_big5')
|
||||
pushDec(raw, 'shift_jis', 'raw_sjis')
|
||||
pushDec(raw, 'euc-kr', 'raw_euckr')
|
||||
if (raw.length >= 2) {
|
||||
const buf2 = Buffer.alloc(raw.length)
|
||||
for (let i = 0; i < raw.length; i++) buf2[i] = raw[i]
|
||||
pushDec(buf2, 'cp936', 'raw_cp936')
|
||||
}
|
||||
}
|
||||
|
||||
let best = null
|
||||
let bestScore = -Infinity
|
||||
for (const cand of candidates) {
|
||||
if (!cand.s) continue
|
||||
if (/\uFFFD/.test(cand.s) && cand.src !== 'origin') continue
|
||||
const s = cand.s.replace(/\u0000/g, '')
|
||||
if (!s) continue
|
||||
let sc = scoreChinese(s)
|
||||
if (cand.src === 'origin' && /[ÃÂÄÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßçèéêëìíîïðñòóôõöøùúûüýþÿ]/.test(s)) sc -= 15
|
||||
if (cand.src === 'origin' && hasRepeatedMojibake(s)) sc -= 30
|
||||
if (/[ÃÂÄÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßçèéêëìíîïðñòóôõöøùúûüýþÿ]/.test(s) && cand.src !== 'origin') sc -= 10
|
||||
if (sc > bestScore) { bestScore = sc; best = s }
|
||||
}
|
||||
|
||||
if (!best) best = name
|
||||
if (best.length > 255) best = best.slice(0, 250) + (ext || '')
|
||||
return best
|
||||
}
|
||||
|
||||
const DB_PATH = path.join(process.cwd(), 'data', 'mp_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,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
})
|
||||
|
||||
const mapAttachment = row => {
|
||||
const original = String(row && row.file_name != null ? row.file_name : '')
|
||||
const fixed = fixFileNameEncoding(original)
|
||||
if (original && fixed && original !== fixed) {
|
||||
try {
|
||||
getDb().prepare(`UPDATE attachments SET file_name = ? WHERE id = ? AND file_name = ?`)
|
||||
.run(fixed, row.id, original)
|
||||
} catch {}
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
favoriteId: row.favorite_id,
|
||||
favorite_id: row.favorite_id,
|
||||
fileName: fixed,
|
||||
file_name: fixed,
|
||||
storedName: row.stored_name,
|
||||
stored_name: row.stored_name,
|
||||
filePath: row.file_path,
|
||||
file_path: row.file_path,
|
||||
fileSize: Number(row.file_size || 0),
|
||||
file_size: Number(row.file_size || 0),
|
||||
mimeType: row.mime_type,
|
||||
mime_type: row.mime_type,
|
||||
isImage: !!row.is_image,
|
||||
is_image: !!row.is_image,
|
||||
createdAt: row.created_at,
|
||||
created_at: 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,
|
||||
getDb,
|
||||
nowIso,
|
||||
}
|
||||
Reference in New Issue
Block a user