package toc
import (
"fmt"
"html"
"regexp"
"strings"
"unicode/utf8"
)
var htmlTagRegexp = regexp.MustCompile(`<[^>]*>`)
// htmlTextLength 计算剥离 HTML 标签后的字符数,匹配 Telegram 的计数方式
func htmlTextLength(s string) int {
stripped := htmlTagRegexp.ReplaceAllString(s, "")
return utf8.RuneCountInString(stripped)
}
// renderLines 将所有分类和条目渲染为独立的行
func (m *Manager) renderLines() (header string, lines []string, footer string, err error) {
categories, err := m.storage.ListCategories()
if err != nil {
return "", nil, "", err
}
entriesByCategory, err := m.storage.GetEntriesByCategory()
if err != nil {
return "", nil, "", err
}
header = "📚 频道目录\n━━━━━━━━━━━━━━━\n\n"
footer = "━━━━━━━━━━━━━━━\n"
if len(categories) == 0 {
lines = append(lines, "暂无分类\n")
return header, lines, footer, nil
}
for _, cat := range categories {
entries := entriesByCategory[cat.Name]
catLine := fmt.Sprintf("📁 %s", html.EscapeString(cat.Name))
if len(entries) > 0 {
catLine += fmt.Sprintf(" (%d)", len(entries))
}
catLine += "\n"
lines = append(lines, catLine)
if len(entries) == 0 {
lines = append(lines, " 暂无内容\n")
} else {
for _, entry := range entries {
entryLine := fmt.Sprintf(" • %s", html.EscapeString(entry.MessageLink()), html.EscapeString(entry.Title))
if entry.HasDirectLink() {
entryLine += fmt.Sprintf(" [直达]", html.EscapeString(entry.DirectLink))
}
entryLine += "\n"
lines = append(lines, entryLine)
}
}
// 分类之间空行
lines = append(lines, "\n")
}
return header, lines, footer, nil
}
// RenderPage 渲染指定页的 TOC 内容,返回内容、总页数
func (m *Manager) RenderPage(page, charLimit int) (string, int, error) {
header, lines, footer, err := m.renderLines()
if err != nil {
return "", 0, err
}
// 页码行的最大长度预估(如 "📄 99 / 99\n")
pageIndicatorLen := 15
headerLen := htmlTextLength(header)
footerLen := htmlTextLength(footer)
fixedLen := headerLen + footerLen + pageIndicatorLen
// 将行分配到各页
var pages [][]string
var currentPage []string
currentLen := fixedLen
for _, line := range lines {
lineLen := htmlTextLength(line)
// 如果当前页加上这行会超限,且当前页已有内容,则开新页
if currentLen+lineLen > charLimit && len(currentPage) > 0 {
pages = append(pages, currentPage)
currentPage = nil
currentLen = fixedLen
}
currentPage = append(currentPage, line)
currentLen += lineLen
}
// 最后一页
if len(currentPage) > 0 {
pages = append(pages, currentPage)
}
totalPages := len(pages)
if totalPages == 0 {
totalPages = 1
pages = [][]string{{}}
}
// clamp page
if page < 1 {
page = 1
}
if page > totalPages {
page = totalPages
}
// 组装当前页内容
var sb strings.Builder
sb.WriteString(header)
for _, line := range pages[page-1] {
sb.WriteString(line)
}
sb.WriteString(footer)
if totalPages > 1 {
sb.WriteString(fmt.Sprintf("📄 %d / %d\n", page, totalPages))
}
return sb.String(), totalPages, nil
}
// Render 渲染完整 TOC(兼容方法,返回第 1 页,使用 photo caption 限制)
func (m *Manager) Render() (string, error) {
content, _, err := m.RenderPage(1, captionCharLimit)
return content, err
}