docDB/doc.go

421 lines
10 KiB
Go
Raw Normal View History

package docDB
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"apigo.cc/go/cast"
"apigo.cc/go/file"
"apigo.cc/go/log"
"apigo.cc/go/tableDB"
)
// Hooks 生命周期钩子
type Hooks struct {
OnUpdatedDoc func(doc *Document)
OnRemovedDoc func(path string)
OnUpdatedToC func(path string, toc []ToCNode)
OnRemovedToC func(path string)
}
// DocDBUnauthorized 实例
type DocDBUnauthorized struct {
db *tableDB.TableDBUnauthorized
baseDir string
Hooks *Hooks
}
// DocDB 实例 (带用户权限)
type DocDB struct {
parent *DocDBUnauthorized
userID string
table *tableDB.Table
hist *tableDB.Table
}
// Document 结构体
type Document struct {
Path string // 层级路径 (唯一标识)
Title string // 标题
TextContent string // 纯文本
BinaryContent io.Reader `json:"-"` // 仅为内存瞬态,流式读取
FilePath string // 原文件落盘绝对路径
Thumbnail string // 缩略图路径
Summary string // 摘要
Metadata map[string]any // 元数据
ToC []ToCNode // 目录树
Tags string // 标签
Language string // 语言
Type string // 类型
StoreType string // 存储方式
AsFile bool // 是否以文件存储
Version uint64 // 版本号
IsSecret bool // 是否加密
CreateTime int64 // 创建时间
UpdateTime int64 // 更新时间
Creator string // 创建人 ID
Updater string // 更新人 ID
}
// ToCNode 目录树节点
type ToCNode struct {
Title string
Level int
Children []ToCNode
}
// GetDB 获取 DocDBUnauthorized 实例并初始化表结构
func GetDB(dsn string, logger *log.Logger, redis string, baseDir string) *DocDBUnauthorized {
unauthorizedDB := tableDB.GetDB(dsn, logger, redis)
sys := unauthorizedDB.Auth(tableDB.SystemUserID)
if raw, err := sys.GetRawDB(); err == nil {
_ = raw.Sync(tableDB.SystemSchema)
}
initTable := func(tableName string) {
res, _ := sys.Table("_Table").Get(tableName)
if res == nil {
_ = sys.SetTable(tableDB.TableSchema{ID: tableName, Name: tableName, Memo: "Document Storage", EnableRLS: true})
fields := []tableDB.FieldSchema{
{Name: "path", Type: "v256", IsIndex: true},
{Name: "title", Type: "v255"},
{Name: "textContent", Type: "t"},
{Name: "filePath", Type: "v256"},
{Name: "thumbnail", Type: "v256"},
{Name: "summary", Type: "t"},
{Name: "metadata", Type: "o"},
{Name: "toc", Type: "o"},
{Name: "tags", Type: "v1024"},
{Name: "language", Type: "v32"},
{Name: "type", Type: "v32"},
{Name: "storeType", Type: "v16"},
{Name: "asFile", Type: "b"},
{Name: "version", Type: "bi"},
{Name: "isSecret", Type: "b"},
{Name: "updateTime", Type: "bi"},
{Name: "updater", Type: "v64"},
{Name: "createTime", Type: "bi"},
{Name: "creator", Type: "v64"},
}
if tableName == "_Doc_History" {
fields = append(fields, tableDB.FieldSchema{Name: "docId", Type: "c10", IsIndex: true})
}
_ = sys.Table(tableName).SetField(fields...)
}
}
initTable("_Doc")
initTable("_Doc_History")
return &DocDBUnauthorized{
db: unauthorizedDB,
baseDir: baseDir,
Hooks: &Hooks{},
}
}
// Auth 授权并获取 DocDB
func (d *DocDBUnauthorized) Auth(userID string) *DocDB {
appTableDB := d.db.Auth(userID)
return &DocDB{
parent: d,
userID: userID,
table: appTableDB.Table("_Doc"),
hist: appTableDB.Table("_Doc_History"),
}
}
// SetDoc 更新内容 (强制提升版本号)
func (a *DocDB) SetDoc(docObj *Document) error {
existing, _ := a.getRaw(docObj.Path)
var internalID string
if existing != nil {
a.triggerRemovedToC(docObj.Path)
a.archive(existing)
internalID = cast.String(existing["id"])
docObj.Version = cast.Uint64(existing["version"]) + 1
} else {
docObj.Version = 1
}
if docObj.BinaryContent != nil {
if internalID == "" {
placeholder := map[string]any{"path": docObj.Path, "version": docObj.Version}
_ = a.table.Set(placeholder)
existing, _ = a.getRaw(docObj.Path)
if existing != nil {
internalID = cast.String(existing["id"])
}
}
if err := a.saveFile(internalID, docObj); err != nil {
return err
}
docObj.AsFile = true
docObj.StoreType = "file"
}
if docObj.ToC == nil && (docObj.Type == "markdown" || strings.HasSuffix(strings.ToLower(docObj.Path), ".md")) {
docObj.ToC = ExtractToC(docObj.TextContent)
}
record := a.toRecord(internalID, docObj)
err := a.table.Set(record)
if err == nil {
a.triggerUpdatedDoc(docObj)
if docObj.ToC != nil {
a.triggerUpdatedToC(docObj.Path, docObj.ToC)
}
}
return err
}
// SetMeta 更新元数据 (不提升版本号)
func (a *DocDB) SetMeta(path string, meta map[string]any) error {
existing, err := a.getRaw(path)
if err != nil || existing == nil {
return fmt.Errorf("document not found: %s", path)
}
// 检查 TextContent 变更
if text, ok := meta["textContent"].(string); ok {
docType := cast.String(existing["type"])
if docType == "markdown" || strings.HasSuffix(strings.ToLower(path), ".md") {
a.triggerRemovedToC(path)
newToC := ExtractToC(text)
meta["toc"] = newToC
a.triggerUpdatedToC(path, newToC)
}
}
for k, v := range meta {
existing[k] = v
}
return a.table.Set(existing)
}
// Move 修改路径 (重命名)
func (a *DocDB) Move(oldPath, newPath string) error {
existing, err := a.getRaw(oldPath)
if err != nil || existing == nil {
return fmt.Errorf("document not found: %s", oldPath)
}
// 触发删除旧路径事件
a.triggerRemovedDoc(oldPath)
if existing["toc"] != nil {
a.triggerRemovedToC(oldPath)
}
existing["path"] = newPath
err = a.table.Set(existing)
if err == nil {
newDoc, _ := a.Get(newPath)
if newDoc != nil {
a.triggerUpdatedDoc(newDoc)
if newDoc.ToC != nil {
a.triggerUpdatedToC(newPath, newDoc.ToC)
}
}
}
return err
}
func (a *DocDB) getRaw(path string) (map[string]any, error) {
res, err := a.table.List(map[string]any{"path": path})
if err != nil || len(res) == 0 {
return nil, err
}
return res[0], nil
}
func (a *DocDB) archive(existing map[string]any) {
histRecord := make(map[string]any)
for k, v := range existing {
histRecord[k] = v
}
histRecord["docId"] = cast.String(existing["id"])
delete(histRecord, "id")
_ = a.hist.Set(histRecord)
}
func (a *DocDB) toRecord(internalID string, docObj *Document) map[string]any {
record := make(map[string]any)
cast.Convert(&record, docObj)
if internalID != "" {
record["id"] = internalID
}
delete(record, "binaryContent")
return record
}
func (a *DocDB) saveFile(internalID string, docObj *Document) error {
subDir1 := internalID[0:2]
subDir2 := internalID[2:6]
subDir3 := internalID[6:10]
ext := filepath.Ext(docObj.Path)
fileName := fmt.Sprintf("v%d%s", docObj.Version, ext)
filePath := filepath.Join(a.parent.baseDir, "doc", subDir1, subDir2, subDir3, fileName)
file.EnsureParentDir(filePath)
out, err := os.Create(filePath)
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, docObj.BinaryContent); err != nil {
return err
}
docObj.FilePath = filePath
return nil
}
// Get 获取文档 (当前版本)
func (a *DocDB) Get(path string) (*Document, error) {
res, err := a.getRaw(path)
if err != nil || res == nil {
return nil, err
}
var doc Document
cast.Convert(&doc, res)
return &doc, nil
}
// GetByVersion 按版本获取文档
func (a *DocDB) GetByVersion(path string, version uint64) (*Document, error) {
current, _ := a.Get(path)
if current != nil && current.Version == version {
return current, nil
}
res, _ := a.table.List(map[string]any{"path": path})
var docID string
if len(res) > 0 {
docID = cast.String(res[0]["id"])
} else {
histRes, _ := a.hist.List(map[string]any{"path": path})
if len(histRes) > 0 {
docID = cast.String(histRes[0]["docId"])
}
}
if docID == "" {
return nil, fmt.Errorf("document not found: %s", path)
}
histRes, err := a.hist.List(map[string]any{"docId": docID, "version": version})
if err != nil || len(histRes) == 0 {
return nil, err
}
var doc Document
cast.Convert(&doc, histRes[0])
return &doc, nil
}
// Remove 删除文档 (自动保存最后一版到历史)
func (a *DocDB) Remove(path string) error {
existing, err := a.getRaw(path)
if err != nil || existing == nil {
return nil
}
a.triggerRemovedDoc(path)
if existing["toc"] != nil {
a.triggerRemovedToC(path)
}
a.archive(existing)
return a.table.Remove(cast.String(existing["id"]))
}
func (a *DocDB) triggerUpdatedDoc(doc *Document) {
if a.parent.Hooks.OnUpdatedDoc != nil {
a.parent.Hooks.OnUpdatedDoc(doc)
}
}
func (a *DocDB) triggerRemovedDoc(path string) {
if a.parent.Hooks.OnRemovedDoc != nil {
a.parent.Hooks.OnRemovedDoc(path)
}
}
func (a *DocDB) triggerUpdatedToC(path string, toc []ToCNode) {
if a.parent.Hooks.OnUpdatedToC != nil {
a.parent.Hooks.OnUpdatedToC(path, toc)
}
}
func (a *DocDB) triggerRemovedToC(path string) {
if a.parent.Hooks.OnRemovedToC != nil {
a.parent.Hooks.OnRemovedToC(path)
}
}
// ExtractToC 提取 Markdown 目录结构 (带代码块识别)
func ExtractToC(content string) []ToCNode {
lines := strings.Split(content, "\n")
var root []ToCNode
stack := []*[]ToCNode{&root}
inCodeBlock := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "```") {
inCodeBlock = !inCodeBlock
continue
}
if inCodeBlock {
continue
}
if !strings.HasPrefix(trimmed, "#") {
continue
}
level := 0
for level < len(trimmed) && trimmed[level] == '#' {
level++
}
if level > 0 && level < len(trimmed) && trimmed[level] == ' ' {
title := strings.TrimSpace(trimmed[level:])
for len(stack) > level {
stack = stack[:len(stack)-1]
}
for len(stack) < level {
parentSlice := stack[len(stack)-1]
if len(*parentSlice) == 0 {
*parentSlice = append(*parentSlice, ToCNode{
Title: "...",
Level: len(stack),
})
}
lastNode := &((*parentSlice)[len(*parentSlice)-1])
stack = append(stack, &(lastNode.Children))
}
parentSlice := stack[len(stack)-1]
*parentSlice = append(*parentSlice, ToCNode{
Title: title,
Level: level,
})
lastNode := &((*parentSlice)[len(*parentSlice)-1])
stack = append(stack, &(lastNode.Children))
}
}
return root
}