Files
larksuite-cli/shortcuts/doc/docs_search.go
evandance 201e3e016f feat(doc): emit typed error envelopes across the doc domain (#1346)
Emit structured validation, API, network, file, and internal error envelopes for Doc shortcuts so users and agents can recover from failed document workflows using stable type, subtype, param, and code fields.

Add Doc domain errscontract and golangci guards to prevent legacy envelope and common helper regressions.
2026-06-09 20:43:20 +08:00

341 lines
8.6 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"context"
"encoding/json"
"fmt"
"io"
"math"
"regexp"
"strconv"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
var DocsSearch = common.Shortcut{
Service: "docs",
Command: "+search",
Description: "Search Lark docs, Wiki, and spreadsheet files (Search v2: doc_wiki/search)",
Risk: "read",
Scopes: []string{"search:docs:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "query", Desc: "search keyword"},
{Name: "filter", Desc: "filter conditions (JSON object)"},
{Name: "page-token", Desc: "page token"},
{Name: "page-size", Default: "15", Desc: "page size (default 15, max 20)"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
requestData, err := buildDocsSearchRequest(
runtime.Str("query"),
runtime.Str("filter"),
runtime.Str("page-token"),
runtime.Str("page-size"),
)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
POST("/open-apis/search/v2/doc_wiki/search").
Body(requestData)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
requestData, err := buildDocsSearchRequest(
runtime.Str("query"),
runtime.Str("filter"),
runtime.Str("page-token"),
runtime.Str("page-size"),
)
if err != nil {
return err
}
data, err := runtime.CallAPITyped("POST", "/open-apis/search/v2/doc_wiki/search", nil, requestData)
if err != nil {
return err
}
items, _ := data["res_units"].([]interface{})
// Add ISO time fields
normalizedItems := addIsoTimeFields(items)
resultData := map[string]interface{}{
"total": data["total"],
"has_more": data["has_more"],
"page_token": data["page_token"],
"results": normalizedItems,
}
runtime.OutFormat(resultData, &output.Meta{Count: len(normalizedItems)}, func(w io.Writer) {
if len(normalizedItems) == 0 {
fmt.Fprintln(w, "No matching results found.")
return
}
// Table output
htmlTagRe := regexp.MustCompile(`</?h>`)
var rows []map[string]interface{}
for _, item := range normalizedItems {
u, _ := item.(map[string]interface{})
if u == nil {
continue
}
rawTitle := fmt.Sprintf("%v", u["title_highlighted"])
title := htmlTagRe.ReplaceAllString(rawTitle, "")
title = common.TruncateStr(title, 50)
resultMeta, _ := u["result_meta"].(map[string]interface{})
docTypes := ""
if resultMeta != nil {
docTypes = fmt.Sprintf("%v", resultMeta["doc_types"])
}
entityType := fmt.Sprintf("%v", u["entity_type"])
typeStr := docTypes
if typeStr == "" || typeStr == "<nil>" {
typeStr = entityType
}
url := ""
editTime := ""
if resultMeta != nil {
url = fmt.Sprintf("%v", resultMeta["url"])
editTime = fmt.Sprintf("%v", resultMeta["update_time_iso"])
}
if len(url) > 80 {
url = url[:80]
}
rows = append(rows, map[string]interface{}{
"type": typeStr,
"title": title,
"edit_time": editTime,
"url": url,
})
}
output.PrintTable(w, rows)
moreHint := ""
hasMore, _ := data["has_more"].(bool)
if hasMore {
moreHint = " (more available, use --format json to get page_token, then --page-token to paginate)"
}
fmt.Fprintf(w, "\n%d result(s)%s\n", len(rows), moreHint)
})
return nil
},
}
func buildDocsSearchRequest(query, filterStr, pageToken, pageSizeStr string) (map[string]interface{}, error) {
pageSize, _ := strconv.Atoi(pageSizeStr)
if pageSize <= 0 {
pageSize = 15
}
if pageSize > 20 {
pageSize = 20
}
requestData := map[string]interface{}{
"query": query,
"page_size": pageSize,
}
if pageToken != "" {
requestData["page_token"] = pageToken
}
if filterStr == "" {
requestData["doc_filter"] = map[string]interface{}{}
requestData["wiki_filter"] = map[string]interface{}{}
return requestData, nil
}
var filter map[string]interface{}
if err := json.Unmarshal([]byte(filterStr), &filter); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--filter is not valid JSON").WithParam("--filter").WithCause(err)
}
if err := convertTimeRangeInFilter(filter, "open_time"); err != nil {
return nil, err
}
if err := convertTimeRangeInFilter(filter, "create_time"); err != nil {
return nil, err
}
hasFolderTokens := hasNonEmptyFilterArray(filter, "folder_tokens")
hasSpaceIDs := hasNonEmptyFilterArray(filter, "space_ids")
if hasFolderTokens && hasSpaceIDs {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--filter cannot contain both folder_tokens and space_ids; doc and wiki scoped search cannot be combined").WithParam("--filter")
}
docFilter := cloneFilterMap(filter)
delete(docFilter, "space_ids")
wikiFilter := cloneFilterMap(filter)
delete(wikiFilter, "folder_tokens")
switch {
case hasFolderTokens:
requestData["doc_filter"] = docFilter
case hasSpaceIDs:
requestData["wiki_filter"] = wikiFilter
default:
requestData["doc_filter"] = docFilter
requestData["wiki_filter"] = wikiFilter
}
return requestData, nil
}
func cloneFilterMap(src map[string]interface{}) map[string]interface{} {
dst := make(map[string]interface{}, len(src))
for k, v := range src {
dst[k] = v
}
return dst
}
func hasNonEmptyFilterArray(filter map[string]interface{}, key string) bool {
val, ok := filter[key]
if !ok || val == nil {
return false
}
items, ok := val.([]interface{})
return ok && len(items) > 0
}
// convertTimeRangeInFilter converts ISO 8601 time range to Unix seconds.
func convertTimeRangeInFilter(filter map[string]interface{}, key string) error {
val, ok := filter[key]
if !ok {
return nil
}
rangeMap, ok := val.(map[string]interface{})
if !ok {
return nil
}
result := make(map[string]interface{})
if start, ok := rangeMap["start"].(string); ok && start != "" {
startTime, err := toUnixSeconds(start)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid %s.start %q: %s", key, start, err).WithParam("--filter").WithCause(err)
}
result["start"] = startTime
}
if end, ok := rangeMap["end"].(string); ok && end != "" {
endTime, err := toUnixSeconds(end)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid %s.end %q: %s", key, end, err).WithParam("--filter").WithCause(err)
}
result["end"] = endTime
}
filter[key] = result
return nil
}
func toUnixSeconds(input string) (int64, error) {
formats := []string{
time.RFC3339,
"2006-01-02T15:04:05",
"2006-01-02 15:04:05",
"2006-01-02",
}
for _, f := range formats {
if t, err := time.ParseInLocation(f, input, time.Local); err == nil {
return t.Unix(), nil
}
}
// Try as number
if n, err := strconv.ParseInt(input, 10, 64); err == nil {
return n, nil
}
return 0, fmt.Errorf("expected RFC3339, YYYY-MM-DD[ HH:MM:SS], or unix seconds") //nolint:forbidigo // intermediate parse helper; caller wraps into typed ValidationError
}
func unixTimestampToISO8601(v interface{}) string {
if v == nil {
return ""
}
var num float64
switch val := v.(type) {
case float64:
num = val
case json.Number:
parsed, err := val.Float64()
if err != nil {
return ""
}
num = parsed
case string:
parsed, err := strconv.ParseFloat(val, 64)
if err != nil {
return ""
}
num = parsed
default:
return ""
}
if math.IsInf(num, 0) || math.IsNaN(num) {
return ""
}
// Heuristic: >= 1e12 treat as ms, else seconds
ms := int64(num)
if num >= 1e12 {
ms = ms / 1000
}
t := time.Unix(ms, 0)
return t.Format(time.RFC3339)
}
// addIsoTimeFields recursively adds *_time_iso fields.
func addIsoTimeFields(value interface{}) []interface{} {
if arr, ok := value.([]interface{}); ok {
result := make([]interface{}, len(arr))
for i, item := range arr {
result[i] = addIsoTimeFieldsOne(item)
}
return result
}
return nil
}
func addIsoTimeFieldsOne(value interface{}) interface{} {
switch v := value.(type) {
case []interface{}:
result := make([]interface{}, len(v))
for i, item := range v {
result[i] = addIsoTimeFieldsOne(item)
}
return result
case map[string]interface{}:
out := make(map[string]interface{})
for key, item := range v {
if strings.HasSuffix(key, "_time_iso") {
out[key] = item
continue
}
out[key] = addIsoTimeFieldsOne(item)
if strings.HasSuffix(key, "_time") {
iso := unixTimestampToISO8601(item)
if iso != "" {
out[key+"_iso"] = iso
}
}
}
return out
default:
return value
}
}