chore: 以本地工作区为准全量快照同步 GitHub

包含小程序、管理端、soul-api、脚本与静态资源等当前本地全部已跟踪与新增文件(排除 .DS_Store 与 .obsidian)。

Made-with: Cursor
This commit is contained in:
卡若
2026-04-13 11:49:38 +08:00
parent 83375cc388
commit b5f5180654
142 changed files with 10684 additions and 2670 deletions

View File

@@ -174,52 +174,189 @@ function parseBlockToSegments(block, config) {
return segs
}
/**
* 解析 <table> HTML 为结构化数据 { headers: string[], rows: string[][] }
*/
function parseTableHtml(tableHtml) {
const stripTags = s => decodeEntities((s || '').replace(/<[^>]+>/g, '').trim())
const headers = []
const rows = []
const headMatch = tableHtml.match(/<thead[^>]*>([\s\S]*?)<\/thead>/i)
if (headMatch) {
const thRe = /<th[^>]*>([\s\S]*?)<\/th>/gi
let m
while ((m = thRe.exec(headMatch[1])) !== null) {
headers.push(stripTags(m[1]))
}
}
const bodyMatch = tableHtml.match(/<tbody[^>]*>([\s\S]*?)<\/tbody>/i)
const bodyContent = bodyMatch ? bodyMatch[1] : tableHtml
const trRe = /<tr[^>]*>([\s\S]*?)<\/tr>/gi
let trM
let isFirst = !headMatch
while ((trM = trRe.exec(bodyContent)) !== null) {
if (headMatch && trM[0] === (headMatch[0].match(/<tr[^>]*>[\s\S]*?<\/tr>/i) || [])[0]) continue
const cells = []
const cellRe = /<t[dh][^>]*>([\s\S]*?)<\/t[dh]>/gi
let cM
while ((cM = cellRe.exec(trM[1])) !== null) {
cells.push(stripTags(cM[1]))
}
if (!cells.length) continue
if (isFirst && !headers.length) {
headers.push(...cells)
isFirst = false
} else {
rows.push(cells)
}
}
return { headers, rows }
}
/**
* 从 HTML 中解析出 lines纯文本行和 segments含富文本片段
* segment 新增类型heading / quote / listItem
* @param {string} html
* @param {object} [config] - { persons: [], linkTags: [], assetBase?: string },用于对 text 段自动匹配 @人名 / #标签assetBase 用于补全图片相对 URL
* @param {object} [config] - { persons: [], linkTags: [], assetBase?: string }
*/
function parseHtmlToSegments(html, config) {
const lines = []
const segments = []
// 1. 块级标签换行,保留内联标签供后续解析
let text = html
// 0. 提取 <table> 块
const tables = []
let text = html.replace(/<table[^>]*>[\s\S]*?<\/table>/gi, (match) => {
const idx = tables.length
tables.push(parseTableHtml(match))
return '\n__TABLE_' + idx + '__\n'
})
// 1. 提取 <h2>/<h3> → heading 占位
const headings = []
text = text.replace(/<h([2-6])[^>]*>([\s\S]*?)<\/h\1>/gi, function (_, lvl, inner) {
const idx = headings.length
headings.push({ level: parseInt(lvl, 10), raw: inner.trim() })
return '\n__H_' + idx + '__\n'
})
// 2. 提取 <blockquote> → quote 占位
const quotes = []
text = text.replace(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/gi, function (_, inner) {
const idx = quotes.length
quotes.push(inner.trim())
return '\n__Q_' + idx + '__\n'
})
// 3. 列表 → listItem 占位(保留 ordered 标记)
var olDepth = 0
var olCounter = 0
text = text.replace(/<ol[^>]*>/gi, function () { olDepth++; olCounter = 0; return '\n' })
text = text.replace(/<\/ol>/gi, function () { olDepth = Math.max(0, olDepth - 1); return '\n' })
text = text.replace(/<ul[^>]*>/gi, '\n')
text = text.replace(/<\/ul>/gi, '\n')
text = text.replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, function (_, inner) {
var cleaned = inner.replace(/<p[^>]*>/gi, '').replace(/<\/p>/gi, '').trim()
if (!cleaned) return '\n'
if (olDepth > 0) {
olCounter++
return '\n__LI_O_' + olCounter + '__ ' + cleaned + '\n'
}
return '\n__LI_U__ ' + cleaned + '\n'
})
// 4. 块级标签换行
text = text.replace(/<\/p>\s*<p[^>]*>/gi, '\n\n')
text = text.replace(/<p[^>]*>/gi, '')
text = text.replace(/<\/p>/gi, '\n')
text = text.replace(/<div[^>]*>/gi, '')
text = text.replace(/<\/div>/gi, '\n')
text = text.replace(/<br\s*\/?>/gi, '\n')
text = text.replace(/<\/?h[1-6][^>]*>/gi, '\n')
text = text.replace(/<\/?blockquote[^>]*>/gi, '\n')
text = text.replace(/<\/?ul[^>]*>/gi, '\n')
text = text.replace(/<\/?ol[^>]*>/gi, '\n')
text = text.replace(/<li[^>]*>/gi, '• ')
text = text.replace(/<\/li>/gi, '\n')
text = text.replace(/<hr\s*\/?>/gi, '\n')
// 2. 逐段解析
const blocks = text.split(/\n+/)
for (const block of blocks) {
// 5. 逐段解析
var blocks = text.split(/\n+/)
for (var bi = 0; bi < blocks.length; bi++) {
var block = blocks[bi]
if (!block.trim()) continue
let blockSegs = parseBlockToSegments(block, config)
// table
var tblM = block.trim().match(/^__TABLE_(\d+)__$/)
if (tblM) {
var tbl = tables[parseInt(tblM[1], 10)]
if (tbl) {
lines.push([tbl.headers.join(' | '), ...tbl.rows.map(function (r) { return r.join(' | ') })].join('\n'))
segments.push([{ type: 'table', headers: tbl.headers, rows: tbl.rows }])
}
continue
}
// heading
var hM = block.trim().match(/^__H_(\d+)__$/)
if (hM) {
var h = headings[parseInt(hM[1], 10)]
if (h) {
var hText = decodeEntities(h.raw.replace(/<[^>]+>/g, '').trim())
lines.push(hText)
segments.push([{ type: 'heading', level: h.level, text: hText }])
}
continue
}
// quote
var qM = block.trim().match(/^__Q_(\d+)__$/)
if (qM) {
var qHtml = quotes[parseInt(qM[1], 10)]
if (qHtml) {
var qText = decodeEntities(
qHtml.replace(/<\/?p[^>]*>/gi, '\n').replace(/<[^>]+>/g, '').trim()
).replace(/\n{2,}/g, '\n')
lines.push(qText)
segments.push([{ type: 'quote', text: qText }])
}
continue
}
// ordered list item
var liOM = block.match(/^__LI_O_(\d+)__\s*(.*)/)
if (liOM) {
var liNum = parseInt(liOM[1], 10)
var liContent = liOM[2].trim()
var liSegs = parseBlockToSegments(liContent, config)
var liText = decodeEntities(liContent.replace(/<[^>]+>/g, '').trim())
lines.push(liNum + '. ' + liText)
segments.push([{ type: 'listItem', ordered: true, number: liNum, text: liText, segs: liSegs }])
continue
}
// unordered list item
var liUM = block.match(/^__LI_U__\s*(.*)/)
if (liUM) {
var liContent2 = liUM[1].trim()
var liSegs2 = parseBlockToSegments(liContent2, config)
var liText2 = decodeEntities(liContent2.replace(/<[^>]+>/g, '').trim())
lines.push(liText2)
segments.push([{ type: 'listItem', ordered: false, text: liText2, segs: liSegs2 }])
continue
}
// 普通段落
var blockSegs = parseBlockToSegments(block, config)
if (!blockSegs.length) continue
// 纯图片行独立成段
if (blockSegs.length === 1 && blockSegs[0].type === 'image') {
lines.push('')
segments.push(blockSegs)
continue
}
// 对 text 段再跑一遍 @人名 / #标签 自动匹配(处理未用 TipTap 插入而是手打的 @xxx
if (config && (config.persons?.length || config.linkTags?.length)) {
const expanded = []
for (const seg of blockSegs) {
var expanded = []
for (var si = 0; si < blockSegs.length; si++) {
var seg = blockSegs[si]
if (seg.type === 'text' && seg.text) {
const sub = matchLineToSegments(seg.text, config)
expanded.push(...sub)
expanded.push.apply(expanded, matchLineToSegments(seg.text, config))
} else {
expanded.push(seg)
}
@@ -227,8 +364,7 @@ function parseHtmlToSegments(html, config) {
blockSegs = expanded
}
// 行纯文本用于 linespreviewParagraphs 降级展示)
const lineText = decodeEntities(block.replace(/<[^>]+>/g, '')).trim()
var lineText = decodeEntities(block.replace(/<[^>]+>/g, '')).trim()
lines.push(lineText)
segments.push(blockSegs)
}