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 }