docDB/doc.go

310 lines
7.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package docDB
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"apigo.cc/go/cast"
"apigo.cc/go/file"
"apigo.cc/go/id"
"apigo.cc/go/tableDB"
)
// DocDB 实例
type DocDB struct {
db *tableDB.TableDBUnauthorized
baseDir string
onCreatedDoc func(doc *Document)
onUpdatedDoc func(doc *Document)
onRemoved func(id string)
}
// App 实例 (带用户权限)
type App struct {
docDB *DocDB
userID string
table *tableDB.Table
}
// Document 结构体
type Document struct {
ID string // 唯一标识符
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 // 存储方式 (file/db)
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
}
// New 创建 DocDB 实例
func New(db *tableDB.TableDBUnauthorized, baseDir string) *DocDB {
return &DocDB{
db: db,
baseDir: baseDir,
}
}
// Auth 授权并初始化表结构
func (d *DocDB) Auth(userID string) *App {
appTableDB := d.db.Auth(userID)
// 检查 _Doc 表是否存在,不存在则初始化
res, _ := appTableDB.Table("_Table").Get("_Doc")
if res == nil {
// 使用高级接口 SetTable 和 SetField
_ = appTableDB.SetTable(tableDB.TableSchema{ID: "_Doc", Name: "_Doc", Memo: "Document Repository"})
_ = appTableDB.Table("_Doc").SetField(
tableDB.FieldSchema{Name: "path", Type: "v256", IsIndex: true},
tableDB.FieldSchema{Name: "title", Type: "v255"},
tableDB.FieldSchema{Name: "textContent", Type: "t"},
tableDB.FieldSchema{Name: "filePath", Type: "v256"},
tableDB.FieldSchema{Name: "thumbnail", Type: "v256"},
tableDB.FieldSchema{Name: "summary", Type: "t"},
tableDB.FieldSchema{Name: "metadata", Type: "o"},
tableDB.FieldSchema{Name: "toc", Type: "o"},
tableDB.FieldSchema{Name: "tags", Type: "v1024"},
tableDB.FieldSchema{Name: "language", Type: "v32"},
tableDB.FieldSchema{Name: "type", Type: "v32"},
tableDB.FieldSchema{Name: "storeType", Type: "v16"},
tableDB.FieldSchema{Name: "asFile", Type: "b"},
tableDB.FieldSchema{Name: "version", Type: "bi"},
tableDB.FieldSchema{Name: "isSecret", Type: "b"},
tableDB.FieldSchema{Name: "updateTime", Type: "bi"},
tableDB.FieldSchema{Name: "updater", Type: "v64"},
tableDB.FieldSchema{Name: "createTime", Type: "bi"},
tableDB.FieldSchema{Name: "creator", Type: "v64"},
)
}
return &App{
docDB: d,
userID: userID,
table: appTableDB.Table("_Doc"),
}
}
// SetDoc 更新内容 (强制提升版本号)
func (a *App) SetDoc(docObj *Document) error {
if docObj.ID == "" {
docObj.ID = id.MakeID(10)
}
now := time.Now().UnixMilli()
existing, err := a.table.Get(docObj.ID)
isUpdate := err == nil && existing != nil
if isUpdate {
docObj.Version = cast.Uint64(existing["version"]) + 1
docObj.CreateTime = cast.Int64(existing["createTime"])
docObj.Creator = cast.String(existing["creator"])
} else {
docObj.Version = 1
docObj.CreateTime = now
docObj.Creator = a.userID
}
docObj.UpdateTime = now
docObj.Updater = a.userID
// 处理文件落盘
if docObj.BinaryContent != nil {
if err := a.saveFile(docObj); err != nil {
return err
}
docObj.AsFile = true
docObj.StoreType = "file"
}
// 自动提取 ToC
if docObj.ToC == nil && (docObj.Type == "markdown" || strings.HasSuffix(strings.ToLower(docObj.Path), ".md")) {
docObj.ToC = ExtractToC(docObj.TextContent)
}
// 转换为 map 并清理瞬态/冲突字段
record := a.toRecord(docObj)
err = a.table.Set(record)
if err == nil {
if isUpdate {
a.docDB.triggerUpdated(docObj)
} else {
a.docDB.triggerCreated(docObj)
}
}
return err
}
// SetMeta 更新元数据 (不提升版本号)
func (a *App) SetMeta(id string, meta map[string]any) error {
existing, err := a.table.Get(id)
if err != nil || existing == nil {
return fmt.Errorf("document not found: %s", id)
}
now := time.Now().UnixMilli()
for k, v := range meta {
existing[k] = v
}
existing["updateTime"] = now
existing["updater"] = a.userID
return a.table.Set(existing)
}
func (a *App) toRecord(docObj *Document) map[string]any {
record := make(map[string]any)
cast.Convert(&record, docObj)
// tableDB 内部约定使用小写 id这里强制转换以避免产生重复字段
if v, ok := record["ID"]; ok {
record["id"] = v
delete(record, "ID")
}
// 移除瞬态字段
delete(record, "binaryContent")
return record
}
func (a *App) saveFile(docObj *Document) error {
subDir1 := docObj.ID[0:2]
subDir2 := docObj.ID[2:6]
subDir3 := docObj.ID[6:10]
ext := filepath.Ext(docObj.Path)
fileName := fmt.Sprintf("v%d%s", docObj.Version, ext)
filePath := filepath.Join(a.docDB.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 *App) Get(id string) (*Document, error) {
res, err := a.table.Get(id)
if err != nil || res == nil {
return nil, err
}
var doc Document
cast.Convert(&doc, res)
return &doc, nil
}
// Remove 删除文档
func (a *App) Remove(id string) error {
err := a.table.Remove(id)
if err == nil {
a.docDB.triggerRemoved(id)
}
return err
}
// 事件订阅
func (d *DocDB) OnCreatedDoc(fn func(doc *Document)) { d.onCreatedDoc = fn }
func (d *DocDB) OnUpdatedDoc(fn func(doc *Document)) { d.onUpdatedDoc = fn }
func (d *DocDB) OnRemoved(fn func(id string)) { d.onRemoved = fn }
func (d *DocDB) triggerCreated(doc *Document) {
if d.onCreatedDoc != nil {
d.onCreatedDoc(doc)
}
}
func (d *DocDB) triggerUpdated(doc *Document) {
if d.onUpdatedDoc != nil {
d.onUpdatedDoc(doc)
}
}
func (d *DocDB) triggerRemoved(id string) {
if d.onRemoved != nil {
d.onRemoved(id)
}
}
// ExtractToC 保持提取目录逻辑
func ExtractToC(content string) []ToCNode {
lines := strings.Split(content, "\n")
var root []ToCNode
stack := []*[]ToCNode{&root}
for _, line := range lines {
trimmed := strings.TrimSpace(line)
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
}