310 lines
7.7 KiB
Go
310 lines
7.7 KiB
Go
|
|
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
|
|||
|
|
}
|