194 lines
6.6 KiB
Go
194 lines
6.6 KiB
Go
package dingtalkdiscovery
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// DocumentNode 是一篇钉钉在线文档/表格,用于「钉钉文档·表格」这一类数据源:
|
||
// 它不在知识库、也不在钉盘的目录树里,只能按节点单独登记。
|
||
type DocumentNode struct {
|
||
NodeID string `json:"node_id"`
|
||
NodeType string `json:"node_type"`
|
||
Name string `json:"name"`
|
||
URL string `json:"url"`
|
||
ContentType string `json:"content_type"`
|
||
UpdatedAt string `json:"updated_at,omitempty"`
|
||
}
|
||
|
||
// DocResult 是一次文档发现的结果。Notice 说明「为什么没列全」,不把失败伪装成空列表。
|
||
type DocResult struct {
|
||
Documents []DocumentNode `json:"documents"`
|
||
Notice string `json:"notice,omitempty"`
|
||
FetchedAt time.Time `json:"fetched_at"`
|
||
}
|
||
|
||
// RecentDocsArgs 列出最近编辑的文档;没有关键词时的默认入口。
|
||
func RecentDocsArgs(limit int) []string {
|
||
if limit <= 0 || limit > 200 {
|
||
limit = 50
|
||
}
|
||
return []string{"drive", "+recent", "--operate-type", "1", "--limit", "20", "--page-all",
|
||
"--max-items", fmt.Sprint(limit), "--max-pages", "10"}
|
||
}
|
||
|
||
// SearchDocsArgs 按关键词在文档空间检索。
|
||
func SearchDocsArgs(query string, limit int) []string {
|
||
if limit <= 0 || limit > 200 {
|
||
limit = 50
|
||
}
|
||
return []string{"drive", "+search-docs", "--query", query, "--limit", fmt.Sprint(limit)}
|
||
}
|
||
|
||
// DocInfoArgs / SheetListArgs 是校验「这篇还能不能读」的只读命令:
|
||
// 文档取元信息,表格取工作表清单——都真实打一次接口,不用缓存谎报可读。
|
||
func DocInfoArgs(node string) []string { return []string{"doc", "info", "--node", node} }
|
||
func SheetListArgs(node string) []string { return []string{"sheet", "list", "--node", node} }
|
||
|
||
// IsSheet 判断节点是否表格:表格要用 sheet 校验,文档用 doc。
|
||
func IsSheet(contentType string) bool {
|
||
kind := strings.ToUpper(strings.TrimSpace(contentType))
|
||
return strings.Contains(kind, "SHEET") || strings.Contains(kind, "ABLE")
|
||
}
|
||
|
||
// DiscoverDocs 枚举可登记的在线文档:有关键词就检索,没有就给最近编辑的一批。
|
||
// 与 Discover 一样,读不到就返回 ErrUnavailable,而不是空结果。
|
||
func DiscoverDocs(ctx context.Context, runner Runner, query string, limit int) (DocResult, error) {
|
||
result := DocResult{FetchedAt: time.Now().UTC()}
|
||
args := RecentDocsArgs(limit)
|
||
if trimmed := strings.TrimSpace(query); trimmed != "" {
|
||
args = SearchDocsArgs(trimmed, limit)
|
||
}
|
||
raw, err := runner.RunDWS(ctx, args...)
|
||
if err != nil {
|
||
return result, fmt.Errorf("%w: %s", ErrUnavailable, err.Error())
|
||
}
|
||
docs, notice, err := parseDocs(raw)
|
||
if err != nil {
|
||
return result, fmt.Errorf("%w: %s", ErrUnavailable, err.Error())
|
||
}
|
||
result.Documents, result.Notice = docs, notice
|
||
return result, nil
|
||
}
|
||
|
||
// ValidateDocument 真实读一次节点,返回它的规范元信息;读不到就是不可登记。
|
||
func ValidateDocument(ctx context.Context, runner Runner, node string) (DocumentNode, error) {
|
||
node = strings.TrimSpace(node)
|
||
if node == "" {
|
||
return DocumentNode{}, fmt.Errorf("%w: empty node", ErrUnavailable)
|
||
}
|
||
raw, err := runner.RunDWS(ctx, DocInfoArgs(node)...)
|
||
if err != nil {
|
||
return DocumentNode{}, fmt.Errorf("%w: %s", ErrUnavailable, err.Error())
|
||
}
|
||
doc, err := parseDocInfo(raw)
|
||
if err != nil {
|
||
return DocumentNode{}, fmt.Errorf("%w: %s", ErrUnavailable, err.Error())
|
||
}
|
||
if doc.NodeID == "" {
|
||
doc.NodeID = node
|
||
}
|
||
if IsSheet(doc.ContentType) {
|
||
if _, err := runner.RunDWS(ctx, SheetListArgs(doc.NodeID)...); err != nil {
|
||
return DocumentNode{}, fmt.Errorf("%w: %s", ErrUnavailable, err.Error())
|
||
}
|
||
}
|
||
return doc, nil
|
||
}
|
||
|
||
// parseDocs 解析 `drive +recent` / `drive +search-docs`:
|
||
// {"ok":true,"data":{"items":[{nodeId,nodeType,name,docUrl,contentType,accessTime}]}}
|
||
// 第二个返回值是「结果不完整」的说明(服务端还有下一页或条目被截断)。
|
||
func parseDocs(raw []byte) ([]DocumentNode, string, error) {
|
||
var payload struct {
|
||
OK bool `json:"ok"`
|
||
Data struct {
|
||
Count int `json:"count"`
|
||
HasMore bool `json:"hasMore"`
|
||
Items []struct {
|
||
NodeID string `json:"nodeId"`
|
||
NodeType string `json:"nodeType"`
|
||
Name string `json:"name"`
|
||
DocURL string `json:"docUrl"`
|
||
ContentType string `json:"contentType"`
|
||
AccessTime string `json:"accessTime"`
|
||
} `json:"items"`
|
||
} `json:"data"`
|
||
}
|
||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||
return nil, "", fmt.Errorf("decode docs: %w", err)
|
||
}
|
||
if !payload.OK {
|
||
return nil, "", errors.New("docs listing reported failure")
|
||
}
|
||
out := make([]DocumentNode, 0, len(payload.Data.Items))
|
||
seen := map[string]bool{}
|
||
for _, item := range payload.Data.Items {
|
||
id := strings.TrimSpace(item.NodeID)
|
||
if id == "" || seen[id] {
|
||
continue
|
||
}
|
||
seen[id] = true
|
||
nodeType := strings.TrimSpace(item.NodeType)
|
||
if nodeType == "" {
|
||
nodeType = "file"
|
||
}
|
||
out = append(out, DocumentNode{
|
||
NodeID: id,
|
||
NodeType: nodeType,
|
||
Name: strings.TrimSpace(item.Name),
|
||
URL: strings.TrimSpace(item.DocURL),
|
||
ContentType: strings.TrimSpace(item.ContentType),
|
||
UpdatedAt: strings.TrimSpace(item.AccessTime),
|
||
})
|
||
}
|
||
notice := ""
|
||
if payload.Data.HasMore {
|
||
notice = fmt.Sprintf("只列出前 %d 篇,还有更多;用关键词检索可以找到其余的。", len(out))
|
||
}
|
||
return out, notice, nil
|
||
}
|
||
|
||
// parseDocInfo 解析 `dws doc info --node`。服务端字段是 camelCase,部分历史响应是
|
||
// snake_case,两种都认,避免因为命名差异把可读文档判成不可读。
|
||
func parseDocInfo(raw []byte) (DocumentNode, error) {
|
||
var payload map[string]any
|
||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||
return DocumentNode{}, fmt.Errorf("decode doc info: %w", err)
|
||
}
|
||
if ok, present := payload["ok"].(bool); present && !ok {
|
||
return DocumentNode{}, errors.New("doc info reported failure")
|
||
}
|
||
source := payload
|
||
if data, ok := payload["data"].(map[string]any); ok {
|
||
source = data
|
||
}
|
||
pick := func(keys ...string) string {
|
||
for _, key := range keys {
|
||
if value, ok := source[key].(string); ok && strings.TrimSpace(value) != "" {
|
||
return strings.TrimSpace(value)
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
doc := DocumentNode{
|
||
NodeID: pick("nodeId", "node_id", "dentUuid", "dentryUuid", "id"),
|
||
NodeType: pick("nodeType", "node_type"),
|
||
Name: pick("name", "title", "docName"),
|
||
URL: pick("docUrl", "doc_url", "url"),
|
||
ContentType: pick("contentType", "content_type", "extension"),
|
||
UpdatedAt: pick("updateTime", "updatedAt", "modifiedTime"),
|
||
}
|
||
if doc.NodeType == "" {
|
||
doc.NodeType = "file"
|
||
}
|
||
if doc.Name == "" {
|
||
return DocumentNode{}, errors.New("doc info has no title")
|
||
}
|
||
return doc, nil
|
||
}
|