feat: 新增运动节拍训练工具套件与子工具页面

新增运动节拍RepClock全套小程序页面、后端API与数据库服务,新增subhub子工具汇总页面,优化全局主页样式与配置,为web静态资源添加版本缓存参数,补充配套调试脚本
This commit is contained in:
yangxiangyuan
2026-09-09 14:43:06 +08:00
parent db7648eb4d
commit d4bf3627fd
69 changed files with 5759 additions and 297 deletions
+282
View File
@@ -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 }
+332
View File
@@ -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,
}